Files
invoices/web/RecordEditors.tsx
pastilhasandClaude Opus 5 06203f97b3 wrap the document, record and send-mail editors in real forms
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>
2026-08-19 10:43:59 +00:00

745 lines
27 KiB
TypeScript

import type { ReactNode } from 'react';
import type { Address, Customer, Expense, Invoice, Item, Payment } from './shared';
import { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { formatMoney, fromMajor, toInputDate, toMajor } from './format';
import {
useCurrency,
useLookups,
useNextNumber,
useResourceDetail,
useResourceList,
useResourceMutations,
} from './useInvoiceShelfData';
// The four small record forms: customer, item, payment, expense. Dialogs rather than full screens —
// each is a handful of fields, and unlike a document none of them needs a line-item table.
//
// One shape for all four: `id === 'new'` creates, a number edits, and the form seeds itself once from the
// loaded record. The seed is guarded by a `loaded` flag rather than a dependency list because react-query
// re-delivers the record on every background refetch, and re-seeding mid-edit would silently discard what
// the user had typed.
//
// Money is entered in MAJOR units and written back in minor ones (`fromMajor`), the same convention as the
// document editor. Reading goes the other way through `toMajor`.
type EditorProps = { id: number | 'new'; onClose: () => void };
// ── shared bits ──────────────────────────────────────────────────────────────────────────────────
const Row = ({ children }: { children: ReactNode }) => <div className="grid gap-3 sm:grid-cols-2">{children}</div>;
const Cell = ({ label, children }: { label: string; children: ReactNode }) => (
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">{label}</Label>
{children}
</div>
);
const Select = ({
value,
onChange,
children,
}: {
value: string | number;
onChange: (value: string) => void;
children: ReactNode;
}) => (
<select
value={value}
onChange={(ev) => onChange(ev.target.value)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
{children}
</select>
);
const EditorShell = ({
title,
description,
canSave,
busy,
onSave,
onClose,
children,
wide,
}: {
title: string;
description?: string;
canSave: boolean;
busy: boolean;
onSave: () => void;
onClose: () => void;
children: ReactNode;
wide?: boolean;
}) => (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className={wide ? 'sm:max-w-2xl' : 'sm:max-w-lg'}>
{/* `contents` keeps the form invisible to DialogContent's grid layout — it still owns the gap between
header, body and footer, the form just carries Enter-to-submit across all three. */}
<form
onSubmit={(ev) => {
ev.preventDefault();
onSave();
}}
className="contents"
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="max-h-[65vh] space-y-3 overflow-y-auto pr-1">{children}</div>
<DialogFooter>
<Button variant="ghost" size="sm" type="button" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" size="sm" disabled={!canSave || busy}>
{busy ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
// ── customer ─────────────────────────────────────────────────────────────────────────────────────
const EMPTY_ADDRESS = {
name: '',
address_street_1: '',
address_street_2: '',
city: '',
state: '',
zip: '',
phone: '',
country_id: '' as number | '',
};
type AddressForm = typeof EMPTY_ADDRESS;
const fromAddress = (a: Address | null | undefined): AddressForm =>
a
? {
name: a.name ?? '',
address_street_1: a.address_street_1 ?? '',
address_street_2: a.address_street_2 ?? '',
city: a.city ?? '',
state: a.state ?? '',
zip: a.zip ?? '',
phone: a.phone ?? '',
country_id: a.country_id ?? '',
}
: { ...EMPTY_ADDRESS };
export const CustomerEditor = ({ id, onClose }: EditorProps) => {
const isNew = id === 'new';
const { currencies } = useLookups();
const currency = useCurrency();
const { record } = useResourceDetail<Customer>('customers', isNew ? null : (id as number));
const { create, update } = useResourceMutations('customers');
// Read-only upstream; the address selects are unusable without it.
const { rows: countries } = useResourceList<{ id: number; name: string }>('countries');
const [form, setForm] = useState({
name: '',
email: '',
phone: '',
contact_name: '',
company_name: '',
website: '',
prefix: '',
tax_id: '',
currency_id: '' as number | '',
enable_portal: false,
});
const [billing, setBilling] = useState<AddressForm>({ ...EMPTY_ADDRESS });
const [shipping, setShipping] = useState<AddressForm>({ ...EMPTY_ADDRESS });
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (loaded || isNew || !record) return;
setForm({
name: record.name ?? '',
email: record.email ?? '',
phone: record.phone ?? '',
contact_name: record.contact_name ?? '',
company_name: record.company_name ?? '',
website: record.website ?? '',
prefix: record.prefix ?? '',
tax_id: record.tax_id ?? '',
currency_id: record.currency_id ?? '',
enable_portal: Boolean(record.enable_portal),
});
setBilling(fromAddress(record.billing));
setShipping(fromAddress(record.shipping));
setLoaded(true);
}, [record, isNew, loaded]);
const patch = (changes: Partial<typeof form>) => setForm((prev) => ({ ...prev, ...changes }));
const save = () => {
const payload = {
...form,
email: form.email || null,
currency_id: form.currency_id === '' ? (currency?.id ?? null) : form.currency_id,
billing: { ...billing, country_id: billing.country_id === '' ? null : billing.country_id },
shipping: { ...shipping, country_id: shipping.country_id === '' ? null : shipping.country_id },
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
return (
<EditorShell
title={isNew ? 'New customer' : `Edit ${form.name || 'customer'}`}
canSave={form.name.trim() !== ''}
busy={create.isPending || update.isPending}
onSave={save}
onClose={onClose}
wide
>
<Row>
<Cell label="Display name">
<Input value={form.name} onChange={(ev) => patch({ name: ev.target.value })} className="h-8 text-xs" />
</Cell>
<Cell label="Contact name">
<Input
value={form.contact_name}
onChange={(ev) => patch({ contact_name: ev.target.value })}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Email">
<Input
type="email"
value={form.email}
onChange={(ev) => patch({ email: ev.target.value })}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Phone">
<Input value={form.phone} onChange={(ev) => patch({ phone: ev.target.value })} className="h-8 text-xs" />
</Cell>
<Cell label="Company">
<Input
value={form.company_name}
onChange={(ev) => patch({ company_name: ev.target.value })}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Website">
<Input value={form.website} onChange={(ev) => patch({ website: ev.target.value })} className="h-8 text-xs" />
</Cell>
<Cell label="Tax ID">
<Input value={form.tax_id} onChange={(ev) => patch({ tax_id: ev.target.value })} className="h-8 text-xs" />
</Cell>
<Cell label="Currency">
<Select value={form.currency_id} onChange={(v) => patch({ currency_id: v ? Number(v) : '' })}>
<option value="">Company default</option>
{currencies.map((c) => (
<option key={c.id} value={c.id}>
{c.code} — {c.name}
</option>
))}
</Select>
</Cell>
</Row>
<label className="flex items-center gap-2 text-xs">
<Checkbox
checked={form.enable_portal}
onCheckedChange={(checked) => patch({ enable_portal: checked === true })}
/>
Give this customer a portal login
</label>
<AddressFields label="Billing address" value={billing} onChange={setBilling} countries={countries} />
<AddressFields
label="Shipping address"
value={shipping}
onChange={setShipping}
countries={countries}
onCopy={() => setShipping({ ...billing })}
/>
</EditorShell>
);
};
const AddressFields = ({
label,
value,
onChange,
countries,
onCopy,
}: {
label: string;
value: AddressForm;
onChange: (next: AddressForm) => void;
countries: { id: number; name: string }[];
onCopy?: () => void;
}) => {
const patch = (changes: Partial<AddressForm>) => onChange({ ...value, ...changes });
return (
<div className="rounded-lg border border-border p-2">
<div className="mb-2 flex items-center justify-between">
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
{onCopy && (
<Button variant="ghost" size="sm" type="button" className="h-6 text-[10px]" onClick={onCopy}>
Same as billing
</Button>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<Input
placeholder="Name"
value={value.name}
onChange={(ev) => patch({ name: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="Phone"
value={value.phone}
onChange={(ev) => patch({ phone: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="Street"
value={value.address_street_1}
onChange={(ev) => patch({ address_street_1: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="Street 2"
value={value.address_street_2}
onChange={(ev) => patch({ address_street_2: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="City"
value={value.city}
onChange={(ev) => patch({ city: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="State"
value={value.state}
onChange={(ev) => patch({ state: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="ZIP"
value={value.zip}
onChange={(ev) => patch({ zip: ev.target.value })}
className="h-8 text-xs"
/>
<Select value={value.country_id} onChange={(v) => patch({ country_id: v ? Number(v) : '' })}>
<option value="">Country…</option>
{countries.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</div>
</div>
);
};
// ── item ─────────────────────────────────────────────────────────────────────────────────────────
export const ItemEditor = ({ id, onClose }: EditorProps) => {
const isNew = id === 'new';
const currency = useCurrency();
const { units } = useLookups();
const { record } = useResourceDetail<Item>('items', isNew ? null : (id as number));
const { create, update } = useResourceMutations('items');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState(0);
const [unitId, setUnitId] = useState<number | ''>('');
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (loaded || isNew || !record) return;
setName(record.name ?? '');
setDescription(record.description ?? '');
setPrice(toMajor(record.price));
setUnitId(record.unit_id ?? '');
setLoaded(true);
}, [record, isNew, loaded]);
const save = () => {
const payload = {
name: name.trim(),
description: description || null,
price: fromMajor(price),
unit_id: unitId === '' ? null : unitId,
currency_id: record?.currency_id ?? currency?.id ?? null,
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
return (
<EditorShell
title={isNew ? 'New item' : `Edit ${name || 'item'}`}
description="A saved line you can drop onto any invoice or estimate."
canSave={name.trim() !== ''}
busy={create.isPending || update.isPending}
onSave={save}
onClose={onClose}
>
<Cell label="Name">
<Input value={name} onChange={(ev) => setName(ev.target.value)} className="h-8 text-xs" />
</Cell>
<Row>
<Cell label={`Price (${currency?.code ?? ''})`}>
<Input
type="number"
step="0.01"
value={price}
onChange={(ev) => setPrice(Number(ev.target.value))}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Unit">
<Select value={unitId} onChange={(v) => setUnitId(v ? Number(v) : '')}>
<option value="">None</option>
{units.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</Select>
</Cell>
</Row>
<Cell label="Description">
<Textarea value={description} onChange={(ev) => setDescription(ev.target.value)} rows={3} className="text-xs" />
</Cell>
</EditorShell>
);
};
// ── payment ──────────────────────────────────────────────────────────────────────────────────────
/**
* `forInvoiceId` is the "Record payment" path from the invoices list: the invoice is known, its customer
* and outstanding amount are not, so they are fetched and filled in rather than made the user's problem.
*/
export const PaymentEditor = ({ id, forInvoiceId, onClose }: EditorProps & { forInvoiceId?: number }) => {
const isNew = id === 'new';
const currency = useCurrency();
const { customers, paymentMethods } = useLookups();
const { record } = useResourceDetail<Payment>('payments', isNew ? null : (id as number));
const { record: seedInvoice } = useResourceDetail<Invoice>('invoices', isNew ? (forInvoiceId ?? null) : null);
const { create, update } = useResourceMutations('payments');
const nextNumber = useNextNumber('payment', isNew);
const [customerId, setCustomerId] = useState<number | ''>('');
const [invoiceId, setInvoiceId] = useState<number | ''>('');
const [number, setNumber] = useState('');
const [date, setDate] = useState(() => toInputDate(new Date().toISOString()));
const [amount, setAmount] = useState(0);
const [methodId, setMethodId] = useState<number | ''>('');
const [notes, setNotes] = useState('');
const [loaded, setLoaded] = useState(false);
// `status: 'DUE'` is upstream's own token for "paid_status in (UNPAID, PARTIALLY_PAID)" — the only
// invoices a payment can sensibly be attached to. Fetched only once a customer is chosen.
const { rows: openInvoices } = useResourceList<Invoice>(
'invoices',
{ customer_id: customerId || undefined, status: 'DUE', limit: 100 },
customerId !== '',
);
useEffect(() => {
if (loaded) return;
if (isNew) {
if (!nextNumber) return;
// Wait for the seed invoice too, or the form would open blank and then jump under the user's cursor.
if (forInvoiceId != null && !seedInvoice) return;
setNumber(nextNumber);
if (seedInvoice) {
setCustomerId(seedInvoice.customer_id);
setInvoiceId(seedInvoice.id);
setAmount(toMajor(seedInvoice.due_amount));
}
setLoaded(true);
return;
}
if (!record) return;
setCustomerId(record.customer_id ?? '');
setInvoiceId(record.invoice_id ?? '');
setNumber(record.payment_number ?? '');
setDate(toInputDate(record.payment_date));
setAmount(toMajor(record.amount));
setMethodId(record.payment_method_id ?? '');
setNotes(record.notes ?? '');
setLoaded(true);
}, [record, isNew, nextNumber, seedInvoice, forInvoiceId, loaded]);
// The pre-selected invoice may not be in the customer's open list yet (that query runs a beat later), so
// fall back to the seed for the over-payment warning.
const selected =
openInvoices.find((inv) => inv.id === invoiceId) ??
(seedInvoice && seedInvoice.id === invoiceId ? seedInvoice : undefined);
const save = () => {
const payload = {
payment_date: date,
payment_number: number.trim(),
customer_id: customerId === '' ? null : customerId,
invoice_id: invoiceId === '' ? null : invoiceId,
payment_method_id: methodId === '' ? null : methodId,
amount: fromMajor(amount),
notes: notes || null,
currency_id: record?.currency_id ?? currency?.id ?? null,
exchange_rate: 1,
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
return (
<EditorShell
title={isNew ? 'Record a payment' : `Edit payment ${number}`}
description="Recording a payment reduces the linked invoice's due amount. It does not email anyone."
canSave={customerId !== '' && number.trim() !== '' && date !== '' && amount > 0}
busy={create.isPending || update.isPending}
onSave={save}
onClose={onClose}
>
<Row>
<Cell label="Date">
<Input type="date" value={date} onChange={(ev) => setDate(ev.target.value)} className="h-8 text-xs" />
</Cell>
<Cell label="Payment number">
<Input value={number} onChange={(ev) => setNumber(ev.target.value)} className="h-8 text-xs" />
</Cell>
<Cell label="Customer">
<Select
value={customerId}
onChange={(v) => {
setCustomerId(v ? Number(v) : '');
// The old invoice belongs to the old customer; keeping it would post the payment to a
// document the new customer does not own.
setInvoiceId('');
}}
>
<option value="">Select…</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</Cell>
<Cell label="Invoice">
<Select
value={invoiceId}
onChange={(v) => {
const next = v ? Number(v) : '';
setInvoiceId(next);
// Default the amount to whatever is still outstanding — the overwhelmingly common case, and
// still editable for a part payment.
const inv = openInvoices.find((i) => i.id === next);
if (inv) setAmount(toMajor(inv.due_amount));
}}
>
<option value="">{customerId === '' ? 'Pick a customer first' : 'Unlinked'}</option>
{/* The pre-selected invoice is listed explicitly in case the open-invoice query has not landed
yet — otherwise the select would show a blank for a value it does hold. */}
{seedInvoice && !openInvoices.some((inv) => inv.id === seedInvoice.id) && (
<option value={seedInvoice.id}>
{seedInvoice.invoice_number} — {formatMoney(seedInvoice.due_amount, currency)} due
</option>
)}
{openInvoices.map((inv) => (
<option key={inv.id} value={inv.id}>
{inv.invoice_number} — {formatMoney(inv.due_amount, currency)} due
</option>
))}
</Select>
</Cell>
<Cell label={`Amount (${currency?.code ?? ''})`}>
<Input
type="number"
step="0.01"
value={amount}
onChange={(ev) => setAmount(Number(ev.target.value))}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Method">
<Select value={methodId} onChange={(v) => setMethodId(v ? Number(v) : '')}>
<option value="">None</option>
{paymentMethods.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Select>
</Cell>
</Row>
{selected && fromMajor(amount) > Number(selected.due_amount ?? 0) && (
<p className="text-[10px] text-warning">
More than the {formatMoney(selected.due_amount, currency)} outstanding on {selected.invoice_number}.
InvoiceShelf will refuse this.
</p>
)}
<Cell label="Notes">
<Textarea value={notes} onChange={(ev) => setNotes(ev.target.value)} rows={3} className="text-xs" />
</Cell>
</EditorShell>
);
};
// ── expense ──────────────────────────────────────────────────────────────────────────────────────
export const ExpenseEditor = ({ id, onClose }: EditorProps) => {
const isNew = id === 'new';
const currency = useCurrency();
const { customers, categories, paymentMethods } = useLookups();
const { record } = useResourceDetail<Expense>('expenses', isNew ? null : (id as number));
const { create, update } = useResourceMutations('expenses');
const [date, setDate] = useState(() => toInputDate(new Date().toISOString()));
const [number, setNumber] = useState('');
const [categoryId, setCategoryId] = useState<number | ''>('');
const [customerId, setCustomerId] = useState<number | ''>('');
const [methodId, setMethodId] = useState<number | ''>('');
const [amount, setAmount] = useState(0);
const [notes, setNotes] = useState('');
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (loaded || isNew || !record) return;
setDate(toInputDate(record.expense_date));
setNumber(record.expense_number ?? '');
setCategoryId(record.expense_category_id ?? '');
setCustomerId(record.customer_id ?? '');
setMethodId(record.payment_method_id ?? '');
setAmount(toMajor(record.amount));
setNotes(record.notes ?? '');
setLoaded(true);
}, [record, isNew, loaded]);
const save = () => {
const payload = {
expense_date: date,
expense_number: number || null,
expense_category_id: categoryId === '' ? null : categoryId,
customer_id: customerId === '' ? null : customerId,
payment_method_id: methodId === '' ? null : methodId,
amount: fromMajor(amount),
notes: notes || null,
currency_id: record?.currency_id ?? currency?.id ?? null,
exchange_rate: 1,
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
return (
<EditorShell
title={isNew ? 'New expense' : 'Edit expense'}
canSave={categoryId !== '' && date !== '' && amount !== 0}
busy={create.isPending || update.isPending}
onSave={save}
onClose={onClose}
>
<Row>
<Cell label="Date">
<Input type="date" value={date} onChange={(ev) => setDate(ev.target.value)} className="h-8 text-xs" />
</Cell>
<Cell label="Number">
<Input
value={number}
onChange={(ev) => setNumber(ev.target.value)}
placeholder="Optional"
className="h-8 text-xs"
/>
</Cell>
<Cell label="Category">
<Select value={categoryId} onChange={(v) => setCategoryId(v ? Number(v) : '')}>
<option value="">Select…</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</Cell>
<Cell label={`Amount (${currency?.code ?? ''})`}>
<Input
type="number"
step="0.01"
value={amount}
onChange={(ev) => setAmount(Number(ev.target.value))}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Customer">
<Select value={customerId} onChange={(v) => setCustomerId(v ? Number(v) : '')}>
<option value="">None</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</Cell>
<Cell label="Paid with">
<Select value={methodId} onChange={(v) => setMethodId(v ? Number(v) : '')}>
<option value="">None</option>
{paymentMethods.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Select>
</Cell>
</Row>
<Cell label="Notes">
<Textarea value={notes} onChange={(ev) => setNotes(ev.target.value)} rows={3} className="text-xs" />
</Cell>
{/* Receipts are read-only here. Attaching one is a multipart upload to a separate endpoint that the
sidecar's JSON allow-list does not carry; an existing receipt is linked from the expenses list. */}
{record?.attachment_receipt_url && (
<p className="text-[10px] text-muted-foreground">
This expense has a receipt attached.{' '}
<a
href={record.attachment_receipt_url}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Open it
</a>
. Replacing it has to be done in InvoiceShelf.
</p>
)}
</EditorShell>
);
};