// FRAZIER Finance OS — Invoices (Phase 2 demo). FRONTEND-ONLY: no // fin_invoice_* backend exists yet, so this seeds a realistic in-memory // dummy dataset below (clearly labeled, same pattern as FIN_HOME_MOCK in // fin-home.jsx) and operates entirely on local React state — no finApi/ // fetch calls. A real backend swap is a later phase; the UI/behavior built // here should not need to change shape when that lands. const FIN_INV_CLIENTS_SEED = [ { id: 'c1', name: 'Kopi Kenangan' }, { id: 'c2', name: 'Sociolla' }, { id: 'c3', name: 'Erigo' }, { id: 'c4', name: 'Wardah Beauty' }, { id: 'c5', name: 'Ternak Uang Media' }, { id: 'c6', name: 'Bank Jago Digital' }, ]; const FIN_INV_SEED = [ { id: 'i1', clientId: 'c1', number: 'FRZ/INV/2026/09/0041', service: 'Social Media Management — September', amount: 32_000_000, discount: 0, tax: 11, issueDate: '2026-09-01', dueDate: '2026-09-20', status: 'overdue', notes: '' }, { id: 'i2', clientId: 'c2', number: 'FRZ/INV/2026/09/0042', service: 'Campaign Production — Ramadan Refresh', amount: 58_500_000, discount: 5, tax: 11, issueDate: '2026-09-05', dueDate: '2026-09-28', status: 'overdue', notes: '' }, { id: 'i3', clientId: 'c3', number: 'FRZ/INV/2026/09/0043', service: 'Content Shoot — Lookbook Vol. 4', amount: 24_750_000, discount: 0, tax: 11, issueDate: '2026-08-28', dueDate: '2026-09-15', status: 'paid', notes: '' }, { id: 'i4', clientId: 'c4', number: 'FRZ/INV/2026/08/0039', service: 'Influencer Management — August', amount: 45_000_000, discount: 0, tax: 11, issueDate: '2026-08-10', dueDate: '2026-08-30', status: 'paid', notes: '' }, { id: 'i5', clientId: 'c5', number: 'FRZ/INV/2026/10/0044', service: 'Podcast Editing Retainer — October', amount: 18_200_000, discount: 0, tax: 11, issueDate: '2026-10-01', dueDate: '2026-10-20', status: 'sent', notes: '' }, { id: 'i6', clientId: 'c6', number: 'FRZ/INV/2026/10/0045', service: 'Brand Campaign — Q4 Launch', amount: 60_000_000, discount: 10, tax: 11, issueDate: '2026-10-05', dueDate: '2026-10-25', status: 'ready', notes: '' }, { id: 'i7', clientId: 'c1', number: 'FRZ/INV/2026/10/0046', service: 'TikTok Series — November batch', amount: 15_000_000, discount: 0, tax: 11, issueDate: '2026-10-05', dueDate: '2026-11-05', status: 'draft', notes: '' }, { id: 'i8', clientId: 'c2', number: 'FRZ/INV/2026/10/0047', service: 'Photo Retouching Batch 12', amount: 21_000_000, discount: 0, tax: 11, issueDate: '2026-10-06', dueDate: '2026-11-06', status: 'draft', notes: '' }, ]; const FIN_INV_STATUS = { draft: { label: 'Draft', tone: 'neutral' }, ready: { label: 'Ready to Send', tone: 'info' }, sent: { label: 'Sent', tone: 'info' }, paid: { label: 'Paid', tone: 'ok' }, overdue: { label: 'Overdue', tone: 'crit' }, }; const FIN_INV_FILTERS = [ { id: 'needs', label: 'Needs Action', test: (s) => s === 'draft' }, { id: 'ready', label: 'Ready to Send', test: (s) => s === 'ready' }, { id: 'sent', label: 'Sent', test: (s) => s === 'sent' }, { id: 'paid', label: 'Paid', test: (s) => s === 'paid' }, { id: 'overdue', label: 'Overdue', test: (s) => s === 'overdue' }, { id: 'all', label: 'All', test: () => true }, ]; function finInvDateFmt(iso) { if (!iso) return '—'; const d = new Date(iso + 'T00:00:00'); if (isNaN(d.getTime())) return iso; return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); } // Simple sequential number — good enough for the demo dataset; a real // backend would own the counter + year/month bucketing server-side. function finInvNextNumber(invoices, issueDateIso) { const d = issueDateIso ? new Date(issueDateIso + 'T00:00:00') : new Date(); const base = isNaN(d.getTime()) ? new Date() : d; const y = base.getFullYear(); const m = String(base.getMonth() + 1).padStart(2, '0'); const seq = String(invoices.length + 1).padStart(4, '0'); return `FRZ/INV/${y}/${m}/${seq}`; } function finInvTotals(amount, discountPct, taxPct) { const subtotal = Number(amount) || 0; const discountAmt = subtotal * (Number(discountPct) || 0) / 100; const afterDiscount = subtotal - discountAmt; const taxAmt = afterDiscount * (Number(taxPct) || 0) / 100; return { subtotal, discountAmt, afterDiscount, taxAmt, total: afterDiscount + taxAmt }; } function finInvDaysOverdue(dueIso) { const d = new Date(dueIso + 'T00:00:00'); if (isNaN(d.getTime())) return 0; return Math.max(0, Math.round((Date.now() - d.getTime()) / 86400000)); } function finInvNewDraft() { return { step: 1, clientId: null, clientSearch: '', addingClient: false, newClientName: '', service: '', amount: '', discount: '0', tax: '11', issueDate: new Date().toISOString().slice(0, 10), dueDate: '', notes: '', }; } function FinInvPill({ status }) { const meta = FIN_INV_STATUS[status] || FIN_INV_STATUS.draft; return {meta.label}; } function FinInvRow({ invoice, clientName }) { const overdue = invoice.status === 'overdue'; return (
{clientName} {invoice.service} {invoice.number}
Due {finInvDateFmt(invoice.dueDate)} {overdue ? {finInvDaysOverdue(invoice.dueDate)}d overdue : null}
{finIDR(invoice.amount)}
); } function FinInvWizard({ wizard, setWizard, clients, invoices, onAddClient, onClose, onMarkAsSent }) { React.useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); const prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', onKey); document.body.style.overflow = prevOverflow; }; }, [onClose]); const set = (patch) => setWizard((w) => ({ ...w, ...patch })); const goStep = (step) => set({ step }); const filteredClients = clients.filter((c) => c.name.toLowerCase().includes(wizard.clientSearch.trim().toLowerCase())); const selectedClient = clients.find((c) => c.id === wizard.clientId); const handleAddClient = () => { const c = onAddClient(wizard.newClientName); if (c) set({ clientId: c.id, newClientName: '', addingClient: false, clientSearch: '' }); }; const handleDownloadPdf = () => { // Real PDF generation is a later phase (see Finance OS plan) — the // browser's print dialog (which can "Save as PDF") stands in for now. window.print(); }; const step1Valid = !!wizard.clientId; const step2Valid = !!(wizard.service.trim() && Number(wizard.amount) > 0 && wizard.issueDate && wizard.dueDate); const totals = finInvTotals(wizard.amount, wizard.discount, wizard.tax); const previewNumber = finInvNextNumber(invoices, wizard.issueDate); return (
e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Create invoice">
= 1 ? 'is-on' : '')}>1 Client = 2 ? 'is-on' : '')}>2 Details = 3 ? 'is-on' : '')}>3 Preview
{wizard.step === 1 ? (
set({ clientSearch: e.target.value })} autoFocus />
{filteredClients.map((c) => ( ))} {!filteredClients.length ?

No clients match "{wizard.clientSearch}".

: null}
{wizard.addingClient ? (
set({ newClientName: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') handleAddClient(); }} />
) : ( )}
) : null} {wizard.step === 2 ? (