Files
invoices/web/RecordEditors.tsx
T
officer 6c70eb8eb3 invoices, extracted from the platform as a plugin
Ninth plugin out, third resurrection: switched off since 2026-08-13 with the
permission reduced to api: [], so the server booted clean and every panel
request 404'd. invoiceshelf/schema was also commented out of the drizzle
barrel.

Named `invoices`, not `invoiceshelf`. The appName drives the API prefix, the
frontend route, the table prefix and the install key all at once, and before
extraction those had drifted: the API was /api/invoiceshelf while the screen
was /invoices. The URL is what a person sees and should name what the plugin
shows rather than the daemon behind it — the permission key and label were
already `invoices`, so the API prefix was the odd one out.

Cost: three call sites, and renaming invoiceshelf_accounts to invoices_accounts
to satisfy the table-prefix rule. That rename was free ONLY because the table
existed on no server, which was checked before deciding. With rows in it,
keeping the vendor name would have been the better trade: db:push sees a rename
as a drop plus a create and this project has no migration mechanism.

The sidecar keeps its Invoiceshelf-named functions deliberately — they name the
upstream API it actually speaks.

91 palette classes converted, 52 of them in STATUS_TONE, which was already the
single source of status colour but held raw palette strings. SENT and VIEWED
now share `info`: the closed six-tone set has no second shade of "in flight,
not yet actioned". Recorded in PLUGIN.md as a real loss.
2026-08-19 03:47:44 +00:00

735 lines
26 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'}>
<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" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="sm" onClick={onSave} disabled={!canSave || busy}>
{busy ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</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" 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>
);
};