diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index f6c35e1..59d7e06 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -126,6 +126,12 @@ export interface DocumentEvent { id: string; documentId: string; atWall: string; kind: string; meta: Record } +/** 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): Pr export const createDocument = (body: Record): Promise => 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): Promise => @@ -189,6 +195,17 @@ export const documentAction = (id: string, action: string, body?: Record 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): Promise => apiFetch<{ payment: Payment }>('/payments', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.payment) export const getLedger = (clientId: string): Promise => @@ -347,6 +364,31 @@ export async function fetchPdfBlob(documentId: string): Promise { return res.blob() } +/** + * Download the document PDF as a file. Hits `?download=1` (attachment disposition) but, + * because the route needs the bearer header an 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 { + 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 diff --git a/apps/hq-web/src/pages/DocumentView.tsx b/apps/hq-web/src/pages/DocumentView.tsx index 58534ab..676e267 100644 --- a/apps/hq-web/src/pages/DocumentView.tsx +++ b/apps/hq-web/src/pages/DocumentView.tsx @@ -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() { {actionErr !== undefined && {actionErr}} + + {paying && ( ) } + +/** 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/` 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() + const [err, setErr] = useState() + const [copied, setCopied] = useState() + + 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) => { + 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 } + 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 ( +
+

Share & download

+ + + {primary === undefined ? ( + + ) : ( + <> + + + + + + )} + + {err !== undefined && {err}} + {live.length === 0 ? ( +

+ No active link yet — create one to share this {TITLE[doc.docType].toLowerCase()} by link, WhatsApp, or the share sheet. +

+ ) : ( +
+ {live.map((s) => ( +
+ + {shareUrl(s.token)} + + + {s.expiresAt === null ? 'No expiry' : `Expires ${new Date(s.expiresAt).toLocaleDateString()}`} + + + +
+ ))} +
+ )} +
+ ) +} diff --git a/apps/hq-web/vite.config.ts b/apps/hq-web/vite.config.ts index 1f9104e..28951f2 100644 --- a/apps/hq-web/vite.config.ts +++ b/apps/hq-web/vite.config.ts @@ -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' } }, })