feat(hq-web): send-email composer dialog + styled confirms — window.prompt/confirm eliminated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 584fb291f8
commit 5d81550e34

@ -0,0 +1,72 @@
import { useEffect, useMemo, useState } from 'react'
import { Button, Dialog, Field, Notice } from '@sims/ui'
import { getEmailStatus, type Client, type Doc, type EmailStatus } from '../api'
const OTHER = '__other__'
/** Real send composer — To from the client's contacts, Subject prefilled, Gmail state inline. */
export function SendDialog(props: {
open: boolean
onClose: () => void
doc: Doc
client: Client | undefined
onSend: (to: string, subject: string) => void
}) {
const emails = useMemo(
() => (props.client?.contacts ?? []).map((c) => c.email).filter((e): e is string => e !== undefined && e !== ''),
[props.client],
)
const [pick, setPick] = useState('')
const [other, setOther] = useState('')
const [subject, setSubject] = useState('')
const [gmail, setGmail] = useState<EmailStatus | undefined>()
useEffect(() => {
if (!props.open) return
setPick(emails[0] ?? OTHER)
setOther('')
setSubject(`${props.doc.docType} ${props.doc.docNo ?? ''}`.trim())
getEmailStatus().then(setGmail).catch(() => setGmail(undefined))
}, [props.open, emails, props.doc])
const to = pick === OTHER ? other.trim() : pick
const down = gmail !== undefined && (!gmail.connected || gmail.dead)
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to) && subject.trim() !== '' && !down
return (
<Dialog
open={props.open}
onClose={props.onClose}
title="Send by email"
footer={
<>
<Button pill onClick={props.onClose}>Cancel</Button>
<Button pill tone="primary" onClick={() => { if (valid) { props.onSend(to, subject.trim()); props.onClose() } }}>
Send
</Button>
</>
}
>
{down && (
<Notice tone="warn">Gmail is disconnected run gmail-connect on the server, then retry.</Notice>
)}
<Field label="To">
<select className="wf" value={pick} onChange={(e) => setPick(e.target.value)}>
{emails.map((e) => <option key={e} value={e}>{e}</option>)}
<option value={OTHER}>Other</option>
</select>
</Field>
{pick === OTHER && (
<Field label="Email address">
<input className="wf" type="email" autoFocus value={other} onChange={(e) => setOther(e.target.value)} />
</Field>
)}
<Field label="Subject">
<input className="wf" value={subject} onChange={(e) => setSubject(e.target.value)} />
</Field>
{gmail?.address !== undefined && (
<p style={{ fontSize: 12, color: 'var(--text-dim)', margin: 0 }}>Sends from {gmail.address}.</p>
)}
</Dialog>
)
}

@ -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>
)
}

Loading…
Cancel
Save