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>
745 lines
27 KiB
TypeScript
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>
|
|
);
|
|
};
|