Bills: Buildium-style record/edit form + new read-only bill detail view

- Record/Edit dialog redesigned to a two-column layout: large attachment panel
  (drop/preview/replace) on the left; Bill Details + Item Details table on the
  right; prominent total bar; Save / Save & add another / Cancel footer.
  Keeps AI parse, field highlighting, vendor mapping
- New read-only bill detail view (click a bill #/vendor): header with vendor +
  status + Pay/Duplicate/Delete/Edit, Bill details card, item-details table,
  and a Bill amount sidebar (Total / Paid / Remaining)
- duplicateBill clones a bill into a fresh create form

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 23:41:36 -04:00
parent 512abcc1a2
commit f518f0b8f4
+253 -85
View File
@@ -253,7 +253,7 @@ export default function AccountingBillsPage() {
}
};
const save = async () => {
const save = async (keepOpen = false) => {
if (!number.trim()) return toast.error("Bill number required");
let attachmentUrl = uploadedUrl;
if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file);
@@ -299,11 +299,45 @@ export default function AccountingBillsPage() {
await accounting.from("bill_items").insert(itemRows(bill.id));
toast.success("Bill recorded");
}
setOpen(false);
resetForm();
if (!keepOpen) setOpen(false);
qc.invalidateQueries({ queryKey: ["bills", cid] });
};
// ── Read-only bill detail view ──
const [viewBill, setViewBill] = useState<any | null>(null);
const [viewItems, setViewItems] = useState<any[]>([]);
const openView = async (b: any) => {
setViewBill(b);
const { data } = await accounting.from("bill_items").select("*").eq("bill_id", b.id);
setViewItems(data ?? []);
};
// Clone a bill into a fresh create form (today's date, blank number).
const duplicateBill = async (b: any) => {
setViewBill(null);
let pubVendorId = "";
if (b.vendor_id) {
const { data: av } = await accounting.from("vendors").select("external_source, external_id").eq("id", b.vendor_id).maybeSingle();
if (av?.external_source === "acmacc_vendor" && av?.external_id) pubVendorId = String(av.external_id);
}
const { data: its } = await accounting.from("bill_items").select("*").eq("bill_id", b.id);
setEditId(null);
setVendorId(pubVendorId);
setNumber("");
setIssueDate(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }));
setDueDate("");
const sub = Number(b.subtotal || 0);
setTaxPct(sub > 0 ? +((Number(b.tax || 0) / sub) * 100).toFixed(4) : 0);
setNotes(b.notes ?? "");
setUploadedUrl(null); setFile(null); setFilePreview(null);
setAiFilled(new Set()); setMissingFields(new Set());
setItems(its && its.length
? its.map((i: any) => ({ description: i.description ?? "", quantity: Number(i.quantity ?? 1), rate: Number(i.rate ?? 0), account_id: i.account_id ?? null }))
: [{ description: "", quantity: 1, rate: 0, account_id: null }]);
setOpen(true);
};
// Payment dialog state
const [payBill, setPayBill] = useState<any | null>(null);
@@ -535,49 +569,68 @@ export default function AccountingBillsPage() {
<h1 className="text-2xl font-semibold">Bills</h1>
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) resetForm(); }}>
<DialogTrigger asChild><Button><Plus className="mr-1 h-4 w-4" /> New Bill</Button></DialogTrigger>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>{editId ? "Edit bill" : "New bill"}</DialogTitle></DialogHeader>
<DialogContent className="max-w-5xl max-h-[92vh] overflow-hidden p-0 gap-0 flex flex-col">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle>{editId ? "Edit bill" : "Record bill"}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
{aiFilled.size > 0 && (
<div className="rounded-md border border-amber-300 bg-amber-50 text-amber-900 px-3 py-2 text-sm flex items-start gap-2">
<div className="mx-6 mt-4 rounded-md border border-amber-300 bg-amber-50 text-amber-900 px-3 py-2 text-sm flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<span>AI has filled in this bill please review all fields before saving.</span>
<span>AI has filled in this bill please review highlighted fields before saving.</span>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Form */}
<div className="md:col-span-2 space-y-4">
{/* Upload zone */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6">
{/* ── Left: attachment panel ── */}
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault(); setDragOver(false);
const f = e.dataTransfer.files?.[0]; if (f) handleFile(f);
}}
onDrop={(e) => { e.preventDefault(); setDragOver(false); const f = e.dataTransfer.files?.[0]; if (f) handleFile(f); }}
className={cn(
"rounded-lg border-2 border-dashed p-4 text-center text-sm cursor-pointer transition-colors",
dragOver ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50"
"relative rounded-lg border-2 border-dashed min-h-[420px] flex flex-col items-center justify-center text-center p-6 transition-colors",
dragOver ? "border-primary bg-primary/5" : "border-border",
!filePreview && !file && !uploadedUrl && "cursor-pointer hover:bg-muted/40",
)}
onClick={() => fileRef.current?.click()}
onClick={() => { if (!filePreview && !file && !uploadedUrl) fileRef.current?.click(); }}
>
<input
ref={fileRef} type="file" accept={ACCEPT} className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
<Upload className="mx-auto h-5 w-5 text-muted-foreground" />
<div className="mt-1 font-medium">
{parsing ? "Parsing with AI…" : file ? file.name : "Upload & Parse Bill"}
<input ref={fileRef} type="file" accept={ACCEPT} className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
{parsing ? (
<div className="text-sm text-muted-foreground"><Loader2 className="mx-auto h-6 w-6 animate-spin mb-2" />Parsing with AI</div>
) : filePreview ? (
<img src={filePreview} alt="Bill" className="max-h-[400px] w-full object-contain rounded" />
) : uploadedUrl && !file ? (
<a href={uploadedUrl} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline inline-flex flex-col items-center gap-2">
<FileText className="h-12 w-12" /> View attached document
</a>
) : file ? (
<div className="text-sm text-muted-foreground">
<FileText className="mx-auto h-12 w-12 mb-2 text-foreground/70" />
<div className="font-medium text-foreground">{file.name}</div>
<div className="text-xs">{(file.size / 1024).toFixed(1)} KB</div>
</div>
<div className="text-xs text-muted-foreground">
{parsing ? "Extracting fields from your bill…" : "Drag & drop or click — PDF, JPG, PNG, HEIC · auto-parses on drop"}
</div>
{file && !parsing && (
<div className="mt-2 flex justify-center gap-2" onClick={(e) => e.stopPropagation()}>
) : (
<>
<Receipt className="h-12 w-12 text-muted-foreground/40" />
<div className="mt-3 font-medium">Upload &amp; view your attachment here</div>
<div className="text-xs text-muted-foreground mt-1">PDF, JPG, PNG, HEIC · max 20MB · auto-parses with AI</div>
<div className="text-xs text-muted-foreground mt-1">Drag &amp; drop or <span className="text-primary underline">browse</span></div>
</>
)}
{(file || uploadedUrl) && !parsing && (
<div className="mt-3 flex justify-center gap-2" onClick={(e) => e.stopPropagation()}>
{file && (
<Button size="sm" variant="outline" onClick={() => runParse(file)}>
<Sparkles className="mr-1 h-3.5 w-3.5" /> Re-parse
</Button>
)}
<Button size="sm" variant="outline" onClick={() => fileRef.current?.click()}>
<Upload className="mr-1 h-3.5 w-3.5" /> Replace
</Button>
<Button size="sm" variant="ghost" onClick={() => { setFile(null); setFilePreview(null); setUploadedUrl(null); setAiFilled(new Set()); setMissingFields(new Set()); }}>
<X className="h-3.5 w-3.5" />
</Button>
@@ -585,94 +638,98 @@ export default function AccountingBillsPage() {
)}
</div>
{/* ── Right: form ── */}
<div className="space-y-5">
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">Bill Details</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Vendor</Label>
<Label>Date *</Label>
<Input type="date" value={issueDate} onChange={(e) => setIssueDate(e.target.value)} className={hl("issueDate")} />
</div>
<div>
<Label>Due *</Label>
<Input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} className={hl("dueDate")} />
</div>
</div>
<div className="mt-3">
<Label>Pay to *</Label>
<Select value={vendorId} onValueChange={setVendorId}>
<SelectTrigger className={hl("vendor") || (aiFilled.has("vendor_unmatched") ? ERR : "")}>
<SelectValue placeholder={aiFilled.has("vendor_unmatched") ? "Could not match — pick vendor" : "Select vendor"} />
<SelectValue placeholder={aiFilled.has("vendor_unmatched") ? "Could not match — pick vendor" : "Select a vendor"} />
</SelectTrigger>
<SelectContent>{(vendors as any[]).map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3 mt-3">
<div>
<Label>Bill #{missingFields.has("number") && <span className="text-red-600 text-xs ml-1">Could not detect enter manually</span>}</Label>
<Label>Reference number{missingFields.has("number") && <span className="text-red-600 text-xs ml-1">enter manually</span>}</Label>
<Input maxLength={40} value={number} onChange={(e) => setNumber(e.target.value)} className={hl("number")} />
</div>
<div>
<Label>Issue date</Label>
<Input type="date" value={issueDate} onChange={(e) => setIssueDate(e.target.value)} className={hl("issueDate")} />
<Label>Memo</Label>
<Input value={notes} onChange={(e) => setNotes(e.target.value)} className={hl("notes")} placeholder="Optional" />
</div>
<div>
<Label>Due date</Label>
<Input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} className={hl("dueDate")} />
</div>
</div>
<div>
<Label>Line items</Label>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">Item Details</h3>
<div className="rounded-md border overflow-hidden">
<div className="grid grid-cols-12 gap-2 bg-muted/50 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<div className="col-span-4">Account</div>
<div className="col-span-4">Description</div>
<div className="col-span-1 text-right">Qty</div>
<div className="col-span-2 text-right">Rate</div>
<div className="col-span-1" />
</div>
{items.map((it, idx) => (
<div key={idx} className={cn("mt-2 grid grid-cols-12 gap-2 p-2 rounded", aiFilled.has("items") && "bg-sky-50/60")}>
<Input className="col-span-5" placeholder="Description" maxLength={200} value={it.description}
onChange={(e) => { const a = [...items]; a[idx] = { ...it, description: e.target.value }; setItems(a); }} />
<Input className="col-span-1" type="number" min={0} step="0.01" value={it.quantity}
onChange={(e) => { const a = [...items]; a[idx] = { ...it, quantity: Number(e.target.value) }; setItems(a); }} />
<Input className="col-span-2" type="number" min={0} step="0.01" value={it.rate}
onChange={(e) => { const a = [...items]; a[idx] = { ...it, rate: Number(e.target.value) }; setItems(a); }} />
<div key={idx} className={cn("grid grid-cols-12 gap-2 px-3 py-2 border-t items-center", aiFilled.has("items") && "bg-sky-50/40")}>
<Select value={it.account_id ?? ""} onValueChange={(v) => { const a = [...items]; a[idx] = { ...it, account_id: v || null }; setItems(a); }}>
<SelectTrigger className="col-span-3 text-xs"><SelectValue placeholder="Account" /></SelectTrigger>
<SelectTrigger className="col-span-4 h-9 text-xs"><SelectValue placeholder="Select an account" /></SelectTrigger>
<SelectContent>
{(expenseAccounts as any[]).map((a) => (
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button className="col-span-1" variant="ghost" size="icon" onClick={() => setItems(items.filter((_, i) => i !== idx))}><Trash2 className="h-4 w-4" /></Button>
<Input className="col-span-4 h-9" placeholder="Description" maxLength={200} value={it.description}
onChange={(e) => { const a = [...items]; a[idx] = { ...it, description: e.target.value }; setItems(a); }} />
<Input className="col-span-1 h-9 text-right" type="number" min={0} step="0.01" value={it.quantity}
onChange={(e) => { const a = [...items]; a[idx] = { ...it, quantity: Number(e.target.value) }; setItems(a); }} />
<Input className="col-span-2 h-9 text-right" type="number" min={0} step="0.01" value={it.rate}
onChange={(e) => { const a = [...items]; a[idx] = { ...it, rate: Number(e.target.value) }; setItems(a); }} />
<Button className="col-span-1 h-9 w-9 justify-self-end" variant="ghost" size="icon" onClick={() => setItems(items.filter((_, i) => i !== idx))}><Trash2 className="h-4 w-4" /></Button>
</div>
))}
</div>
<Button className="mt-2" variant="outline" size="sm" onClick={() => setItems([...items, { description: "", quantity: 1, rate: 0, account_id: null }])}><Plus className="mr-1 h-3 w-3" /> Add item</Button>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<div className="flex items-end justify-between gap-4">
<div className="w-28">
<Label>Tax %</Label>
<Input type="number" min={0} value={taxPct} onChange={(e) => setTaxPct(Number(e.target.value))} className={hl("tax")} />
</div>
<div className="col-span-2 text-right text-sm">
<div>Subtotal: <b>{money(subtotal, cur)}</b></div>
<div>Tax: <b>{money(tax, cur)}</b></div>
<div className="text-lg">Total: <b>{money(total, cur)}</b></div>
<div className="text-right text-sm space-y-0.5">
<div className="text-muted-foreground">Subtotal <span className="ml-3 text-foreground font-medium">{money(subtotal, cur)}</span></div>
<div className="text-muted-foreground">Tax <span className="ml-3 text-foreground font-medium">{money(tax, cur)}</span></div>
</div>
</div>
</div>
</div>
</div>
{(notes || aiFilled.has("notes")) && (
<div>
<Label>Notes</Label>
<Input value={notes} onChange={(e) => setNotes(e.target.value)} className={hl("notes")} />
</div>
)}
{/* Total bar */}
<div className="bg-primary text-primary-foreground px-6 py-3 flex items-center justify-end text-sm font-medium">
Total bill amount: <span className="ml-2 text-base font-semibold">{money(total, cur)}</span>
</div>
{/* Preview pane */}
<div className="md:col-span-1">
<Label>Attachment</Label>
<div className="mt-1 rounded-md border bg-muted/30 p-2 min-h-[200px] flex items-center justify-center">
{filePreview ? (
<img src={filePreview} alt="Bill preview" className="max-h-[400px] w-full object-contain rounded" />
) : file ? (
<div className="text-center text-sm text-muted-foreground">
<FileText className="mx-auto h-8 w-8 mb-2" />
<div className="font-medium">{file.name}</div>
<div className="text-xs">{(file.size / 1024).toFixed(1)} KB</div>
</div>
) : (
<div className="text-xs text-muted-foreground text-center">No file uploaded</div>
)}
</div>
</div>
</div>
<DialogFooter><Button onClick={save}>Save bill</Button></DialogFooter>
<DialogFooter className="px-6 py-3 border-t sm:justify-start gap-2">
<Button onClick={() => save(false)}>{editId ? "Save bill" : "Save"}</Button>
{!editId && <Button variant="outline" onClick={() => save(true)}>Save &amp; add another</Button>}
<Button variant="ghost" onClick={() => { setOpen(false); resetForm(); }}>Cancel</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
@@ -741,14 +798,14 @@ export default function AccountingBillsPage() {
{!isLoading && filtered.map((b: any) => (
<TableRow key={b.id}>
<TableCell className="font-medium">
{b.attachment_url ? (
<a href={b.attachment_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 hover:underline">
<FileText className="h-3 w-3" /> {b.number}
</a>
) : b.number}
<button onClick={() => openView(b)} className="inline-flex items-center gap-1 text-primary hover:underline">
{b.attachment_url && <FileText className="h-3 w-3" />} {b.number}
</button>
{b.auto_created && <span className="ml-2 inline-flex items-center rounded bg-amber-100 text-amber-700 px-1.5 py-0.5 text-[10px] font-semibold">Auto</span>}
</TableCell>
<TableCell>{b.vendors?.name ?? "—"}</TableCell>
<TableCell>
<button onClick={() => openView(b)} className="hover:underline">{b.vendors?.name ?? "—"}</button>
</TableCell>
<TableCell>{fmtDate(b.issue_date)}</TableCell>
<TableCell>{fmtDate(b.due_date)}</TableCell>
<TableCell className="text-right">{money(b.total, cur)}</TableCell>
@@ -853,6 +910,117 @@ export default function AccountingBillsPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bill detail (read-only overview) */}
<Dialog open={!!viewBill} onOpenChange={(o) => { if (!o) { setViewBill(null); setViewItems([]); } }}>
<DialogContent className="max-w-3xl max-h-[92vh] overflow-y-auto p-0 gap-0">
{viewBill && (() => {
const acctName = (id: string | null) => {
const a = (expenseAccounts as any[]).find((x) => x.id === id);
return a ? `${a.code ? a.code + " " : ""}${a.name}` : "—";
};
const v = viewBill;
const canPay = v.derivedStatus !== "paid" && v.derivedStatus !== "void";
return (
<>
{/* Header */}
<div className="px-6 py-4 border-b">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">{v.vendors?.name ?? "Vendor"}</h2>
<StatusBadge status={v.derivedStatus.replace("_", " ")} />
</div>
<p className="text-sm text-muted-foreground mt-0.5">
Bill {v.number} · {money(v.total, cur)} · Due {fmtDate(v.due_date)}
<button onClick={() => { setViewBill(null); openEdit(v); }} className="ml-2 text-primary hover:underline">Edit</button>
</p>
</div>
<div className="flex items-center gap-2">
{canPay && <Button size="sm" onClick={() => { setViewBill(null); openPayment(v); }}>Pay bill</Button>}
<Button size="sm" variant="outline" onClick={() => duplicateBill(v)}>Duplicate</Button>
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => { remove(v.id); setViewBill(null); }}>Delete</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-6">
{/* Bill details + items */}
<div className="md:col-span-2 space-y-4">
<Card>
<CardHeader className="py-3 bg-muted/40 border-b"><CardTitle className="text-sm">Bill details</CardTitle></CardHeader>
<CardContent className="pt-4 grid grid-cols-3 gap-4 text-sm">
<Field label="Date" value={fmtDate(v.issue_date)} />
<Field label="Due" value={fmtDate(v.due_date)} />
<Field label="Pay to" value={v.vendors?.name ?? "—"} />
<Field label="Reference number" value={v.number || "—"} />
<Field label="Memo" value={v.notes || "—"} />
{v.attachment_url && (
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">Attachment</div>
<a href={v.attachment_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline inline-flex items-center gap-1 mt-1">
<FileText className="h-3.5 w-3.5" /> View
</a>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="py-3 bg-muted/40 border-b"><CardTitle className="text-sm">Item details</CardTitle></CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Account</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{viewItems.map((it: any) => (
<TableRow key={it.id}>
<TableCell>{acctName(it.account_id)}</TableCell>
<TableCell>{it.description || "—"}</TableCell>
<TableCell className="text-right">{money(Number(it.amount), cur)}</TableCell>
</TableRow>
))}
<TableRow className="font-semibold border-t-2">
<TableCell colSpan={2}>Total</TableCell>
<TableCell className="text-right">{money(v.total, cur)}</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</div>
{/* Amount sidebar */}
<div className="space-y-4">
<Card>
<CardHeader className="py-3 bg-muted/40 border-b"><CardTitle className="text-sm">Bill amount</CardTitle></CardHeader>
<CardContent className="pt-4 space-y-2 text-sm">
<div className="flex justify-between"><span className="text-muted-foreground">Total</span><span className="font-medium">{money(v.total, cur)}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Paid</span><span className="font-medium">{money(Number(v.paid_amount ?? 0), cur)}</span></div>
<div className="flex justify-between border-t pt-2 text-base"><span>Remaining</span><span className="font-semibold">{money(v.balance, cur)}</span></div>
</CardContent>
</Card>
</div>
</div>
</>
);
})()}
</DialogContent>
</Dialog>
</div>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-0.5 text-foreground">{value}</div>
</div>
);
}