Added quantity to expenses

X-Lovable-Edit-ID: edt-9715813f-f801-4c4d-8803-3f22d9990c85
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-25 18:53:27 +00:00
co-authored by renee-png
6 changed files with 100 additions and 23 deletions
+46 -12
View File
@@ -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<File | null>(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 }) {
/>
</div>
)}
<form onSubmit={submit} className="grid grid-cols-1 md:grid-cols-4 gap-3">
<form onSubmit={submit} className="grid grid-cols-2 md:grid-cols-6 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Date</Label>
<Input type="date" value={form.expense_date} onChange={(e) => setForm({ ...form, expense_date: e.target.value })} required />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Amount ($)</Label>
<Input type="number" step="0.01" min="0" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} required />
<Label className="text-xs">Qty</Label>
<Input type="number" step="any" min="0" value={form.quantity} onChange={(e) => setForm({ ...form, quantity: e.target.value })} required />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Unit price ($)</Label>
<Input type="number" step="0.01" min="0" value={form.unit_price} onChange={(e) => setForm({ ...form, unit_price: e.target.value })} required />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Total</Label>
<Input
readOnly
tabIndex={-1}
value={`$${((parseFloat(form.quantity) || 0) * (parseFloat(form.unit_price) || 0)).toFixed(2)}`}
className="bg-muted/40"
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs">Description</Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} required maxLength={500} />
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 md:col-span-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.billable} onCheckedChange={(c) => setForm({ ...form, billable: !!c })} />
Billable
</label>
</div>
<div className="md:col-span-2">
<div className="md:col-span-4">
<Label className="text-xs">Receipt (optional)</Label>
<Input ref={fileRef} type="file" onChange={(e) => setReceipt(e.target.files?.[0] ?? null)} />
</div>
<div className="md:col-span-4 flex justify-end">
<div className="md:col-span-6 flex justify-end">
<Button type="submit" disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save expense
@@ -248,13 +278,15 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) {
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-left px-4 py-3 font-medium">Description</th>
<th className="text-left px-4 py-3 font-medium">User</th>
<th className="text-right px-4 py-3 font-medium">Qty</th>
<th className="text-right px-4 py-3 font-medium">Unit</th>
<th className="text-right px-4 py-3 font-medium">Amount</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{items.length === 0 && <tr><td colSpan={6} className="text-center py-12 text-muted-foreground">No expenses.</td></tr>}
{items.length === 0 && <tr><td colSpan={8} className="text-center py-12 text-muted-foreground">No expenses.</td></tr>}
{items.map((e) => (
<tr key={e.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">{formatDate(e.expense_date)}</td>
@@ -267,6 +299,8 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) {
)}
</td>
<td className="px-4 py-3 text-muted-foreground">{e.user?.full_name || e.user?.email}</td>
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{Number(e.quantity ?? 1)}</td>
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{formatCurrency(Number(e.unit_price ?? e.amount))}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(e.amount)}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{!e.billable ? "Non-billable" : e.invoice_id ? "Invoiced" : "Unbilled"}</td>
<td className="px-4 py-3 text-right">
+24 -8
View File
@@ -283,7 +283,8 @@ export function QuickAddExpense() {
caseId: "",
expense_date: new Date().toISOString().slice(0, 10),
description: "",
amount: "",
quantity: "1",
unit_price: "",
billable: true,
});
const [receipt, setReceipt] = useState<File | null>(null);
@@ -299,7 +300,8 @@ export function QuickAddExpense() {
caseId: "",
expense_date: new Date().toISOString().slice(0, 10),
description: "",
amount: "",
quantity: "1",
unit_price: "",
billable: true,
});
setReceipt(null);
@@ -311,8 +313,11 @@ export function QuickAddExpense() {
if (!user?.id) return toast.error("Not signed in");
if (!form.caseId) return toast.error("Select a case");
if (!form.description.trim()) return toast.error("Description required");
const amount = parseFloat(form.amount);
if (Number.isNaN(amount) || amount < 0) return toast.error("Invalid amount");
const quantity = parseFloat(form.quantity);
const unit_price = parseFloat(form.unit_price);
if (Number.isNaN(quantity) || quantity <= 0) return toast.error("Quantity must be greater than 0");
if (Number.isNaN(unit_price) || unit_price < 0) return toast.error("Invalid unit price");
const amount = +(quantity * unit_price).toFixed(2);
setSubmitting(true);
let receipt_storage_path: string | null = null;
if (receipt) {
@@ -330,12 +335,15 @@ export function QuickAddExpense() {
expense_date: form.expense_date,
description: form.description.trim(),
amount,
quantity,
unit_price,
billable: form.billable,
receipt_storage_path,
});
setSubmitting(false);
if (error) return toast.error(error.message);
toast.success("Expense added");
window.dispatchEvent(new CustomEvent("expenses:changed", { detail: { caseId: form.caseId } }));
reset();
setOpen(false);
};
@@ -399,7 +407,8 @@ export function QuickAddExpense() {
if (fee) {
setForm((f) => ({
...f,
amount: String(fee.amount),
unit_price: String(fee.amount),
quantity: f.quantity || "1",
billable: fee.billable,
description: f.description || fee.name,
}));
@@ -416,15 +425,22 @@ export function QuickAddExpense() {
/>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Date</Label>
<Input type="date" value={form.expense_date} onChange={(e) => setForm({ ...form, expense_date: e.target.value })} required />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Amount ($)</Label>
<Input type="number" step="0.01" min="0" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} required />
<Label className="text-xs">Qty</Label>
<Input type="number" step="any" min="0" value={form.quantity} onChange={(e) => setForm({ ...form, quantity: e.target.value })} required />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Unit price ($)</Label>
<Input type="number" step="0.01" min="0" value={form.unit_price} onChange={(e) => setForm({ ...form, unit_price: e.target.value })} required />
</div>
</div>
<div className="text-xs text-muted-foreground">
Total: ${((parseFloat(form.quantity) || 0) * (parseFloat(form.unit_price) || 0)).toFixed(2)}
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
+6
View File
@@ -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
}
+7 -3
View File
@@ -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;
}
+6
View File
@@ -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() {
<th className="text-left px-4 py-2 font-medium">Case</th>
<th className="text-left px-4 py-2 font-medium">User</th>
<th className="text-left px-4 py-2 font-medium">Description</th>
<th className="text-right px-4 py-2 font-medium">Qty</th>
<th className="text-right px-4 py-2 font-medium">Unit</th>
<th className="text-left px-4 py-2 font-medium">Billable</th>
<th className="text-right px-4 py-2 font-medium">Amount</th>
</tr>
@@ -543,6 +547,8 @@ function ExpenseReport() {
<td className="px-4 py-2">{r.case_label}</td>
<td className="px-4 py-2">{r.user_name}</td>
<td className="px-4 py-2 text-muted-foreground">{r.description}</td>
<td className="px-4 py-2 text-right tabular-nums">{r.quantity}</td>
<td className="px-4 py-2 text-right tabular-nums">{formatCurrency(r.unit_price)}</td>
<td className="px-4 py-2">{r.billable ? "Yes" : "No"}</td>
<td className="px-4 py-2 text-right">{formatCurrency(r.amount)}</td>
</tr>
@@ -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;