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.
413 lines
17 KiB
TypeScript
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 & 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>
|
|
)
|
|
}
|