diff --git a/apps/hq-web/src/components/SendDialog.tsx b/apps/hq-web/src/components/SendDialog.tsx new file mode 100644 index 0000000..b8b4b1b --- /dev/null +++ b/apps/hq-web/src/components/SendDialog.tsx @@ -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() + + 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 ( + + + + + } + > + {down && ( + Gmail is disconnected — run gmail-connect on the server, then retry. + )} + + + + {pick === OTHER && ( + + setOther(e.target.value)} /> + + )} + + setSubject(e.target.value)} /> + + {gmail?.address !== undefined && ( +

Sends from {gmail.address}.

+ )} +
+ ) +} diff --git a/apps/hq-web/src/pages/DocumentView.tsx b/apps/hq-web/src/pages/DocumentView.tsx index 0860d5d..924a1f1 100644 --- a/apps/hq-web/src/pages/DocumentView.tsx +++ b/apps/hq-web/src/pages/DocumentView.tsx @@ -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 = { 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() + const toast = useToast() const [busy, setBusy] = useState() const [paying, setPaying] = useState(false) + const [sending, setSending] = useState(false) + const [confirm, setConfirm] = useState() // PDF preview via blob URL — an