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 }) => (
{children}
); const Select = ({ value, onChange, children, }: { value: string | number; onChange: (value: string) => void; children: ReactNode; }) => ( ); 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}
); // ── 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" /> 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 (
{label}
{onCopy && ( )}
patch({ name: ev.target.value })} className="h-8 text-xs" /> patch({ phone: ev.target.value })} className="h-8 text-xs" /> patch({ address_street_1: ev.target.value })} className="h-8 text-xs" /> patch({ address_street_2: ev.target.value })} className="h-8 text-xs" /> patch({ city: ev.target.value })} className="h-8 text-xs" /> patch({ state: ev.target.value })} className="h-8 text-xs" /> patch({ zip: ev.target.value })} className="h-8 text-xs" />
); }; // ── 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" />