feat(hq-web): DocumentView share & download action group

Add a Share & download group to DocumentView: Download PDF (hits
?download=1 via a bearer-authed blob + save anchor), Create/Copy a public
/share/<token> view link with the live-share list and one-click Revoke,
Share on WhatsApp (wa.me/<client digits> prefilled with greeting + doc no
+ total + the share link), and native share (navigator.share of the link,
falling back to Download where unsupported).

Client api.ts gains the Share type, shares on getDocumentFull, createShare/
revokeShare, and downloadDocumentPdf. Vite dev proxies /share to the hq
server so a minted link opens against the dev origin exactly as in prod
(the hq server serves both the SPA and the public /share route on one
origin). Server share/download endpoints and the golden renderer are
unchanged.

Browser-verified: minted a link, opened /share/<token> in a fresh
unauthenticated context (200 application/pdf, viewer renders the doc),
revoked and confirmed a 404 "Link unavailable" page with no data leak, and
confirmed the WhatsApp deep link is prefilled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 1 week ago
parent 25b96e2514
commit cfa1cf81f6

@ -126,6 +126,12 @@ export interface DocumentEvent {
id: string; documentId: string; atWall: string; kind: string; meta: Record<string, unknown>
}
/** A public share link for one document (mirrors apps/hq repos-shares — camelCase JSON). */
export interface Share {
id: string; documentId: string; token: string; createdBy: string
createdAt: string; expiresAt: string | null; revoked: boolean
}
/** email_log rows come straight off the table (snake_case). */
export interface EmailLogRow {
id: string; document_id: string | null; to_addr: string; subject: string
@ -181,7 +187,7 @@ export const patchClientModule = (id: string, body: Record<string, unknown>): Pr
export const createDocument = (body: Record<string, unknown>): Promise<Doc> =>
apiFetch<{ document: Doc }>('/documents', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.document)
export const getDocumentFull = (id: string): Promise<{ document: Doc; events: DocumentEvent[]; emails: EmailLogRow[] }> =>
export const getDocumentFull = (id: string): Promise<{ document: Doc; events: DocumentEvent[]; emails: EmailLogRow[]; shares: Share[] }> =>
apiFetch(`/documents/${id}`)
/** issue / status / convert / cancel / credit-note / send — all POST → { document }. */
export const documentAction = (id: string, action: string, body?: Record<string, unknown>): Promise<Doc> =>
@ -189,6 +195,17 @@ export const documentAction = (id: string, action: string, body?: Record<string,
method: 'POST', body: JSON.stringify(body ?? {}),
}).then((r) => r.document)
/** Mint a public share link for a document. `expiresDays: null` = never expires; omit for the +30d default. */
export const createShare = (id: string, expiresDays?: number | null): Promise<{ share: Share; shares: Share[] }> =>
apiFetch<{ share: Share; shares: Share[] }>(`/documents/${id}/share`, {
method: 'POST', body: JSON.stringify(expiresDays !== undefined ? { expiresDays } : {}),
})
/** Revoke a share (owner-gated, audited); returns the refreshed share list. */
export const revokeShare = (id: string, shareId: string): Promise<{ share: Share; shares: Share[] }> =>
apiFetch<{ share: Share; shares: Share[] }>(`/documents/${id}/share/${shareId}/revoke`, {
method: 'POST', body: '{}',
})
export const recordPayment = (body: Record<string, unknown>): Promise<Payment> =>
apiFetch<{ payment: Payment }>('/payments', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.payment)
export const getLedger = (clientId: string): Promise<Ledger> =>
@ -347,6 +364,31 @@ export async function fetchPdfBlob(documentId: string): Promise<Blob> {
return res.blob()
}
/**
* Download the document PDF as a file. Hits `?download=1` (attachment disposition) but,
* because the route needs the bearer header an <a href> can't carry, fetches a blob and
* saves it via a transient object-URL anchor named after the doc-no (slugged, `/` `-`).
*/
export async function downloadDocumentPdf(documentId: string, docNo: string | null): Promise<void> {
const token = localStorage.getItem(TOKEN_KEY) ?? ''
const res = await fetch(`/api/documents/${documentId}/pdf?download=1`, {
headers: token !== '' ? { authorization: `Bearer ${token}` } : {},
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(body.error ?? `PDF failed: HTTP ${res.status}`)
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${(docNo ?? 'draft').replaceAll('/', '-')}.pdf`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
export interface DocTotals {
taxablePaise: number; cgstPaise: number; sgstPaise: number
igstPaise: number; roundOffPaise: number; payablePaise: number

@ -3,8 +3,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, Notice, PageHeader, Toolbar } from '@sims/ui'
import {
documentAction, fetchPdfBlob, getClient, getDocumentFull,
type Client, type Doc,
createShare, documentAction, downloadDocumentPdf, fetchPdfBlob, getClient, getDocumentFull,
revokeShare, type Client, type Doc, type Share,
} from '../api'
import { DOC_TONE, useData } from './Clients'
import { PaymentForm } from './ClientDetail'
@ -143,6 +143,8 @@ export function DocumentView() {
</Toolbar>
{actionErr !== undefined && <Notice tone="err">{actionErr}</Notice>}
<ShareActions doc={doc} client={client.data} shares={full.data.shares} onChange={full.reload} />
{paying && (
<PaymentForm
clientId={doc.clientId}
@ -191,3 +193,130 @@ export function DocumentView() {
</div>
)
}
/** True while a share is still usable — not revoked and not past its expiry. */
function isLive(s: Share): boolean {
return !s.revoked && (s.expiresAt === null || s.expiresAt > new Date().toISOString())
}
/**
* Share & download action group: download the PDF (attachment), mint/copy a public
* `/share/<token>` view link, list the live links with one-click revoke, and hand the
* link to WhatsApp click-to-chat or the native share sheet (falling back to Download
* where `navigator.share` is unavailable). The public link is same-origin served by
* the hq server (in production it also serves this SPA; in dev vite proxies `/share`).
*/
function ShareActions(props: {
doc: Doc; client: Client | undefined; shares: Share[]; onChange: () => void
}) {
const { doc, client, shares, onChange } = props
const [busy, setBusy] = useState<string | undefined>()
const [err, setErr] = useState<string | undefined>()
const [copied, setCopied] = useState<string | undefined>()
const shareUrl = (token: string): string => `${window.location.origin}/share/${token}`
const live = shares.filter(isLive)
const primary = live[0] // server lists newest-first (uuidv7 id DESC)
const run = (key: string, p: Promise<unknown>) => {
setErr(undefined)
setBusy(key)
p.then(() => onChange()).catch((e: Error) => setErr(e.message)).finally(() => setBusy(undefined))
}
const onDownload = () => {
setErr(undefined)
setBusy('download')
downloadDocumentPdf(doc.id, doc.docNo)
.catch((e: Error) => setErr(e.message))
.finally(() => setBusy(undefined))
}
const onCopy = (url: string) => {
void navigator.clipboard?.writeText(url).then(
() => { setCopied(url); window.setTimeout(() => setCopied(undefined), 1800) },
() => setErr('Could not copy — select the link and copy it manually.'),
)
}
// Greeting + doc type/no + total + the view link — the message that rides the share.
const message = (url: string): string => {
const who = client?.name !== undefined && client.name !== '' ? `Dear ${client.name},` : 'Hello,'
const no = doc.docNo ?? '(draft)'
return `${who}\n\nPlease find your ${TITLE[doc.docType]} ${no} for ${formatINR(doc.payablePaise)}.\n`
+ `\nView it here: ${url}`
}
const onWhatsApp = (url: string) => {
const digits = (client?.contacts.find((c) => c.phone !== undefined && c.phone !== '')?.phone ?? '')
.replace(/\D/g, '')
// With digits → chat opens on that number; without → WhatsApp asks which contact.
window.open(`https://wa.me/${digits}?text=${encodeURIComponent(message(url))}`, '_blank', 'noopener')
}
const onNativeShare = (url: string) => {
const nav = navigator as Navigator & { share?: (d: ShareData) => Promise<void> }
if (typeof nav.share === 'function') {
void nav.share({ title: `${TITLE[doc.docType]} ${doc.docNo ?? ''}`.trim(), text: message(url), url })
.catch(() => { /* user dismissed the sheet — nothing to do */ })
} else {
onDownload() // desktop / unsupported → save the PDF instead
}
}
return (
<section style={{ marginTop: 14 }}>
<h3 style={{ margin: '0 0 8px' }}>Share &amp; download</h3>
<Toolbar>
<Button onClick={onDownload}>{busy === 'download' ? 'Preparing…' : 'Download PDF'}</Button>
{primary === undefined ? (
<Button tone="primary" onClick={() => run('create', createShare(doc.id))}>
{busy === 'create' ? 'Creating…' : 'Create share link'}
</Button>
) : (
<>
<Button onClick={() => onCopy(shareUrl(primary.token))}>
{copied === shareUrl(primary.token) ? 'Copied ✓' : 'Copy link'}
</Button>
<Button onClick={() => onWhatsApp(shareUrl(primary.token))}>WhatsApp</Button>
<Button onClick={() => onNativeShare(shareUrl(primary.token))}>Share</Button>
<Button onClick={() => run('create', createShare(doc.id))}>
{busy === 'create' ? 'Creating…' : 'New link'}
</Button>
</>
)}
</Toolbar>
{err !== undefined && <Notice tone="err">{err}</Notice>}
{live.length === 0 ? (
<p style={{ color: 'var(--text-dim)', fontSize: 13, margin: '2px 0 0' }}>
No active link yet create one to share this {TITLE[doc.docType].toLowerCase()} by link, WhatsApp, or the share sheet.
</p>
) : (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
{live.map((s) => (
<div
key={s.id}
style={{
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
border: '1px solid var(--border)', borderRadius: 8, padding: '8px 10px',
}}
>
<code style={{ fontFamily: 'var(--mono)', fontSize: 12.5, wordBreak: 'break-all', flex: 1, minWidth: 220 }}>
{shareUrl(s.token)}
</code>
<span style={{ fontSize: 12, color: 'var(--text-dim)', whiteSpace: 'nowrap' }}>
{s.expiresAt === null ? 'No expiry' : `Expires ${new Date(s.expiresAt).toLocaleDateString()}`}
</span>
<Button onClick={() => onCopy(shareUrl(s.token))}>
{copied === shareUrl(s.token) ? 'Copied ✓' : 'Copy'}
</Button>
<Button tone="danger" onClick={() => run(`revoke:${s.id}`, revokeShare(doc.id, s.id))}>
{busy === `revoke:${s.id}` ? 'Revoking…' : 'Revoke'}
</Button>
</div>
))}
</div>
)}
</section>
)
}

@ -14,6 +14,8 @@ export default defineConfig({
'@sims/printing': p('../../packages/printing/src/index.ts'),
},
},
// 5182 = apps/hq server (5181 store-server, 5180 backoffice dev).
server: { port: 5183, proxy: { '/api': 'http://localhost:5182' } },
// 5182 = apps/hq server (5181 store-server, 5180 backoffice dev). /share is the public
// document-view route (served by the hq server, outside /api) — proxied so a minted
// link opens against the dev origin exactly as it will in production (one origin).
server: { port: 5183, proxy: { '/api': 'http://localhost:5182', '/share': 'http://localhost:5182' } },
})

Loading…
Cancel
Save