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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user