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 }) =>
{children}
;
const Cell = ({ label, children }: { label: string; children: ReactNode }) => (
{label}
{children}
);
const Select = ({
value,
onChange,
children,
}: {
value: string | number;
onChange: (value: string) => void;
children: ReactNode;
}) => (
onChange(ev.target.value)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
{children}
);
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;
}) => (
!open && onClose()}>
{title}
{description && {description} }
{children}
Cancel
{busy ? 'Saving…' : 'Save'}
);
// ── 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('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({ ...EMPTY_ADDRESS });
const [shipping, setShipping] = useState({ ...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) => 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 (
patch({ name: ev.target.value })} className="h-8 text-xs" />
|
patch({ contact_name: ev.target.value })}
className="h-8 text-xs"
/>
|
patch({ email: ev.target.value })}
className="h-8 text-xs"
/>
|
patch({ phone: ev.target.value })} className="h-8 text-xs" />
|
patch({ company_name: ev.target.value })}
className="h-8 text-xs"
/>
|
patch({ website: ev.target.value })} className="h-8 text-xs" />
|
patch({ tax_id: ev.target.value })} className="h-8 text-xs" />
|
patch({ currency_id: v ? Number(v) : '' })}>
Company default
{currencies.map((c) => (
{c.code} — {c.name}
))}
|
patch({ enable_portal: checked === true })}
/>
Give this customer a portal login
setShipping({ ...billing })}
/>
);
};
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) => onChange({ ...value, ...changes });
return (
);
};
// ── item ─────────────────────────────────────────────────────────────────────────────────────────
export const ItemEditor = ({ id, onClose }: EditorProps) => {
const isNew = id === 'new';
const currency = useCurrency();
const { units } = useLookups();
const { record } = useResourceDetail- ('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
('');
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 (
setName(ev.target.value)} className="h-8 text-xs" />
|
setPrice(Number(ev.target.value))}
className="h-8 text-xs"
/>
|
setUnitId(v ? Number(v) : '')}>
None
{units.map((u) => (
{u.name}
))}
|
|
);
};
// ── 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('payments', isNew ? null : (id as number));
const { record: seedInvoice } = useResourceDetail('invoices', isNew ? (forInvoiceId ?? null) : null);
const { create, update } = useResourceMutations('payments');
const nextNumber = useNextNumber('payment', isNew);
const [customerId, setCustomerId] = useState('');
const [invoiceId, setInvoiceId] = useState('');
const [number, setNumber] = useState('');
const [date, setDate] = useState(() => toInputDate(new Date().toISOString()));
const [amount, setAmount] = useState(0);
const [methodId, setMethodId] = useState('');
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(
'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 (
0}
busy={create.isPending || update.isPending}
onSave={save}
onClose={onClose}
>
setDate(ev.target.value)} className="h-8 text-xs" />
|
setNumber(ev.target.value)} className="h-8 text-xs" />
|
{
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('');
}}
>
Select…
{customers.map((c) => (
{c.name}
))}
|
{
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));
}}
>
{customerId === '' ? 'Pick a customer first' : 'Unlinked'}
{/* 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) && (
{seedInvoice.invoice_number} — {formatMoney(seedInvoice.due_amount, currency)} due
)}
{openInvoices.map((inv) => (
{inv.invoice_number} — {formatMoney(inv.due_amount, currency)} due
))}
|
setAmount(Number(ev.target.value))}
className="h-8 text-xs"
/>
|
setMethodId(v ? Number(v) : '')}>
None
{paymentMethods.map((m) => (
{m.name}
))}
|
{selected && fromMajor(amount) > Number(selected.due_amount ?? 0) && (
More than the {formatMoney(selected.due_amount, currency)} outstanding on {selected.invoice_number}.
InvoiceShelf will refuse this.
)}
|
);
};
// ── expense ──────────────────────────────────────────────────────────────────────────────────────
export const ExpenseEditor = ({ id, onClose }: EditorProps) => {
const isNew = id === 'new';
const currency = useCurrency();
const { customers, categories, paymentMethods } = useLookups();
const { record } = useResourceDetail('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('');
const [customerId, setCustomerId] = useState('');
const [methodId, setMethodId] = useState('');
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 (
setDate(ev.target.value)} className="h-8 text-xs" />
|
setNumber(ev.target.value)}
placeholder="Optional"
className="h-8 text-xs"
/>
|
setCategoryId(v ? Number(v) : '')}>
Select…
{categories.map((c) => (
{c.name}
))}
|
setAmount(Number(ev.target.value))}
className="h-8 text-xs"
/>
|
setCustomerId(v ? Number(v) : '')}>
None
{customers.map((c) => (
{c.name}
))}
|
setMethodId(v ? Number(v) : '')}>
None
{paymentMethods.map((m) => (
{m.name}
))}
|
|
{/* 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 && (
This expense has a receipt attached.{' '}
Open it
. Replacing it has to be done in InvoiceShelf.
)}
);
};