diff --git a/src/components/cases/expenses-tab.tsx b/src/components/cases/expenses-tab.tsx index 7cec103..a6008a4 100644 --- a/src/components/cases/expenses-tab.tsx +++ b/src/components/cases/expenses-tab.tsx @@ -37,7 +37,8 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) { const [form, setForm] = useState({ expense_date: new Date().toISOString().slice(0, 10), description: "", - amount: "", + quantity: "1", + unit_price: "", billable: true, }); const [receipt, setReceipt] = useState(null); @@ -58,6 +59,16 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) { useEffect(() => { load(); }, [caseId]); + // Refresh when an expense is added/changed elsewhere (e.g. dashboard Quick Add) + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail as { caseId?: string } | undefined; + if (!detail?.caseId || detail.caseId === caseId) load(); + }; + window.addEventListener("expenses:changed", handler); + return () => window.removeEventListener("expenses:changed", handler); + }, [caseId]); + // Load case client_id for placeholder invoice creation useEffect(() => { (async () => { @@ -102,8 +113,11 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) { const submit = async (e: React.FormEvent) => { e.preventDefault(); - const amount = parseFloat(form.amount); - if (isNaN(amount) || amount < 0) { toast.error("Invalid amount"); return; } + const quantity = parseFloat(form.quantity); + const unit_price = parseFloat(form.unit_price); + if (isNaN(quantity) || quantity <= 0) { toast.error("Quantity must be greater than 0"); return; } + if (isNaN(unit_price) || unit_price < 0) { toast.error("Invalid unit price"); return; } + const amount = +(quantity * unit_price).toFixed(2); if (!form.description.trim()) { toast.error("Description required"); return; } setSubmitting(true); let receipt_storage_path: string | null = null; @@ -120,6 +134,8 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) { expense_date: form.expense_date, description: form.description.trim(), amount, + quantity, + unit_price, billable: form.billable, receipt_storage_path, }); @@ -129,7 +145,7 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) { toast.success("Expense added"); setShowForm(false); setReceipt(null); - setForm({ ...form, amount: "", description: "" }); + setForm({ ...form, quantity: "1", unit_price: "", description: "" }); if (fileRef.current) fileRef.current.value = ""; load(); } @@ -189,7 +205,8 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) { if (fee) { setForm((f) => ({ ...f, - amount: String(fee.amount), + unit_price: String(fee.amount), + quantity: f.quantity || "1", billable: fee.billable, description: fee.description || fee.name, })); @@ -206,30 +223,43 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) { /> )} -
+
setForm({ ...form, expense_date: e.target.value })} required />
- - setForm({ ...form, amount: e.target.value })} required /> + + setForm({ ...form, quantity: e.target.value })} required /> +
+
+ + setForm({ ...form, unit_price: e.target.value })} required /> +
+
+ +
setForm({ ...form, description: e.target.value })} required maxLength={500} />
-
+
-
+
setReceipt(e.target.files?.[0] ?? null)} />
-
+
)} -
+
setForm({ ...form, expense_date: e.target.value })} required />
- - setForm({ ...form, amount: e.target.value })} required /> + + setForm({ ...form, quantity: e.target.value })} required />
+
+ + setForm({ ...form, unit_price: e.target.value })} required /> +
+
+
+ Total: ${((parseFloat(form.quantity) || 0) * (parseFloat(form.unit_price) || 0)).toFixed(2)}
diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index c8784fe..c52fcac 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -1652,7 +1652,9 @@ export type Database = { external_id: string | null id: string invoice_id: string | null + quantity: number receipt_storage_path: string | null + unit_price: number updated_at: string user_id: string } @@ -1667,7 +1669,9 @@ export type Database = { external_id?: string | null id?: string invoice_id?: string | null + quantity?: number receipt_storage_path?: string | null + unit_price?: number updated_at?: string user_id: string } @@ -1682,7 +1686,9 @@ export type Database = { external_id?: string | null id?: string invoice_id?: string | null + quantity?: number receipt_storage_path?: string | null + unit_price?: number updated_at?: string user_id?: string } diff --git a/src/lib/billing-report-pdf.ts b/src/lib/billing-report-pdf.ts index 14cd027..1b16485 100644 --- a/src/lib/billing-report-pdf.ts +++ b/src/lib/billing-report-pdf.ts @@ -17,6 +17,8 @@ export interface ExpenseReportRow { case_label: string; user_name: string; description: string; + quantity: number; + unit_price: number; amount: number; billable: boolean; } @@ -93,20 +95,22 @@ export function generateExpenseReportPdf(opts: BaseOpts & { rows: ExpenseReportR const totalAmt = opts.rows.reduce((a, r) => a + (r.amount || 0), 0); autoTable(doc, { startY: 110, - head: [["Date", "Case", "User", "Description", "Billable", "Amount"]], + head: [["Date", "Case", "User", "Description", "Qty", "Unit", "Billable", "Amount"]], body: opts.rows.map((r) => [ fmtDate(r.expense_date), r.case_label, r.user_name, r.description, + String(r.quantity ?? 1), + fmtCurrency(r.unit_price ?? r.amount), r.billable ? "Yes" : "No", fmtCurrency(r.amount), ]), - foot: [["", "", "", "", "Total", fmtCurrency(totalAmt)]], + foot: [["", "", "", "", "", "", "Total", fmtCurrency(totalAmt)]], styles: { font: "times", fontSize: 9, cellPadding: 4 }, headStyles: { fillColor: [240, 240, 240], textColor: 20 }, footStyles: { fillColor: [240, 240, 240], textColor: 20, fontStyle: "bold" }, - columnStyles: { 5: { halign: "right" } }, + columnStyles: { 4: { halign: "right" }, 5: { halign: "right" }, 7: { halign: "right" } }, }); return doc; } diff --git a/src/routes/reports.index.tsx b/src/routes/reports.index.tsx index 6b3f997..ffd9590 100644 --- a/src/routes/reports.index.tsx +++ b/src/routes/reports.index.tsx @@ -456,6 +456,8 @@ function ExpenseReport() { case_label: c ? `${c.case_number} — ${c.title}` : "—", user_name: profMap[e.user_id] || "—", description: e.description || "", + quantity: Number(e.quantity ?? 1), + unit_price: Number(e.unit_price ?? e.amount ?? 0), amount: Number(e.amount || 0), billable: !!e.billable, }; @@ -532,6 +534,8 @@ function ExpenseReport() { Case User Description + Qty + Unit Billable Amount @@ -543,6 +547,8 @@ function ExpenseReport() { {r.case_label} {r.user_name} {r.description} + {r.quantity} + {formatCurrency(r.unit_price)} {r.billable ? "Yes" : "No"} {formatCurrency(r.amount)} diff --git a/supabase/migrations/20260425185053_96ee636e-66fc-4cd1-98ec-a96801a73c96.sql b/supabase/migrations/20260425185053_96ee636e-66fc-4cd1-98ec-a96801a73c96.sql new file mode 100644 index 0000000..7e3097d --- /dev/null +++ b/supabase/migrations/20260425185053_96ee636e-66fc-4cd1-98ec-a96801a73c96.sql @@ -0,0 +1,11 @@ +-- Add quantity and unit_price columns to expenses for itemized billing. +-- amount remains the stored total = quantity * unit_price for backwards +-- compatibility with reports, invoices, and trust accounting. +ALTER TABLE public.expenses + ADD COLUMN IF NOT EXISTS quantity numeric NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS unit_price numeric NOT NULL DEFAULT 0; + +-- Backfill: for existing rows, set unit_price = amount and quantity = 1. +UPDATE public.expenses + SET unit_price = amount, quantity = 1 + WHERE unit_price = 0 AND amount IS NOT NULL; \ No newline at end of file