typing into a field and pressing enter did nothing in the document editor, the customer/item/payment/expense dialogs and the send-document dialog — inputs sat in a div, so the button was the only way to submit. now each wraps in a <form onSubmit>, with every non-submit button (cancel, close, add/remove line, "same as billing") marked type="button" so it doesn't submit instead of doing its own thing. the record editors share one dialog shell (EditorShell), so a single form there covers customer, item, payment and expense at once — kept the dialog's grid gap intact with display:contents rather than nesting a box. left the list-view filter boxes and the reports date-range picker alone: none of them has a submit action, so there's nothing for enter to do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
736 lines
30 KiB
TypeScript
736 lines
30 KiB
TypeScript
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<Resource, string> = {
|
||
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<Invoice & Estimate & RecurringInvoice>(
|
||
resource,
|
||
isNew ? null : (id as number),
|
||
);
|
||
const { create, update } = useResourceMutations(resource, isRecurring ? 'recurring invoices' : resource);
|
||
|
||
const [customerId, setCustomerId] = useState<number | null>(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<Line[]>([newLine(0)]);
|
||
const [discount, setDiscount] = useState(0);
|
||
const [discountType, setDiscountType] = useState<'fixed' | 'percentage'>('fixed');
|
||
const [docTaxIds, setDocTaxIds] = useState<number[]>([]);
|
||
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<string>(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 <ErrorState error={error} />;
|
||
if (!isNew && isLoading) return <LoadingState />;
|
||
|
||
const patchLine = (key: string, changes: Partial<Line>) =>
|
||
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 (
|
||
<form onSubmit={save} className="flex h-full min-h-0 flex-col bg-background">
|
||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
|
||
<div className="min-w-0 flex-1 text-xs font-semibold">
|
||
{isNew ? `New ${KIND_NOUN[resource]}` : `Edit ${number || KIND_NOUN[resource]}`}
|
||
</div>
|
||
<Button variant="ghost" size="sm" type="button" className="h-7 text-xs" onClick={onClose} disabled={busy}>
|
||
Cancel
|
||
</Button>
|
||
<Button type="submit" size="sm" className="h-7 text-xs" disabled={!canSave || busy}>
|
||
{busy ? 'Saving…' : 'Save'}
|
||
</Button>
|
||
<Button variant="ghost" size="sm" type="button" className="h-7 px-2" onClick={onClose} title="Close">
|
||
<X className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
<Labelled label="Customer">
|
||
<select
|
||
value={customerId ?? ''}
|
||
onChange={(ev) => setCustomerId(ev.target.value ? Number(ev.target.value) : null)}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||
>
|
||
<option value="">Select…</option>
|
||
{customers.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Labelled>
|
||
{!isRecurring && (
|
||
<Labelled label={isInvoice ? 'Invoice number' : 'Estimate number'}>
|
||
<Input value={number} onChange={(ev) => setNumber(ev.target.value)} className="h-8 text-xs" />
|
||
</Labelled>
|
||
)}
|
||
<Labelled label={isRecurring ? 'Starts on' : isInvoice ? 'Invoice date' : 'Estimate date'}>
|
||
<Input
|
||
type="date"
|
||
value={primaryDate}
|
||
onChange={(ev) => setPrimaryDate(ev.target.value)}
|
||
className="h-8 text-xs"
|
||
/>
|
||
</Labelled>
|
||
{!isRecurring && (
|
||
<Labelled label={isInvoice ? 'Due date' : 'Expiry date'}>
|
||
<Input
|
||
type="date"
|
||
value={secondaryDate}
|
||
onChange={(ev) => setSecondaryDate(ev.target.value)}
|
||
className="h-8 text-xs"
|
||
/>
|
||
</Labelled>
|
||
)}
|
||
|
||
{isRecurring && (
|
||
<>
|
||
<Labelled label="Repeats">
|
||
<select
|
||
value={frequency}
|
||
onChange={(ev) => setFrequency(ev.target.value)}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||
>
|
||
{FREQUENCY_PRESETS.map((f) => (
|
||
<option key={f.cron} value={f.cron}>
|
||
{f.label}
|
||
</option>
|
||
))}
|
||
{!FREQUENCY_PRESETS.some((f) => f.cron === frequency) && (
|
||
<option value={frequency}>Custom ({frequency})</option>
|
||
)}
|
||
</select>
|
||
</Labelled>
|
||
<Labelled label="Ends">
|
||
<select
|
||
value={limitBy}
|
||
onChange={(ev) => setLimitBy(ev.target.value as 'NONE' | 'COUNT' | 'DATE')}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||
>
|
||
<option value="NONE">Never</option>
|
||
<option value="COUNT">After a number of invoices</option>
|
||
<option value="DATE">On a date</option>
|
||
</select>
|
||
</Labelled>
|
||
{limitBy === 'COUNT' && (
|
||
<Labelled label="How many">
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
value={limitCount}
|
||
onChange={(ev) => setLimitCount(Number(ev.target.value))}
|
||
className="h-8 text-xs"
|
||
/>
|
||
</Labelled>
|
||
)}
|
||
{limitBy === 'DATE' && (
|
||
<Labelled label="Until">
|
||
<Input
|
||
type="date"
|
||
value={limitDate}
|
||
onChange={(ev) => setLimitDate(ev.target.value)}
|
||
className="h-8 text-xs"
|
||
/>
|
||
</Labelled>
|
||
)}
|
||
<Labelled label="Status">
|
||
<select
|
||
value={recurringStatus}
|
||
onChange={(ev) => setRecurringStatus(ev.target.value as 'ACTIVE' | 'ON_HOLD')}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||
>
|
||
<option value="ACTIVE">Active</option>
|
||
<option value="ON_HOLD">On hold</option>
|
||
</select>
|
||
</Labelled>
|
||
</>
|
||
)}
|
||
<Labelled label="Reference">
|
||
<Input value={reference} onChange={(ev) => setReference(ev.target.value)} className="h-8 text-xs" />
|
||
</Labelled>
|
||
<Labelled label="Template">
|
||
{/* 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. */}
|
||
<select
|
||
value={template}
|
||
onChange={(ev) => setTemplate(ev.target.value)}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||
>
|
||
{templates.map((t) => (
|
||
<option key={t.name} value={t.name}>
|
||
{t.name}
|
||
</option>
|
||
))}
|
||
{template && !templates.some((t) => t.name === template) && <option value={template}>{template}</option>}
|
||
</select>
|
||
</Labelled>
|
||
<Labelled label="Tax">
|
||
<select
|
||
value={taxPerItem}
|
||
onChange={(ev) => setTaxPerItem(ev.target.value as 'YES' | 'NO')}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||
>
|
||
<option value="NO">On the whole document</option>
|
||
<option value="YES">Per line</option>
|
||
</select>
|
||
</Labelled>
|
||
<Labelled label="Prices">
|
||
<select
|
||
value={taxIncluded ? 'incl' : 'excl'}
|
||
onChange={(ev) => setTaxIncluded(ev.target.value === 'incl')}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||
>
|
||
<option value="excl">Tax exclusive</option>
|
||
<option value="incl">Tax inclusive</option>
|
||
</select>
|
||
</Labelled>
|
||
</div>
|
||
|
||
{isRecurring && (
|
||
<label className="mt-3 flex items-center gap-2 text-xs">
|
||
<Checkbox
|
||
checked={sendAutomatically}
|
||
onCheckedChange={(checked) => setSendAutomatically(checked === true)}
|
||
/>
|
||
Email each generated invoice to the customer automatically
|
||
</label>
|
||
)}
|
||
|
||
<div className="mt-4 rounded-lg border border-border">
|
||
<div className="grid grid-cols-[1fr_70px_110px_110px_110px_32px] items-center gap-2 border-b border-border px-2 py-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||
<span>Item</span>
|
||
<span className="text-right">Qty</span>
|
||
<span className="text-right">Price</span>
|
||
<span className="text-right">Discount</span>
|
||
<span className="text-right">Total</span>
|
||
<span />
|
||
</div>
|
||
|
||
{lines.map((line) => {
|
||
const computed = computeLine(line, taxTypes, taxPerItem);
|
||
return (
|
||
<div key={line.key} className="border-b border-border/40 px-2 py-2">
|
||
<div className="grid grid-cols-[1fr_70px_110px_110px_110px_32px] items-start gap-2">
|
||
<div className="min-w-0 space-y-1">
|
||
<div className="flex gap-1">
|
||
<Input
|
||
value={line.name}
|
||
onChange={(ev) => patchLine(line.key, { name: ev.target.value, item_id: null })}
|
||
placeholder="Description of work"
|
||
className="h-8 text-xs"
|
||
/>
|
||
<select
|
||
value=""
|
||
onChange={(ev) => ev.target.value && pickItem(line.key, Number(ev.target.value))}
|
||
title="Fill from the item catalogue"
|
||
className="h-8 w-8 shrink-0 rounded-md border border-input bg-background text-xs"
|
||
>
|
||
<option value="">+</option>
|
||
{items.map((i) => (
|
||
<option key={i.id} value={i.id}>
|
||
{i.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<Input
|
||
value={line.description}
|
||
onChange={(ev) => patchLine(line.key, { description: ev.target.value })}
|
||
placeholder="Notes (optional)"
|
||
className="h-7 text-[11px]"
|
||
/>
|
||
{taxPerItem === 'YES' && (
|
||
<TaxPicker
|
||
taxTypes={taxTypes}
|
||
selected={line.tax_type_ids}
|
||
onChange={(ids) => patchLine(line.key, { tax_type_ids: ids })}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<Input
|
||
type="number"
|
||
value={line.quantity}
|
||
onChange={(ev) => patchLine(line.key, { quantity: Number(ev.target.value) })}
|
||
className="h-8 text-right text-xs"
|
||
/>
|
||
<Input
|
||
type="number"
|
||
step="0.01"
|
||
value={toMajor(line.price)}
|
||
onChange={(ev) => patchLine(line.key, { price: fromMajor(ev.target.value) })}
|
||
className="h-8 text-right text-xs"
|
||
/>
|
||
<div className="flex gap-1">
|
||
<Input
|
||
type="number"
|
||
step="0.01"
|
||
value={line.discount}
|
||
onChange={(ev) => patchLine(line.key, { discount: Number(ev.target.value) })}
|
||
className="h-8 text-right text-xs"
|
||
/>
|
||
<select
|
||
value={line.discount_type}
|
||
onChange={(ev) =>
|
||
patchLine(line.key, { discount_type: ev.target.value as 'fixed' | 'percentage' })
|
||
}
|
||
className="h-8 w-9 shrink-0 rounded-md border border-input bg-background text-xs"
|
||
>
|
||
<option value="fixed">{currency?.symbol ?? '#'}</option>
|
||
<option value="percentage">%</option>
|
||
</select>
|
||
</div>
|
||
<div className="pt-2 text-right text-xs tabular-nums">{formatMoney(computed.total, currency)}</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
type="button"
|
||
className="h-8 w-8 p-0 text-muted-foreground"
|
||
disabled={lines.length === 1}
|
||
onClick={() => setLines((prev) => prev.filter((l) => l.key !== line.key))}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<div className="px-2 py-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
type="button"
|
||
className="h-7 text-xs"
|
||
onClick={() => setLines((prev) => [...prev, newLine(prev.length)])}
|
||
>
|
||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||
Add line
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||
<div>
|
||
<Labelled label="Notes">
|
||
<Textarea
|
||
value={notes}
|
||
onChange={(ev) => setNotes(ev.target.value)}
|
||
rows={6}
|
||
className="text-xs"
|
||
placeholder="Shown on the document."
|
||
/>
|
||
</Labelled>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-border p-3">
|
||
<TotalRow label="Subtotal" value={formatMoney(totals.subTotal, currency)} />
|
||
|
||
<div className="flex items-center justify-between gap-2 py-1 text-xs">
|
||
<span className="text-muted-foreground">Discount</span>
|
||
<div className="flex items-center gap-1">
|
||
<Input
|
||
type="number"
|
||
step="0.01"
|
||
value={discount}
|
||
onChange={(ev) => setDiscount(Number(ev.target.value))}
|
||
className="h-7 w-24 text-right text-xs"
|
||
/>
|
||
<select
|
||
value={discountType}
|
||
onChange={(ev) => setDiscountType(ev.target.value as 'fixed' | 'percentage')}
|
||
className="h-7 w-10 rounded-md border border-input bg-background text-xs"
|
||
>
|
||
<option value="fixed">{currency?.symbol ?? '#'}</option>
|
||
<option value="percentage">%</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{taxPerItem === 'NO' && (
|
||
<div className="py-1">
|
||
<div className="mb-1 text-[10px] text-muted-foreground">Taxes</div>
|
||
<TaxPicker taxTypes={taxTypes} selected={docTaxIds} onChange={setDocTaxIds} />
|
||
</div>
|
||
)}
|
||
|
||
<TotalRow label="Tax" value={formatMoney(totals.totalTax, currency)} />
|
||
<div className="mt-1 border-t border-border pt-1">
|
||
<TotalRow label="Total" value={formatMoney(totals.total, currency)} strong />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
);
|
||
};
|
||
|
||
const Labelled = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||
{label}
|
||
{children}
|
||
</label>
|
||
);
|
||
|
||
const TotalRow = ({ label, value, strong }: { label: string; value: string; strong?: boolean }) => (
|
||
<div className="flex items-baseline justify-between gap-3 py-1 text-xs">
|
||
<span className="text-muted-foreground">{label}</span>
|
||
<span className={`tabular-nums ${strong ? 'text-sm font-semibold' : ''}`}>{value}</span>
|
||
</div>
|
||
);
|
||
|
||
/** Multi-select over the company's tax types, rendered as toggles because there are only ever a handful. */
|
||
const TaxPicker = ({
|
||
taxTypes,
|
||
selected,
|
||
onChange,
|
||
}: {
|
||
taxTypes: TaxType[];
|
||
selected: number[];
|
||
onChange: (ids: number[]) => void;
|
||
}) => {
|
||
if (!taxTypes.length) return <span className="text-[10px] text-muted-foreground">No tax types defined.</span>;
|
||
|
||
return (
|
||
<div className="flex flex-wrap gap-1">
|
||
{taxTypes.map((t) => {
|
||
const on = selected.includes(t.id);
|
||
return (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
onClick={() => onChange(on ? selected.filter((i) => i !== t.id) : [...selected, t.id])}
|
||
className={`rounded-full px-2 py-0.5 text-[10px] ring-1 ring-inset transition-colors ${
|
||
on ? 'bg-primary/10 text-primary ring-primary/30' : 'text-muted-foreground ring-border hover:bg-muted'
|
||
}`}
|
||
>
|
||
{t.name} {Number(t.percent)}%
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ── arithmetic ───────────────────────────────────────────────────────────────────────────────────
|
||
|
||
type LineTotals = { subTotal: number; discountVal: number; total: number; tax: number };
|
||
|
||
function computeLine(line: Line, taxTypes: TaxType[], taxPerItem: 'YES' | 'NO'): LineTotals {
|
||
const subTotal = Math.round(line.price * (Number(line.quantity) || 0));
|
||
const abs = Math.abs(subTotal);
|
||
const discountVal =
|
||
line.discount_type === 'percentage'
|
||
? Math.round((abs * (Number(line.discount) || 0)) / 100)
|
||
: Math.min(fromMajor(line.discount), abs);
|
||
const total = subTotal - discountVal;
|
||
|
||
const tax =
|
||
taxPerItem === 'YES'
|
||
? line.tax_type_ids.reduce(
|
||
(sum, tid) =>
|
||
sum +
|
||
percentOf(
|
||
total,
|
||
taxTypes.find((t) => t.id === tid),
|
||
),
|
||
0,
|
||
)
|
||
: 0;
|
||
|
||
return { subTotal, discountVal, total, tax };
|
||
}
|
||
|
||
const percentOf = (base: number, taxType?: TaxType): number =>
|
||
taxType ? Math.round((base * (Number(taxType.percent) || 0)) / 100) : 0;
|
||
|
||
type TotalsInput = {
|
||
lines: Line[];
|
||
discount: number;
|
||
discountType: 'fixed' | 'percentage';
|
||
docTaxIds: number[];
|
||
taxPerItem: 'YES' | 'NO';
|
||
taxIncluded: boolean;
|
||
taxTypes: TaxType[];
|
||
};
|
||
|
||
function computeTotals(input: TotalsInput) {
|
||
const { lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes } = input;
|
||
|
||
const computed = lines.map((l) => computeLine(l, taxTypes, taxPerItem));
|
||
const subTotal = computed.reduce((sum, c) => sum + c.total, 0);
|
||
|
||
const documentDiscount =
|
||
discountType === 'percentage'
|
||
? Math.round((Math.abs(subTotal) * (Number(discount) || 0)) / 100)
|
||
: Math.min(fromMajor(discount), Math.abs(subTotal));
|
||
|
||
const taxBase = subTotal - documentDiscount;
|
||
|
||
let totalTax: number;
|
||
if (taxPerItem === 'YES') {
|
||
totalTax = computed.reduce((sum, c) => sum + c.tax, 0);
|
||
} else {
|
||
const chosen = docTaxIds.map((tid) => taxTypes.find((t) => t.id === tid)).filter((t): t is TaxType => Boolean(t));
|
||
const simple = chosen.filter((t) => !t.compound_tax).reduce((sum, t) => sum + percentOf(taxBase, t), 0);
|
||
// A compound tax is charged on the base plus the simple taxes already added — the order matters and
|
||
// reversing it silently under-charges.
|
||
const compound = chosen.filter((t) => t.compound_tax).reduce((sum, t) => sum + percentOf(taxBase + simple, t), 0);
|
||
totalTax = simple + compound;
|
||
}
|
||
|
||
return {
|
||
subTotal,
|
||
documentDiscount,
|
||
taxBase,
|
||
totalTax,
|
||
total: taxIncluded ? taxBase : taxBase + totalTax,
|
||
};
|
||
}
|
||
|
||
/** The tax row shape upstream persists alongside a document or a line. */
|
||
function taxLine(taxTypeId: number, taxTypes: TaxType[], base: number): Partial<DocumentTax> & { tax_type_id: number } {
|
||
const t = taxTypes.find((x) => x.id === taxTypeId);
|
||
return {
|
||
tax_type_id: taxTypeId,
|
||
name: t?.name,
|
||
percent: Number(t?.percent) || 0,
|
||
compound_tax: t?.compound_tax ?? false,
|
||
amount: percentOf(base, t),
|
||
};
|
||
}
|