You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/pages/DocumentView.tsx

413 lines
17 KiB
TypeScript

import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import {
Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, Notice, RecordHeader, Skeleton,
Toolbar, useToast,
} from '@sims/ui'
import {
createShare, documentAction, downloadDocumentPdf, fetchPdfBlob, getClient, getDocumentFull,
revokeShare, type Client, type Doc, type Share,
} from '../api'
import { SendDialog } from '../components/SendDialog'
import { DOC_TONE, useData } from './Clients'
import { PaymentForm } from './client-forms'
/** One pending confirmation — replaces the browser confirm() popup with the styled dialog. */
interface Confirm { title: string; body: string; label: string; tone?: 'danger'; run: () => void }
const TITLE: Record<Doc['docType'], string> = {
QUOTATION: 'Quotation', PROFORMA: 'Proforma Invoice', INVOICE: 'Tax Invoice',
RECEIPT: 'Receipt', CREDIT_NOTE: 'Credit Note',
}
/** Friendly text for the send-route's 409s (the spec's token-death banner). */
function friendly(msg: string): string {
if (msg === 'gmail-token-dead') return 'Gmail token dead — reconnect by running gmail-connect on the server, then retry.'
if (msg === 'gmail-not-connected') return 'Gmail is not connected — run gmail-connect on the server first.'
return msg
}
/** Document page: PDF preview, state-driven action bar, event timeline + email log. */
export function DocumentView() {
const id = useParams()['id'] ?? ''
const nav = useNavigate()
const full = useData(() => getDocumentFull(id), [id])
const doc = full.data?.document
const client = useData<Client | undefined>(
() => (doc !== undefined ? getClient(doc.clientId) : Promise.resolve(undefined)),
[doc?.clientId],
)
const toast = useToast()
const [busy, setBusy] = useState<string | undefined>()
const [paying, setPaying] = useState(false)
const [sending, setSending] = useState(false)
const [confirm, setConfirm] = useState<Confirm | undefined>()
// PDF preview via blob URL — an <iframe src> can't carry the bearer header.
const [pdfUrl, setPdfUrl] = useState<string | undefined>()
const [pdfErr, setPdfErr] = useState<string | undefined>()
useEffect(() => {
if (doc === undefined) return
let alive = true
let url: string | undefined
setPdfErr(undefined)
fetchPdfBlob(id)
.then((blob) => {
if (!alive) return
url = URL.createObjectURL(blob)
setPdfUrl(url)
})
.catch((e: Error) => { if (alive) setPdfErr(e.message) })
return () => {
alive = false
if (url !== undefined) URL.revokeObjectURL(url)
}
}, [id, doc?.docNo, doc?.status])
if (full.error !== undefined) return <div className="wf-page"><ErrorState message={full.error} onRetry={full.reload} /></div>
if (doc === undefined || full.data === undefined) return <div className="wf-page"><Skeleton rows={6} /></div>
/** POST an action; convert/credit-note navigate to the new document; toasts report the outcome. */
const act = (action: string, body?: Record<string, unknown>, opts: { navigate?: boolean; ok?: string } = {}) => {
setBusy(action)
documentAction(id, action, body)
.then((out) => {
if (opts.ok !== undefined) toast.ok(opts.ok)
if (opts.navigate === true && out.id !== id) nav(`/documents/${out.id}`)
else full.reload()
})
.catch((e: Error) => toast.err(friendly(e.message)))
.finally(() => setBusy(undefined))
}
const issued = doc.docNo !== null
const cancelled = doc.status === 'cancelled'
const settling = doc.status === 'part_paid' || doc.status === 'paid'
return (
<div className="wf-page">
<div className="wf-crumbs">
<Link to="/clients">Clients</Link><span>/</span>
{client.data !== undefined ? <Link to={`/clients/${doc.clientId}`}>{client.data.name}</Link> : <span></span>}
<span>/</span><span className="mono">{doc.docNo ?? 'draft'}</span>
</div>
<RecordHeader
name={TITLE[doc.docType]}
avatarSeed={client.data?.name ?? doc.clientId}
status={<Badge tone={DOC_TONE[doc.status]}>{doc.status}</Badge>}
meta={
<>
<span className="mono">{doc.docNo ?? '(draft)'}</span>
<span>· {doc.fy}</span>
<span>· {doc.docDate}</span>
<span>· Payable <strong className="num">{formatINR(doc.payablePaise)}</strong></span>
</>
}
/>
<Toolbar>
{!issued && !cancelled && (
<Button tone="primary" onClick={() => act('issue', undefined, { ok: 'Number issued' })}>
{busy === 'issue' ? 'Issuing…' : 'Issue number'}
</Button>
)}
{issued && !cancelled && (
<Button tone="primary" onClick={() => setSending(true)}>
{busy === 'send' ? 'Sending…' : 'Send by email'}
</Button>
)}
{issued && doc.status === 'draft' && (
<Button onClick={() => act('status', { status: 'sent' }, { ok: 'Marked sent' })}>Mark sent (manual)</Button>
)}
{doc.docType === 'QUOTATION' && doc.status === 'sent' && (
<>
<Button onClick={() => act('status', { status: 'accepted' }, { ok: 'Marked accepted' })}>Mark accepted</Button>
<Button onClick={() => act('status', { status: 'lost' }, { ok: 'Marked lost' })}>Mark lost</Button>
</>
)}
{doc.docType === 'QUOTATION' && !cancelled && (
<Button onClick={() => act('convert', { to: 'PROFORMA' }, { navigate: true, ok: 'Converted to proforma' })}>
Convert to Proforma
</Button>
)}
{(doc.docType === 'QUOTATION' || doc.docType === 'PROFORMA') && !cancelled && (
<Button onClick={() => act('convert', { to: 'INVOICE' }, { navigate: true, ok: 'Converted to invoice' })}>
Convert to Invoice
</Button>
)}
{doc.docType === 'PROFORMA' && !cancelled && doc.status !== 'invoiced' && (
<>
{/* Spec §8 one-click: convert → issue → email, in one action. */}
<Button
tone="primary"
onClick={() => setConfirm({
title: 'Convert to invoice & send',
body: 'Convert to invoice, assign its number and email it to the client?',
label: 'Convert & send',
run: () => act('convert-and-send', {}, { navigate: true, ok: 'Converted to invoice & sent' }),
})}
>
{busy === 'convert-and-send' ? 'Converting…' : 'Convert to invoice & send'}
</Button>
{/* Spec §8 cancel & recreate: retire this proforma, reopen its lines as a fresh draft. */}
<Button
onClick={() => setConfirm({
title: 'Supersede & recreate',
body: 'Supersede this proforma? It is cancelled (number stays consumed) and a fresh draft opens with the same lines.',
label: 'Supersede',
tone: 'danger',
run: () => act('supersede', {}, { navigate: true, ok: 'Superseded' }),
})}
>
{busy === 'supersede' ? 'Superseding…' : 'Supersede & recreate'}
</Button>
</>
)}
{doc.docType === 'INVOICE' && issued && !cancelled && doc.status !== 'paid' && (
<Button tone="primary" onClick={() => setPaying((v) => !v)}>Record payment</Button>
)}
{issued && !cancelled && !settling && (
<Button
tone="danger"
onClick={() => setConfirm({
title: 'Cancel document',
body: `Cancel ${doc.docNo}? The number stays consumed.`,
label: 'Cancel document',
tone: 'danger',
run: () => act('cancel', undefined, { ok: 'Cancelled' }),
})}
>
Cancel
</Button>
)}
{doc.docType === 'INVOICE' && issued && !cancelled && (
<Button
onClick={() => setConfirm({
title: 'Create credit note',
body: `Create a full-value credit note against ${doc.docNo}?`,
label: 'Create credit note',
run: () => act('credit-note', {}, { navigate: true, ok: 'Credit note created' }),
})}
>
Credit note
</Button>
)}
</Toolbar>
<SendDialog
open={sending}
onClose={() => setSending(false)}
doc={doc}
client={client.data}
onSend={(to, subject) => act('send', { to, subject }, { ok: `Sent to ${to}` })}
/>
<ShareActions doc={doc} client={client.data} shares={full.data.shares} onChange={full.reload} />
{paying && (
<PaymentForm
clientId={doc.clientId}
onDone={() => { setPaying(false); full.reload() }}
/>
)}
{pdfErr !== undefined && <Notice tone="warn">PDF preview unavailable: {pdfErr}</Notice>}
{pdfUrl !== undefined && (
<iframe
src={pdfUrl} title="Document PDF"
style={{ width: '100%', height: 560, border: '1px solid var(--border)', borderRadius: 8, background: '#fff' }}
/>
)}
<div className="dash-grid">
<section>
<h3 style={{ marginTop: 20 }}>Timeline</h3>
{full.data.events.length === 0 ? <EmptyState>No events.</EmptyState> : (
<DataTable
columns={[
{ key: 'at', label: 'When' }, { key: 'kind', label: 'Event' }, { key: 'meta', label: 'Detail' },
]}
rows={full.data.events.map((ev) => ({
at: new Date(ev.atWall).toLocaleString(),
kind: <Badge tone="accent">{ev.kind}</Badge>,
meta: Object.keys(ev.meta).length > 0 ? JSON.stringify(ev.meta) : '—',
}))}
/>
)}
</section>
<section>
<h3 style={{ marginTop: 20 }}>Email log</h3>
{full.data.emails.length === 0 ? <EmptyState>Not emailed yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'at', label: 'When' }, { key: 'to', label: 'To' },
{ key: 'subject', label: 'Subject' }, { key: 'status', label: 'Status' },
{ key: 'error', label: 'Error' },
]}
rows={full.data.emails.map((e) => ({
at: new Date(e.at_wall).toLocaleString(),
to: e.to_addr, subject: e.subject,
status: <Badge tone={e.status === 'sent' ? 'ok' : 'err'}>{e.status}</Badge>,
error: e.error ?? '—',
}))}
/>
)}
</section>
</div>
{confirm !== undefined && (
<ConfirmDialog
open
onClose={() => setConfirm(undefined)}
onConfirm={confirm.run}
title={confirm.title}
body={confirm.body}
confirmLabel={confirm.label}
{...(confirm.tone !== undefined ? { tone: confirm.tone } : {})}
/>
)}
</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 [confirm, setConfirm] = useState<{ id: string } | undefined>()
const shareUrl = (token: string): string => `${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); 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.
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={() => setConfirm({ id: s.id })}>
{busy === `revoke:${s.id}` ? 'Revoking…' : 'Revoke'}
</Button>
</div>
))}
</div>
)}
{confirm !== undefined && (
<ConfirmDialog
open
onClose={() => setConfirm(undefined)}
onConfirm={() => run(`revoke:${confirm.id}`, revokeShare(doc.id, confirm.id))}
title="Revoke link"
body="Revoke this share link? Anyone with it will lose access immediately."
confirmLabel="Revoke"
tone="danger"
/>
)}
</section>
)
}