|
|
|
|
@ -1,14 +1,21 @@
|
|
|
|
|
import { useEffect, useState } from 'react'
|
|
|
|
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
|
|
|
|
import { formatINR } from '@sims/domain'
|
|
|
|
|
import { Badge, Button, DataTable, EmptyState, ErrorState, Notice, RecordHeader, Skeleton, Toolbar } from '@sims/ui'
|
|
|
|
|
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',
|
|
|
|
|
@ -31,9 +38,11 @@ export function DocumentView() {
|
|
|
|
|
() => (doc !== undefined ? getClient(doc.clientId) : Promise.resolve(undefined)),
|
|
|
|
|
[doc?.clientId],
|
|
|
|
|
)
|
|
|
|
|
const [actionErr, setActionErr] = useState<string | undefined>()
|
|
|
|
|
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>()
|
|
|
|
|
@ -59,28 +68,19 @@ export function DocumentView() {
|
|
|
|
|
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. */
|
|
|
|
|
const act = (action: string, body?: Record<string, unknown>, navigateToResult = false) => {
|
|
|
|
|
setActionErr(undefined)
|
|
|
|
|
/** 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 (navigateToResult && out.id !== id) nav(`/documents/${out.id}`)
|
|
|
|
|
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) => setActionErr(friendly(e.message)))
|
|
|
|
|
.catch((e: Error) => toast.err(friendly(e.message)))
|
|
|
|
|
.finally(() => setBusy(undefined))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const send = () => {
|
|
|
|
|
const defaultTo = client.data?.contacts.find((ct) => ct.email !== undefined && ct.email !== '')?.email ?? ''
|
|
|
|
|
const to = window.prompt('Send to (email)', defaultTo)
|
|
|
|
|
if (to === null || to.trim() === '') return
|
|
|
|
|
const subject = window.prompt('Subject', `${doc.docType} ${doc.docNo ?? ''}`.trim())
|
|
|
|
|
if (subject === null) return
|
|
|
|
|
act('send', { to: to.trim(), subject })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const issued = doc.docNo !== null
|
|
|
|
|
const cancelled = doc.status === 'cancelled'
|
|
|
|
|
const settling = doc.status === 'part_paid' || doc.status === 'paid'
|
|
|
|
|
@ -108,48 +108,57 @@ export function DocumentView() {
|
|
|
|
|
|
|
|
|
|
<Toolbar>
|
|
|
|
|
{!issued && !cancelled && (
|
|
|
|
|
<Button tone="primary" onClick={() => act('issue')}>
|
|
|
|
|
<Button tone="primary" onClick={() => act('issue', undefined, { ok: 'Number issued' })}>
|
|
|
|
|
{busy === 'issue' ? 'Issuing…' : 'Issue number'}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
{issued && !cancelled && (
|
|
|
|
|
<Button tone="primary" onClick={send}>{busy === 'send' ? 'Sending…' : 'Send by email'}</Button>
|
|
|
|
|
<Button tone="primary" onClick={() => setSending(true)}>
|
|
|
|
|
{busy === 'send' ? 'Sending…' : 'Send by email'}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
{issued && doc.status === 'draft' && (
|
|
|
|
|
<Button onClick={() => act('status', { status: 'sent' })}>Mark sent (manual)</Button>
|
|
|
|
|
<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' })}>Mark accepted</Button>
|
|
|
|
|
<Button onClick={() => act('status', { status: 'lost' })}>Mark lost</Button>
|
|
|
|
|
<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' }, true)}>Convert to Proforma</Button>
|
|
|
|
|
<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' }, true)}>Convert to Invoice</Button>
|
|
|
|
|
<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={() => {
|
|
|
|
|
if (window.confirm('Convert to invoice, assign its number and email it to the client?')) {
|
|
|
|
|
act('convert-and-send', {}, true)
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
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={() => {
|
|
|
|
|
if (window.confirm('Supersede this proforma? It is cancelled (number stays consumed) and a fresh draft opens with the same lines.')) {
|
|
|
|
|
act('supersede', {}, true)
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
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>
|
|
|
|
|
@ -161,24 +170,38 @@ export function DocumentView() {
|
|
|
|
|
{issued && !cancelled && !settling && (
|
|
|
|
|
<Button
|
|
|
|
|
tone="danger"
|
|
|
|
|
onClick={() => { if (window.confirm(`Cancel ${doc.docNo}? The number stays consumed.`)) act('cancel') }}
|
|
|
|
|
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={() => {
|
|
|
|
|
if (window.confirm(`Create a full-value credit note against ${doc.docNo}?`)) {
|
|
|
|
|
act('credit-note', {}, true)
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
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>
|
|
|
|
|
{actionErr !== undefined && <Notice tone="err">{actionErr}</Notice>}
|
|
|
|
|
|
|
|
|
|
<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} />
|
|
|
|
|
|
|
|
|
|
@ -233,6 +256,18 @@ export function DocumentView() {
|
|
|
|
|
)}
|
|
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
@ -256,8 +291,9 @@ function ShareActions(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 => `${window.location.origin}/share/${token}`
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
@ -277,7 +313,7 @@ function ShareActions(props: {
|
|
|
|
|
|
|
|
|
|
const onCopy = (url: string) => {
|
|
|
|
|
void navigator.clipboard?.writeText(url).then(
|
|
|
|
|
() => { setCopied(url); window.setTimeout(() => setCopied(undefined), 1800) },
|
|
|
|
|
() => { setCopied(url); setTimeout(() => setCopied(undefined), 1800) },
|
|
|
|
|
() => setErr('Could not copy — select the link and copy it manually.'),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
@ -294,7 +330,7 @@ function ShareActions(props: {
|
|
|
|
|
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')
|
|
|
|
|
open(`https://wa.me/${digits}?text=${encodeURIComponent(message(url))}`, '_blank', 'noopener')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onNativeShare = (url: string) => {
|
|
|
|
|
@ -353,13 +389,24 @@ function ShareActions(props: {
|
|
|
|
|
<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))}>
|
|
|
|
|
<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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|