import type { DocumentItem, DocumentTax, Estimate, Invoice, RecurringInvoice, TaxType } from './shared'; import { useEffect, useMemo, useState } from 'react'; import { Plus, Trash2, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { ErrorState, LoadingState } from './components'; import { formatMoney, fromMajor, toInputDate, toMajor } from './format'; import { FREQUENCY_PRESETS } from './shared'; import { useCurrency, useLookups, useNextNumber, useResourceDetail, useResourceMutations, useTemplates, } from './useInvoiceShelfData'; // The invoice / estimate / recurring-invoice form. One component for all three: they share every hard part // — the line-item table, the discounts, the taxes and the totals — and differ only in a header strip. // An invoice has a number and two dates; an estimate the same with different labels; a recurring invoice // has no number at all (each generated invoice takes the next one) and carries a schedule instead of dates. // // ── the arithmetic ─────────────────────────────────────────────────────────────────────────────── // Ported from upstream's `use-document-calculations.ts`, and the rounding points are load-bearing because // the server re-validates the totals we send: // // line sub_total = round(price × quantity) (price already in minor units) // line discount = percentage → round(|sub_total| × d / 100) // fixed → min(round(d × 100), |sub_total|) ← d is entered in MAJOR units // line total = sub_total − discount // document sub_total = Σ line totals // tax base = document sub_total − document discount // compound tax base = tax base + simple taxes // total = tax_included ? tax base : tax base + total tax // // Every value posted is an integer of minor units. Amounts are shown to the user in major units and // converted at the edges only — a float never survives past a keystroke. type Resource = 'invoices' | 'estimates' | 'recurring-invoices'; type Line = { key: string; item_id: number | null; name: string; description: string; quantity: number; /** Minor units. */ price: number; discount: number; discount_type: 'fixed' | 'percentage'; tax_type_ids: number[]; }; type Props = { resource: Resource; id: number | 'new'; onClose: () => void }; const KIND_NOUN: Record = { invoices: 'invoice', estimates: 'estimate', 'recurring-invoices': 'recurring invoice', }; const newLine = (index: number): Line => ({ key: `new-${index}`, item_id: null, name: '', description: '', quantity: 1, price: 0, discount: 0, discount_type: 'fixed', tax_type_ids: [], }); export const DocumentEditor = ({ resource, id, onClose }: Props) => { const isNew = id === 'new'; const isRecurring = resource === 'recurring-invoices'; const isInvoice = resource === 'invoices'; const currency = useCurrency(); const { customers, items, taxTypes } = useLookups(); // A recurring invoice renders through the invoice templates — it has no gallery of its own. const templates = useTemplates(isRecurring ? 'invoices' : resource); const nextNumber = useNextNumber(isInvoice ? 'invoice' : 'estimate', isNew && !isRecurring); const { record, isLoading, error } = useResourceDetail( resource, isNew ? null : (id as number), ); const { create, update } = useResourceMutations(resource, isRecurring ? 'recurring invoices' : resource); const [customerId, setCustomerId] = useState(null); const [number, setNumber] = useState(''); const [primaryDate, setPrimaryDate] = useState(() => toInputDate(new Date().toISOString())); const [secondaryDate, setSecondaryDate] = useState(''); const [reference, setReference] = useState(''); const [template, setTemplate] = useState(''); const [notes, setNotes] = useState(''); const [lines, setLines] = useState([newLine(0)]); const [discount, setDiscount] = useState(0); const [discountType, setDiscountType] = useState<'fixed' | 'percentage'>('fixed'); const [docTaxIds, setDocTaxIds] = useState([]); const [taxPerItem, setTaxPerItem] = useState<'YES' | 'NO'>('NO'); const [taxIncluded, setTaxIncluded] = useState(false); const [loaded, setLoaded] = useState(false); // Recurring-only. `frequency` is a raw cron expression upstream; a schedule authored outside the preset // list keeps its own expression and is offered back as an extra option rather than silently rewritten. const [frequency, setFrequency] = useState(FREQUENCY_PRESETS[2].cron); const [limitBy, setLimitBy] = useState<'NONE' | 'COUNT' | 'DATE'>('NONE'); const [limitCount, setLimitCount] = useState(1); const [limitDate, setLimitDate] = useState(''); const [sendAutomatically, setSendAutomatically] = useState(false); const [recurringStatus, setRecurringStatus] = useState<'ACTIVE' | 'ON_HOLD'>('ACTIVE'); // Seed the form once, from either the loaded record or the reserved next number. Guarded by `loaded` // rather than by a dependency list: react-query re-delivers `record` on every background refetch, and // without the guard a refetch mid-edit would silently discard what the user had typed. useEffect(() => { if (loaded) return; if (isNew) { // A recurring invoice has no number of its own, so it does not wait on one. if (!isRecurring && !nextNumber) return; if (nextNumber) setNumber(nextNumber); setTemplate(templates[0]?.name ?? (isInvoice || isRecurring ? 'invoice1' : 'estimate1')); setLoaded(true); return; } if (!record) return; setCustomerId(record.customer_id ?? null); setNumber(isInvoice ? (record.invoice_number ?? '') : (record.estimate_number ?? '')); if (isRecurring) { setPrimaryDate(toInputDate(record.starts_at)); setFrequency(record.frequency ?? FREQUENCY_PRESETS[2].cron); setLimitBy(record.limit_by === 'COUNT' || record.limit_by === 'DATE' ? record.limit_by : 'NONE'); setLimitCount(record.limit_count ?? 1); setLimitDate(toInputDate(record.limit_date)); setSendAutomatically(Boolean(record.send_automatically)); setRecurringStatus(record.status === 'ON_HOLD' ? 'ON_HOLD' : 'ACTIVE'); } else { setPrimaryDate(toInputDate(isInvoice ? record.invoice_date : record.estimate_date)); setSecondaryDate(toInputDate(isInvoice ? record.due_date : record.expiry_date)); } setReference(record.reference_number ?? ''); setTemplate(record.template_name ?? templates[0]?.name ?? ''); setNotes(record.notes ?? ''); setTaxPerItem(record.tax_per_item === 'YES' ? 'YES' : 'NO'); setTaxIncluded(Boolean((record as { tax_included?: boolean }).tax_included)); setDiscountType(record.discount_type === 'percentage' ? 'percentage' : 'fixed'); // A fixed document discount is stored in minor units but entered in major ones; a percentage is a // plain number either way. setDiscount(record.discount_type === 'percentage' ? Number(record.discount) : toMajor(record.discount_val)); setDocTaxIds((record.taxes ?? []).map((t) => t.tax_type_id).filter((t): t is number => t != null)); setLines( (record.items ?? []).map((line, i) => ({ key: `row-${line.id ?? i}`, item_id: line.item_id ?? null, name: line.name, description: line.description ?? '', quantity: Number(line.quantity) || 0, price: Number(line.price) || 0, discount: line.discount_type === 'percentage' ? Number(line.discount) : toMajor(line.discount_val), discount_type: line.discount_type === 'percentage' ? 'percentage' : 'fixed', tax_type_ids: (line.taxes ?? []).map((t) => t.tax_type_id).filter((t): t is number => t != null), })), ); setLoaded(true); }, [record, isNew, nextNumber, templates, isInvoice, isRecurring, loaded]); const totals = useMemo( () => computeTotals({ lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes }), [lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes], ); if (error) return ; if (!isNew && isLoading) return ; const patchLine = (key: string, changes: Partial) => setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...changes } : l))); const pickItem = (key: string, itemId: number) => { const source = items.find((i) => i.id === itemId); if (!source) return; patchLine(key, { item_id: source.id, name: source.name, description: source.description ?? '', price: Number(source.price) || 0, }); }; const canSave = customerId != null && (isRecurring || number.trim() !== '') && primaryDate !== '' && template !== '' && lines.some((l) => l.name.trim()); const save = (ev?: React.FormEvent) => { ev?.preventDefault(); // The three resources agree on everything below the header, and disagree on nothing but it. const header = isRecurring ? { starts_at: primaryDate, frequency, limit_by: limitBy, limit_count: limitBy === 'COUNT' ? limitCount : null, limit_date: limitBy === 'DATE' ? limitDate : null, send_automatically: sendAutomatically, status: recurringStatus, } : { [isInvoice ? 'invoice_date' : 'estimate_date']: primaryDate, [isInvoice ? 'due_date' : 'expiry_date']: secondaryDate || null, [isInvoice ? 'invoice_number' : 'estimate_number']: number.trim(), }; const payload = { ...header, customer_id: customerId, reference_number: reference || null, template_name: template, notes: notes || null, currency_id: record?.currency_id ?? currency?.id ?? null, exchange_rate: 1, tax_per_item: taxPerItem, discount_per_item: lines.some((l) => l.discount) ? 'YES' : 'NO', // The raw figure the user typed; `discount_val` alongside it is the resolved minor-unit amount. discount, discount_type: discountType, discount_val: totals.documentDiscount, sub_total: totals.subTotal, total: totals.total, tax: totals.totalTax, taxes: taxPerItem === 'NO' ? docTaxIds.map((tid) => taxLine(tid, taxTypes, totals.taxBase)) : [], items: lines .filter((l) => l.name.trim()) .map((l) => { const line = computeLine(l, taxTypes, taxPerItem); return { item_id: l.item_id, name: l.name.trim(), description: l.description || null, quantity: l.quantity, price: l.price, discount: l.discount, discount_type: l.discount_type, discount_val: line.discountVal, tax: line.tax, total: line.total, taxes: taxPerItem === 'YES' ? l.tax_type_ids.map((tid) => taxLine(tid, taxTypes, line.total)) : [], }; }), }; if (isNew) create.mutate(payload, { onSuccess: onClose }); else update.mutate({ id: id as number, payload }, { onSuccess: onClose }); }; const busy = create.isPending || update.isPending; return (
{isNew ? `New ${KIND_NOUN[resource]}` : `Edit ${number || KIND_NOUN[resource]}`}
{!isRecurring && ( setNumber(ev.target.value)} className="h-8 text-xs" /> )} setPrimaryDate(ev.target.value)} className="h-8 text-xs" /> {!isRecurring && ( setSecondaryDate(ev.target.value)} className="h-8 text-xs" /> )} {isRecurring && ( <> {limitBy === 'COUNT' && ( setLimitCount(Number(ev.target.value))} className="h-8 text-xs" /> )} {limitBy === 'DATE' && ( setLimitDate(ev.target.value)} className="h-8 text-xs" /> )} )} setReference(ev.target.value)} className="h-8 text-xs" /> {/* Only the names: each template also carries a `path` that is a full base64-encoded PNG preview, several hundred KB apiece, and nothing here needs to render one. */}
{isRecurring && ( )}
Item Qty Price Discount Total
{lines.map((line) => { const computed = computeLine(line, taxTypes, taxPerItem); return (
patchLine(line.key, { name: ev.target.value, item_id: null })} placeholder="Description of work" className="h-8 text-xs" />
patchLine(line.key, { description: ev.target.value })} placeholder="Notes (optional)" className="h-7 text-[11px]" /> {taxPerItem === 'YES' && ( patchLine(line.key, { tax_type_ids: ids })} /> )}
patchLine(line.key, { quantity: Number(ev.target.value) })} className="h-8 text-right text-xs" /> patchLine(line.key, { price: fromMajor(ev.target.value) })} className="h-8 text-right text-xs" />
patchLine(line.key, { discount: Number(ev.target.value) })} className="h-8 text-right text-xs" />
{formatMoney(computed.total, currency)}
); })}