mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -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");
|
if (!number.trim()) return toast.error("Bill number required");
|
||||||
let attachmentUrl = uploadedUrl;
|
let attachmentUrl = uploadedUrl;
|
||||||
if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file);
|
if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file);
|
||||||
@@ -299,11 +299,45 @@ export default function AccountingBillsPage() {
|
|||||||
await accounting.from("bill_items").insert(itemRows(bill.id));
|
await accounting.from("bill_items").insert(itemRows(bill.id));
|
||||||
toast.success("Bill recorded");
|
toast.success("Bill recorded");
|
||||||
}
|
}
|
||||||
setOpen(false);
|
|
||||||
resetForm();
|
resetForm();
|
||||||
|
if (!keepOpen) setOpen(false);
|
||||||
qc.invalidateQueries({ queryKey: ["bills", cid] });
|
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
|
// Payment dialog state
|
||||||
|
|
||||||
const [payBill, setPayBill] = useState<any | null>(null);
|
const [payBill, setPayBill] = useState<any | null>(null);
|
||||||
@@ -535,49 +569,68 @@ export default function AccountingBillsPage() {
|
|||||||
<h1 className="text-2xl font-semibold">Bills</h1>
|
<h1 className="text-2xl font-semibold">Bills</h1>
|
||||||
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) resetForm(); }}>
|
<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>
|
<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">
|
<DialogContent className="max-w-5xl max-h-[92vh] overflow-hidden p-0 gap-0 flex flex-col">
|
||||||
<DialogHeader><DialogTitle>{editId ? "Edit bill" : "New bill"}</DialogTitle></DialogHeader>
|
<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 && (
|
{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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6">
|
||||||
{/* Form */}
|
{/* ── Left: attachment panel ── */}
|
||||||
<div className="md:col-span-2 space-y-4">
|
|
||||||
{/* Upload zone */}
|
|
||||||
<div
|
<div
|
||||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
onDragLeave={() => setDragOver(false)}
|
onDragLeave={() => setDragOver(false)}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => { e.preventDefault(); setDragOver(false); const f = e.dataTransfer.files?.[0]; if (f) handleFile(f); }}
|
||||||
e.preventDefault(); setDragOver(false);
|
|
||||||
const f = e.dataTransfer.files?.[0]; if (f) handleFile(f);
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border-2 border-dashed p-4 text-center text-sm cursor-pointer transition-colors",
|
"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 hover:bg-muted/50"
|
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
|
<input ref={fileRef} type="file" accept={ACCEPT} className="hidden"
|
||||||
ref={fileRef} type="file" accept={ACCEPT} className="hidden"
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
|
||||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
|
||||||
/>
|
{parsing ? (
|
||||||
<Upload className="mx-auto h-5 w-5 text-muted-foreground" />
|
<div className="text-sm text-muted-foreground"><Loader2 className="mx-auto h-6 w-6 animate-spin mb-2" />Parsing with AI…</div>
|
||||||
<div className="mt-1 font-medium">
|
) : filePreview ? (
|
||||||
{parsing ? "Parsing with AI…" : file ? file.name : "Upload & Parse Bill"}
|
<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>
|
||||||
<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>
|
<Receipt className="h-12 w-12 text-muted-foreground/40" />
|
||||||
{file && !parsing && (
|
<div className="mt-3 font-medium">Upload & view your attachment here</div>
|
||||||
<div className="mt-2 flex justify-center gap-2" onClick={(e) => e.stopPropagation()}>
|
<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 & 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)}>
|
<Button size="sm" variant="outline" onClick={() => runParse(file)}>
|
||||||
<Sparkles className="mr-1 h-3.5 w-3.5" /> Re-parse
|
<Sparkles className="mr-1 h-3.5 w-3.5" /> Re-parse
|
||||||
</Button>
|
</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()); }}>
|
<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" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -585,94 +638,98 @@ export default function AccountingBillsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<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}>
|
<Select value={vendorId} onValueChange={setVendorId}>
|
||||||
<SelectTrigger className={hl("vendor") || (aiFilled.has("vendor_unmatched") ? ERR : "")}>
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>{(vendors as any[]).map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}</SelectContent>
|
<SelectContent>{(vendors as any[]).map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 mt-3">
|
||||||
<div>
|
<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")} />
|
<Input maxLength={40} value={number} onChange={(e) => setNumber(e.target.value)} className={hl("number")} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Issue date</Label>
|
<Label>Memo</Label>
|
||||||
<Input type="date" value={issueDate} onChange={(e) => setIssueDate(e.target.value)} className={hl("issueDate")} />
|
<Input value={notes} onChange={(e) => setNotes(e.target.value)} className={hl("notes")} placeholder="Optional" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Label>Due date</Label>
|
|
||||||
<Input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} className={hl("dueDate")} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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) => (
|
{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")}>
|
<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")}>
|
||||||
<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); }} />
|
|
||||||
<Select value={it.account_id ?? ""} onValueChange={(v) => { const a = [...items]; a[idx] = { ...it, account_id: v || null }; setItems(a); }}>
|
<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>
|
<SelectContent>
|
||||||
{(expenseAccounts as any[]).map((a) => (
|
{(expenseAccounts as any[]).map((a) => (
|
||||||
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
|
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
||||||
))}
|
))}
|
||||||
|
</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>
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="flex items-end justify-between gap-4">
|
||||||
<div>
|
<div className="w-28">
|
||||||
<Label>Tax %</Label>
|
<Label>Tax %</Label>
|
||||||
<Input type="number" min={0} value={taxPct} onChange={(e) => setTaxPct(Number(e.target.value))} className={hl("tax")} />
|
<Input type="number" min={0} value={taxPct} onChange={(e) => setTaxPct(Number(e.target.value))} className={hl("tax")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 text-right text-sm">
|
<div className="text-right text-sm space-y-0.5">
|
||||||
<div>Subtotal: <b>{money(subtotal, cur)}</b></div>
|
<div className="text-muted-foreground">Subtotal <span className="ml-3 text-foreground font-medium">{money(subtotal, cur)}</span></div>
|
||||||
<div>Tax: <b>{money(tax, cur)}</b></div>
|
<div className="text-muted-foreground">Tax <span className="ml-3 text-foreground font-medium">{money(tax, cur)}</span></div>
|
||||||
<div className="text-lg">Total: <b>{money(total, cur)}</b></div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(notes || aiFilled.has("notes")) && (
|
{/* Total bar */}
|
||||||
<div>
|
<div className="bg-primary text-primary-foreground px-6 py-3 flex items-center justify-end text-sm font-medium">
|
||||||
<Label>Notes</Label>
|
Total bill amount: <span className="ml-2 text-base font-semibold">{money(total, cur)}</span>
|
||||||
<Input value={notes} onChange={(e) => setNotes(e.target.value)} className={hl("notes")} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview pane */}
|
<DialogFooter className="px-6 py-3 border-t sm:justify-start gap-2">
|
||||||
<div className="md:col-span-1">
|
<Button onClick={() => save(false)}>{editId ? "Save bill" : "Save"}</Button>
|
||||||
<Label>Attachment</Label>
|
{!editId && <Button variant="outline" onClick={() => save(true)}>Save & add another</Button>}
|
||||||
<div className="mt-1 rounded-md border bg-muted/30 p-2 min-h-[200px] flex items-center justify-center">
|
<Button variant="ghost" onClick={() => { setOpen(false); resetForm(); }}>Cancel</Button>
|
||||||
{filePreview ? (
|
</DialogFooter>
|
||||||
<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>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -741,14 +798,14 @@ export default function AccountingBillsPage() {
|
|||||||
{!isLoading && filtered.map((b: any) => (
|
{!isLoading && filtered.map((b: any) => (
|
||||||
<TableRow key={b.id}>
|
<TableRow key={b.id}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{b.attachment_url ? (
|
<button onClick={() => openView(b)} className="inline-flex items-center gap-1 text-primary hover:underline">
|
||||||
<a href={b.attachment_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 hover:underline">
|
{b.attachment_url && <FileText className="h-3 w-3" />} {b.number}
|
||||||
<FileText className="h-3 w-3" /> {b.number}
|
</button>
|
||||||
</a>
|
|
||||||
) : b.number}
|
|
||||||
{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>}
|
{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>
|
||||||
<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.issue_date)}</TableCell>
|
||||||
<TableCell>{fmtDate(b.due_date)}</TableCell>
|
<TableCell>{fmtDate(b.due_date)}</TableCell>
|
||||||
<TableCell className="text-right">{money(b.total, cur)}</TableCell>
|
<TableCell className="text-right">{money(b.total, cur)}</TableCell>
|
||||||
@@ -853,6 +910,117 @@ export default function AccountingBillsPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user