Built invoice generator system
X-Lovable-Edit-ID: edt-4c399fdc-4f22-45a4-bdf4-60d4e610f5d3 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -4,17 +4,15 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { FilePlus, Loader2 } from "lucide-react";
|
||||
import { FilePlus } from "lucide-react";
|
||||
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
|
||||
import { toast } from "sonner";
|
||||
import { GenerateInvoiceDialog } from "@/components/invoices/generate-invoice-dialog";
|
||||
|
||||
export function CaseInvoicesTab({ caseRecord }: { caseRecord: any }) {
|
||||
const { user } = useAuth();
|
||||
const [invoices, setInvoices] = useState<any[]>([]);
|
||||
const [unbilledTime, setUnbilledTime] = useState<any[]>([]);
|
||||
const [unbilledExpenses, setUnbilledExpenses] = useState<any[]>([]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [genOpen, setGenOpen] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const [{ data: invs }, { data: t }, { data: ex }] = await Promise.all([
|
||||
@@ -33,48 +31,19 @@ export function CaseInvoicesTab({ caseRecord }: { caseRecord: any }) {
|
||||
const expensesTotal = unbilledExpenses.reduce((s, e) => s + Number(e.amount), 0);
|
||||
const subtotal = timeTotal + expensesTotal;
|
||||
|
||||
const generate = async () => {
|
||||
if (subtotal <= 0) { toast.error("Nothing to invoice"); return; }
|
||||
setGenerating(true);
|
||||
const yr = new Date().getFullYear();
|
||||
const num = `INV-${yr}-${Math.floor(1000 + Math.random() * 9000)}`;
|
||||
const due = new Date(); due.setDate(due.getDate() + 30);
|
||||
const { data: inv, error } = await supabase.from("invoices").insert({
|
||||
invoice_number: num,
|
||||
client_id: caseRecord.client.id,
|
||||
case_id: caseRecord.id,
|
||||
status: "draft",
|
||||
issue_date: new Date().toISOString().slice(0, 10),
|
||||
due_date: due.toISOString().slice(0, 10),
|
||||
subtotal,
|
||||
tax: 0,
|
||||
total: subtotal,
|
||||
created_by: user?.id,
|
||||
}).select("id").single();
|
||||
if (error || !inv) { toast.error(error?.message || "Failed"); setGenerating(false); return; }
|
||||
// Link entries
|
||||
const timeIds = unbilledTime.map((t) => t.id);
|
||||
const expIds = unbilledExpenses.map((e) => e.id);
|
||||
if (timeIds.length) await supabase.from("time_entries").update({ invoice_id: inv.id }).in("id", timeIds);
|
||||
if (expIds.length) await supabase.from("expenses").update({ invoice_id: inv.id }).in("id", expIds);
|
||||
setGenerating(false);
|
||||
toast.success(`Invoice ${num} created (draft)`);
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="border-border/60 bg-muted/20">
|
||||
<CardContent className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="text-sm space-y-0.5">
|
||||
<div className="font-medium">Unbilled work</div>
|
||||
<div className="font-medium">Unbilled work on this case</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{unbilledTime.length} time entries · {unbilledExpenses.length} expenses
|
||||
</div>
|
||||
<div className="font-serif text-2xl text-foreground mt-1">{formatCurrency(subtotal)}</div>
|
||||
</div>
|
||||
<Button onClick={generate} disabled={subtotal <= 0 || generating}>
|
||||
{generating ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <FilePlus className="h-4 w-4 mr-2" />}
|
||||
<Button onClick={() => setGenOpen(true)} disabled={subtotal <= 0}>
|
||||
<FilePlus className="h-4 w-4 mr-2" />
|
||||
Generate invoice
|
||||
</Button>
|
||||
</CardContent>
|
||||
@@ -111,6 +80,14 @@ export function CaseInvoicesTab({ caseRecord }: { caseRecord: any }) {
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<GenerateInvoiceDialog
|
||||
open={genOpen}
|
||||
onOpenChange={setGenOpen}
|
||||
clientId={caseRecord.client.id}
|
||||
clientName={caseRecord.client.name}
|
||||
presetCaseId={caseRecord.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Loader2, FilePlus } from "lucide-react";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { toast } from "sonner";
|
||||
import { generateInvoiceForClient } from "@/lib/invoice-generation";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (b: boolean) => void;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
/** preselect a single case (used when launched from a case page) */
|
||||
presetCaseId?: string;
|
||||
}
|
||||
|
||||
interface CaseUnbilled {
|
||||
id: string;
|
||||
case_number: string;
|
||||
title: string;
|
||||
timeCount: number;
|
||||
timeAmount: number;
|
||||
expenseCount: number;
|
||||
expenseAmount: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName, presetCaseId }: Props) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [cases, setCases] = useState<CaseUnbilled[]>([]);
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||
const [taxPct, setTaxPct] = useState("0");
|
||||
const [dueDays, setDueDays] = useState("30");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const [{ data: cs }, { data: firm }] = await Promise.all([
|
||||
supabase.from("cases").select("id, case_number, title").eq("client_id", clientId),
|
||||
supabase.from("firm_settings").select("default_tax_rate").maybeSingle(),
|
||||
]);
|
||||
const caseIds = (cs ?? []).map((c) => c.id);
|
||||
if (firm?.default_tax_rate != null) setTaxPct(String(firm.default_tax_rate));
|
||||
if (caseIds.length === 0) {
|
||||
setCases([]); setLoading(false); return;
|
||||
}
|
||||
const [{ data: time }, { data: exp }] = await Promise.all([
|
||||
supabase.from("time_entries").select("case_id, hours, hourly_rate")
|
||||
.in("case_id", caseIds).eq("billable", true).is("invoice_id", null),
|
||||
supabase.from("expenses").select("case_id, amount")
|
||||
.in("case_id", caseIds).eq("billable", true).is("invoice_id", null),
|
||||
]);
|
||||
const tally: Record<string, { tc: number; ta: number; ec: number; ea: number }> = {};
|
||||
for (const t of time ?? []) {
|
||||
const k = t.case_id;
|
||||
if (!tally[k]) tally[k] = { tc: 0, ta: 0, ec: 0, ea: 0 };
|
||||
tally[k].tc += 1;
|
||||
tally[k].ta += Number(t.hours) * Number(t.hourly_rate);
|
||||
}
|
||||
for (const e of exp ?? []) {
|
||||
const k = e.case_id;
|
||||
if (!tally[k]) tally[k] = { tc: 0, ta: 0, ec: 0, ea: 0 };
|
||||
tally[k].ec += 1;
|
||||
tally[k].ea += Number(e.amount);
|
||||
}
|
||||
const enriched: CaseUnbilled[] = (cs ?? []).map((c) => {
|
||||
const t = tally[c.id] ?? { tc: 0, ta: 0, ec: 0, ea: 0 };
|
||||
return {
|
||||
id: c.id, case_number: c.case_number, title: c.title,
|
||||
timeCount: t.tc, timeAmount: t.ta, expenseCount: t.ec, expenseAmount: t.ea,
|
||||
total: t.ta + t.ea,
|
||||
};
|
||||
}).filter((c) => c.total > 0);
|
||||
setCases(enriched);
|
||||
// Default selection
|
||||
const sel: Record<string, boolean> = {};
|
||||
if (presetCaseId) {
|
||||
sel[presetCaseId] = true;
|
||||
} else {
|
||||
enriched.forEach((c) => { sel[c.id] = true; });
|
||||
}
|
||||
setSelected(sel);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [open, clientId, presetCaseId]);
|
||||
|
||||
const selectedIds = Object.entries(selected).filter(([, v]) => v).map(([k]) => k);
|
||||
const subtotal = cases.filter((c) => selected[c.id]).reduce((s, c) => s + c.total, 0);
|
||||
const tax = +(subtotal * (Number(taxPct) || 0) / 100).toFixed(2);
|
||||
const total = subtotal + tax;
|
||||
|
||||
const submit = async () => {
|
||||
if (!user?.id) return;
|
||||
if (selectedIds.length === 0) return toast.error("Select at least one case");
|
||||
setSaving(true);
|
||||
try {
|
||||
const { invoiceId, invoiceNumber } = await generateInvoiceForClient({
|
||||
clientId,
|
||||
caseIds: selectedIds,
|
||||
createdBy: user.id,
|
||||
taxRate: (Number(taxPct) || 0) / 100,
|
||||
dueDays: Number(dueDays) || 30,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
toast.success(`Invoice ${invoiceNumber} created`);
|
||||
onOpenChange(false);
|
||||
navigate({ to: "/invoices/$invoiceId", params: { invoiceId } });
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || "Failed to generate");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate invoice — {clientName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-8 text-center text-muted-foreground">Loading unbilled work…</div>
|
||||
) : cases.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
No unbilled time or expenses across this client's cases.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Cases to bill</Label>
|
||||
<div className="mt-2 border rounded-md divide-y max-h-[260px] overflow-auto">
|
||||
{cases.map((c) => (
|
||||
<label key={c.id} className="flex items-start gap-3 p-3 cursor-pointer hover:bg-muted/40">
|
||||
<Checkbox
|
||||
checked={!!selected[c.id]}
|
||||
onCheckedChange={(v) => setSelected((p) => ({ ...p, [c.id]: !!v }))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm truncate">{c.title}</span>
|
||||
<span className="text-sm tabular-nums font-medium">{formatCurrency(c.total)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{c.case_number} · {c.timeCount} time entries ({formatCurrency(c.timeAmount)}) · {c.expenseCount} expenses ({formatCurrency(c.expenseAmount)})
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>Tax rate (%)</Label>
|
||||
<Input type="number" step="0.01" value={taxPct} onChange={(e) => setTaxPct(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Due in (days)</Label>
|
||||
<Input type="number" value={dueDays} onChange={(e) => setDueDays(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-span-1 flex items-end justify-end">
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">Estimated total</div>
|
||||
<div className="font-serif text-xl">{formatCurrency(total)}</div>
|
||||
{tax > 0 && <div className="text-[11px] text-muted-foreground">{formatCurrency(subtotal)} + {formatCurrency(tax)} tax</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Notes (optional)</Label>
|
||||
<Textarea rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Payment terms, thank-you message…" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={saving || cases.length === 0 || selectedIds.length === 0}>
|
||||
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <FilePlus className="h-4 w-4 mr-2" />}
|
||||
Generate draft invoice
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1110,6 +1110,147 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
invoice_line_items: {
|
||||
Row: {
|
||||
amount: number
|
||||
case_id: string | null
|
||||
created_at: string
|
||||
description: string
|
||||
expense_id: string | null
|
||||
id: string
|
||||
invoice_id: string
|
||||
kind: string
|
||||
quantity: number
|
||||
rate: number
|
||||
sort_order: number
|
||||
time_entry_id: string | null
|
||||
updated_at: string
|
||||
user_id: string | null
|
||||
work_date: string | null
|
||||
}
|
||||
Insert: {
|
||||
amount?: number
|
||||
case_id?: string | null
|
||||
created_at?: string
|
||||
description?: string
|
||||
expense_id?: string | null
|
||||
id?: string
|
||||
invoice_id: string
|
||||
kind?: string
|
||||
quantity?: number
|
||||
rate?: number
|
||||
sort_order?: number
|
||||
time_entry_id?: string | null
|
||||
updated_at?: string
|
||||
user_id?: string | null
|
||||
work_date?: string | null
|
||||
}
|
||||
Update: {
|
||||
amount?: number
|
||||
case_id?: string | null
|
||||
created_at?: string
|
||||
description?: string
|
||||
expense_id?: string | null
|
||||
id?: string
|
||||
invoice_id?: string
|
||||
kind?: string
|
||||
quantity?: number
|
||||
rate?: number
|
||||
sort_order?: number
|
||||
time_entry_id?: string | null
|
||||
updated_at?: string
|
||||
user_id?: string | null
|
||||
work_date?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "invoice_line_items_case_id_fkey"
|
||||
columns: ["case_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "cases"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "invoice_line_items_expense_id_fkey"
|
||||
columns: ["expense_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "expenses"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "invoice_line_items_invoice_id_fkey"
|
||||
columns: ["invoice_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "invoices"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "invoice_line_items_time_entry_id_fkey"
|
||||
columns: ["time_entry_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "time_entries"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "invoice_line_items_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
invoice_payments: {
|
||||
Row: {
|
||||
amount: number
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
id: string
|
||||
invoice_id: string
|
||||
method: string | null
|
||||
notes: string | null
|
||||
paid_on: string
|
||||
reference: string | null
|
||||
}
|
||||
Insert: {
|
||||
amount?: number
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
id?: string
|
||||
invoice_id: string
|
||||
method?: string | null
|
||||
notes?: string | null
|
||||
paid_on?: string
|
||||
reference?: string | null
|
||||
}
|
||||
Update: {
|
||||
amount?: number
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
id?: string
|
||||
invoice_id?: string
|
||||
method?: string | null
|
||||
notes?: string | null
|
||||
paid_on?: string
|
||||
reference?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "invoice_payments_created_by_fkey"
|
||||
columns: ["created_by"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "invoice_payments_invoice_id_fkey"
|
||||
columns: ["invoice_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "invoices"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
invoices: {
|
||||
Row: {
|
||||
amount_paid: number
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
|
||||
export interface GenerateInvoiceArgs {
|
||||
clientId: string;
|
||||
caseIds: string[]; // restrict billing to these cases (must belong to client)
|
||||
createdBy: string;
|
||||
invoicePrefix?: string;
|
||||
taxRate?: number; // e.g. 0.07
|
||||
notes?: string;
|
||||
dueDays?: number;
|
||||
}
|
||||
|
||||
export interface GenerateInvoiceResult {
|
||||
invoiceId: string;
|
||||
invoiceNumber: string;
|
||||
}
|
||||
|
||||
export async function generateInvoiceForClient(args: GenerateInvoiceArgs): Promise<GenerateInvoiceResult> {
|
||||
const { clientId, caseIds, createdBy } = args;
|
||||
const taxRate = args.taxRate ?? 0;
|
||||
const dueDays = args.dueDays ?? 30;
|
||||
if (caseIds.length === 0) throw new Error("No cases selected");
|
||||
|
||||
// Pull unbilled time + expenses for the selected cases
|
||||
const [{ data: time, error: te }, { data: exp, error: ee }] = await Promise.all([
|
||||
supabase
|
||||
.from("time_entries")
|
||||
.select("id, case_id, work_date, hours, hourly_rate, description, user_id, billable, invoice_id")
|
||||
.in("case_id", caseIds)
|
||||
.eq("billable", true)
|
||||
.is("invoice_id", null),
|
||||
supabase
|
||||
.from("expenses")
|
||||
.select("id, case_id, expense_date, amount, description, user_id, billable, invoice_id")
|
||||
.in("case_id", caseIds)
|
||||
.eq("billable", true)
|
||||
.is("invoice_id", null),
|
||||
]);
|
||||
if (te) throw te;
|
||||
if (ee) throw ee;
|
||||
|
||||
if ((time?.length ?? 0) === 0 && (exp?.length ?? 0) === 0) {
|
||||
throw new Error("No unbilled time or expenses on the selected cases");
|
||||
}
|
||||
|
||||
const subtotal =
|
||||
(time ?? []).reduce((s, t) => s + Number(t.hours) * Number(t.hourly_rate), 0) +
|
||||
(exp ?? []).reduce((s, e) => s + Number(e.amount), 0);
|
||||
const tax = +(subtotal * taxRate).toFixed(2);
|
||||
const total = +(subtotal + tax).toFixed(2);
|
||||
|
||||
const yr = new Date().getFullYear();
|
||||
const prefix = args.invoicePrefix?.trim() || "INV";
|
||||
const num = `${prefix}-${yr}-${Math.floor(1000 + Math.random() * 9000)}`;
|
||||
const due = new Date();
|
||||
due.setDate(due.getDate() + dueDays);
|
||||
|
||||
const { data: inv, error: ie } = await supabase
|
||||
.from("invoices")
|
||||
.insert({
|
||||
invoice_number: num,
|
||||
client_id: clientId,
|
||||
case_id: caseIds.length === 1 ? caseIds[0] : null,
|
||||
status: "draft",
|
||||
issue_date: new Date().toISOString().slice(0, 10),
|
||||
due_date: due.toISOString().slice(0, 10),
|
||||
subtotal,
|
||||
tax,
|
||||
total,
|
||||
notes: args.notes ?? null,
|
||||
created_by: createdBy,
|
||||
})
|
||||
.select("id, invoice_number")
|
||||
.single();
|
||||
if (ie || !inv) throw ie ?? new Error("Failed to create invoice");
|
||||
|
||||
// Build line items grouped per case in case order
|
||||
const lineItems: any[] = [];
|
||||
let order = 0;
|
||||
for (const cid of caseIds) {
|
||||
const ts = (time ?? [])
|
||||
.filter((t) => t.case_id === cid)
|
||||
.sort((a, b) => (a.work_date < b.work_date ? -1 : 1));
|
||||
const es = (exp ?? [])
|
||||
.filter((e) => e.case_id === cid)
|
||||
.sort((a, b) => (a.expense_date < b.expense_date ? -1 : 1));
|
||||
for (const t of ts) {
|
||||
const amount = +(Number(t.hours) * Number(t.hourly_rate)).toFixed(2);
|
||||
lineItems.push({
|
||||
invoice_id: inv.id,
|
||||
case_id: cid,
|
||||
kind: "time",
|
||||
description: t.description,
|
||||
work_date: t.work_date,
|
||||
quantity: t.hours,
|
||||
rate: t.hourly_rate,
|
||||
amount,
|
||||
time_entry_id: t.id,
|
||||
user_id: t.user_id,
|
||||
sort_order: order++,
|
||||
});
|
||||
}
|
||||
for (const e of es) {
|
||||
lineItems.push({
|
||||
invoice_id: inv.id,
|
||||
case_id: cid,
|
||||
kind: "expense",
|
||||
description: e.description,
|
||||
work_date: e.expense_date,
|
||||
quantity: 1,
|
||||
rate: e.amount,
|
||||
amount: e.amount,
|
||||
expense_id: e.id,
|
||||
user_id: e.user_id,
|
||||
sort_order: order++,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (lineItems.length) {
|
||||
const { error: lierr } = await supabase.from("invoice_line_items").insert(lineItems);
|
||||
if (lierr) throw lierr;
|
||||
}
|
||||
|
||||
// Mark source records as billed
|
||||
const timeIds = (time ?? []).map((t) => t.id);
|
||||
const expIds = (exp ?? []).map((e) => e.id);
|
||||
if (timeIds.length) await supabase.from("time_entries").update({ invoice_id: inv.id }).in("id", timeIds);
|
||||
if (expIds.length) await supabase.from("expenses").update({ invoice_id: inv.id }).in("id", expIds);
|
||||
|
||||
return { invoiceId: inv.id, invoiceNumber: inv.invoice_number };
|
||||
}
|
||||
|
||||
export async function recalcInvoiceTotals(invoiceId: string, taxRate?: number) {
|
||||
const { data: items } = await supabase
|
||||
.from("invoice_line_items")
|
||||
.select("amount")
|
||||
.eq("invoice_id", invoiceId);
|
||||
const subtotal = (items ?? []).reduce((s, l: any) => s + Number(l.amount), 0);
|
||||
let tax = 0;
|
||||
if (taxRate != null) {
|
||||
tax = +(subtotal * taxRate).toFixed(2);
|
||||
} else {
|
||||
// Preserve existing tax ratio if present
|
||||
const { data: inv } = await supabase
|
||||
.from("invoices")
|
||||
.select("subtotal, tax")
|
||||
.eq("id", invoiceId)
|
||||
.maybeSingle();
|
||||
if (inv && Number(inv.subtotal) > 0) {
|
||||
const ratio = Number(inv.tax) / Number(inv.subtotal);
|
||||
tax = +(subtotal * ratio).toFixed(2);
|
||||
}
|
||||
}
|
||||
const total = +(subtotal + tax).toFixed(2);
|
||||
await supabase.from("invoices").update({ subtotal, tax, total }).eq("id", invoiceId);
|
||||
return { subtotal, tax, total };
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import jsPDF from "jspdf";
|
||||
import { formatDate } from "./format";
|
||||
|
||||
const PAGE_W = 612;
|
||||
const PAGE_H = 792;
|
||||
const MARGIN = 54;
|
||||
const CONTENT_W = PAGE_W - MARGIN * 2;
|
||||
|
||||
export interface InvoiceLineItem {
|
||||
kind: string;
|
||||
description: string;
|
||||
work_date: string | null;
|
||||
quantity: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
user_name?: string | null;
|
||||
}
|
||||
|
||||
export interface InvoiceCaseGroup {
|
||||
caseNumber: string;
|
||||
caseTitle: string;
|
||||
practiceArea?: string | null;
|
||||
items: InvoiceLineItem[];
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
export interface InvoicePdfInput {
|
||||
firm: {
|
||||
name?: string | null;
|
||||
address1?: string | null;
|
||||
address2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postal?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
website?: string | null;
|
||||
footerNote?: string | null;
|
||||
logoDataUrl?: string | null;
|
||||
};
|
||||
client: {
|
||||
name: string;
|
||||
contact?: string | null;
|
||||
address1?: string | null;
|
||||
address2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postal?: string | null;
|
||||
};
|
||||
invoice: {
|
||||
number: string;
|
||||
issueDate: string;
|
||||
dueDate?: string | null;
|
||||
status: string;
|
||||
notes?: string | null;
|
||||
};
|
||||
groups: InvoiceCaseGroup[];
|
||||
totals: {
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
total: number;
|
||||
paid: number;
|
||||
balance: number;
|
||||
};
|
||||
}
|
||||
|
||||
const fmtCurrency = (n: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n || 0);
|
||||
|
||||
function setFont(pdf: jsPDF, opts: { bold?: boolean; italic?: boolean; size?: number; color?: [number, number, number] }) {
|
||||
let style: "normal" | "bold" | "italic" | "bolditalic" = "normal";
|
||||
if (opts.bold && opts.italic) style = "bolditalic";
|
||||
else if (opts.bold) style = "bold";
|
||||
else if (opts.italic) style = "italic";
|
||||
pdf.setFont("helvetica", style);
|
||||
pdf.setFontSize(opts.size ?? 10);
|
||||
if (opts.color) pdf.setTextColor(...opts.color);
|
||||
else pdf.setTextColor(20, 24, 32);
|
||||
}
|
||||
|
||||
function wrap(pdf: jsPDF, text: string, maxWidth: number): string[] {
|
||||
if (!text) return [""];
|
||||
return pdf.splitTextToSize(text, maxWidth) as string[];
|
||||
}
|
||||
|
||||
export async function downloadInvoicePdf(input: InvoicePdfInput, filename: string) {
|
||||
const pdf = new jsPDF({ unit: "pt", format: "letter" });
|
||||
const accent: [number, number, number] = [30, 58, 95]; // deep navy
|
||||
const muted: [number, number, number] = [110, 116, 128];
|
||||
const rule: [number, number, number] = [220, 224, 232];
|
||||
|
||||
let y = MARGIN;
|
||||
const ensure = (need: number) => {
|
||||
if (y + need > PAGE_H - MARGIN - 40) {
|
||||
drawPageFooter(pdf, input);
|
||||
pdf.addPage();
|
||||
y = MARGIN;
|
||||
}
|
||||
};
|
||||
|
||||
// ===== Header band =====
|
||||
// Firm name + address (left)
|
||||
if (input.firm.logoDataUrl) {
|
||||
try {
|
||||
pdf.addImage(input.firm.logoDataUrl, "PNG", MARGIN, y, 110, 44, undefined, "FAST");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const headerLeftX = input.firm.logoDataUrl ? MARGIN + 122 : MARGIN;
|
||||
setFont(pdf, { bold: true, size: 14, color: accent });
|
||||
pdf.text(input.firm.name || "Firm", headerLeftX, y + 14);
|
||||
setFont(pdf, { size: 9, color: muted });
|
||||
let fy = y + 28;
|
||||
const firmLines = [
|
||||
input.firm.address1,
|
||||
input.firm.address2,
|
||||
[input.firm.city, input.firm.state, input.firm.postal].filter(Boolean).join(", "),
|
||||
[input.firm.phone, input.firm.email].filter(Boolean).join(" · "),
|
||||
input.firm.website,
|
||||
].filter((l): l is string => !!l && l.trim().length > 0);
|
||||
for (const l of firmLines) {
|
||||
pdf.text(l, headerLeftX, fy);
|
||||
fy += 11;
|
||||
}
|
||||
|
||||
// INVOICE block (right)
|
||||
setFont(pdf, { bold: true, size: 22, color: accent });
|
||||
const titleW = pdf.getTextWidth("INVOICE");
|
||||
pdf.text("INVOICE", PAGE_W - MARGIN - titleW, y + 18);
|
||||
setFont(pdf, { size: 9, color: muted });
|
||||
const labelX = PAGE_W - MARGIN - 150;
|
||||
const valueX = PAGE_W - MARGIN;
|
||||
let iy = y + 36;
|
||||
const drawKV = (k: string, v: string) => {
|
||||
setFont(pdf, { size: 9, color: muted });
|
||||
pdf.text(k, labelX, iy);
|
||||
setFont(pdf, { size: 10, bold: true });
|
||||
const w = pdf.getTextWidth(v);
|
||||
pdf.text(v, valueX - w, iy);
|
||||
iy += 13;
|
||||
};
|
||||
drawKV("Invoice #", input.invoice.number);
|
||||
drawKV("Issue date", formatDate(input.invoice.issueDate));
|
||||
if (input.invoice.dueDate) drawKV("Due date", formatDate(input.invoice.dueDate));
|
||||
drawKV("Status", input.invoice.status.toUpperCase());
|
||||
|
||||
y = Math.max(fy, iy) + 12;
|
||||
|
||||
// Accent rule
|
||||
pdf.setDrawColor(...accent);
|
||||
pdf.setLineWidth(1.2);
|
||||
pdf.line(MARGIN, y, PAGE_W - MARGIN, y);
|
||||
y += 18;
|
||||
|
||||
// ===== Bill To =====
|
||||
setFont(pdf, { size: 8, color: muted });
|
||||
pdf.text("BILL TO", MARGIN, y);
|
||||
setFont(pdf, { bold: true, size: 12, color: accent });
|
||||
pdf.text(input.client.name, MARGIN, y + 14);
|
||||
setFont(pdf, { size: 9, color: muted });
|
||||
let by = y + 28;
|
||||
const clientLines = [
|
||||
input.client.contact,
|
||||
input.client.address1,
|
||||
input.client.address2,
|
||||
[input.client.city, input.client.state, input.client.postal].filter(Boolean).join(", "),
|
||||
].filter((l): l is string => !!l && l.trim().length > 0);
|
||||
for (const l of clientLines) {
|
||||
pdf.text(l, MARGIN, by);
|
||||
by += 11;
|
||||
}
|
||||
y = by + 16;
|
||||
|
||||
// ===== Column headers =====
|
||||
const colDateX = MARGIN;
|
||||
const colDescX = MARGIN + 64;
|
||||
const colQtyX = MARGIN + 358;
|
||||
const colRateX = MARGIN + 418;
|
||||
const colAmtX = PAGE_W - MARGIN; // right aligned
|
||||
const drawColHeaders = () => {
|
||||
pdf.setFillColor(245, 247, 250);
|
||||
pdf.rect(MARGIN, y - 10, CONTENT_W, 18, "F");
|
||||
setFont(pdf, { bold: true, size: 8, color: accent });
|
||||
pdf.text("DATE", colDateX + 2, y + 2);
|
||||
pdf.text("DESCRIPTION", colDescX, y + 2);
|
||||
pdf.text("QTY/HRS", colQtyX, y + 2);
|
||||
pdf.text("RATE", colRateX, y + 2);
|
||||
const w = pdf.getTextWidth("AMOUNT");
|
||||
pdf.text("AMOUNT", colAmtX - w, y + 2);
|
||||
y += 16;
|
||||
};
|
||||
|
||||
// ===== Per-case groups =====
|
||||
for (const group of input.groups) {
|
||||
ensure(60);
|
||||
// Case header bar
|
||||
pdf.setFillColor(...accent);
|
||||
pdf.rect(MARGIN, y - 10, CONTENT_W, 22, "F");
|
||||
setFont(pdf, { bold: true, size: 11, color: [255, 255, 255] });
|
||||
pdf.text(group.caseTitle, MARGIN + 8, y + 4);
|
||||
const caseMeta = [group.caseNumber, group.practiceArea].filter(Boolean).join(" · ");
|
||||
if (caseMeta) {
|
||||
setFont(pdf, { size: 9, color: [220, 228, 240] });
|
||||
const w = pdf.getTextWidth(caseMeta);
|
||||
pdf.text(caseMeta, PAGE_W - MARGIN - 8 - w, y + 4);
|
||||
}
|
||||
y += 22;
|
||||
|
||||
drawColHeaders();
|
||||
|
||||
setFont(pdf, { size: 9, color: [30, 34, 44] });
|
||||
for (const item of group.items) {
|
||||
const descMaxW = colQtyX - colDescX - 8;
|
||||
const descLines = wrap(pdf, item.description || (item.kind === "expense" ? "Expense" : ""), descMaxW);
|
||||
const subLine = item.kind === "time" && item.user_name ? item.user_name : item.kind === "expense" ? "Expense" : "";
|
||||
const lineHeight = 12;
|
||||
const blockH = Math.max(lineHeight * descLines.length + (subLine ? 10 : 0), 16);
|
||||
ensure(blockH + 4);
|
||||
|
||||
// Date
|
||||
setFont(pdf, { size: 9, color: muted });
|
||||
pdf.text(item.work_date ? formatDate(item.work_date) : "—", colDateX + 2, y);
|
||||
|
||||
// Description
|
||||
setFont(pdf, { size: 9, color: [30, 34, 44] });
|
||||
let dy = y;
|
||||
for (const dl of descLines) {
|
||||
pdf.text(dl, colDescX, dy);
|
||||
dy += lineHeight;
|
||||
}
|
||||
if (subLine) {
|
||||
setFont(pdf, { size: 8, color: muted, italic: true });
|
||||
pdf.text(subLine, colDescX, dy);
|
||||
}
|
||||
|
||||
// Qty
|
||||
setFont(pdf, { size: 9, color: [30, 34, 44] });
|
||||
const qtyText = item.kind === "time" ? Number(item.quantity).toFixed(2) : "1";
|
||||
pdf.text(qtyText, colQtyX, y);
|
||||
|
||||
// Rate
|
||||
pdf.text(fmtCurrency(item.rate), colRateX, y);
|
||||
|
||||
// Amount
|
||||
const amt = fmtCurrency(item.amount);
|
||||
const aw = pdf.getTextWidth(amt);
|
||||
pdf.text(amt, colAmtX - aw, y);
|
||||
|
||||
y += blockH + 4;
|
||||
pdf.setDrawColor(...rule);
|
||||
pdf.setLineWidth(0.4);
|
||||
pdf.line(MARGIN, y - 2, PAGE_W - MARGIN, y - 2);
|
||||
}
|
||||
|
||||
// Case subtotal
|
||||
ensure(20);
|
||||
setFont(pdf, { bold: true, size: 9.5, color: accent });
|
||||
const labelTxt = `${group.caseNumber} subtotal`;
|
||||
pdf.text(labelTxt, colRateX - 40, y + 6);
|
||||
const subText = fmtCurrency(group.subtotal);
|
||||
const sw = pdf.getTextWidth(subText);
|
||||
pdf.text(subText, colAmtX - sw, y + 6);
|
||||
y += 22;
|
||||
}
|
||||
|
||||
// ===== Totals =====
|
||||
ensure(110);
|
||||
y += 8;
|
||||
pdf.setDrawColor(...accent);
|
||||
pdf.setLineWidth(1);
|
||||
pdf.line(PAGE_W - MARGIN - 240, y, PAGE_W - MARGIN, y);
|
||||
y += 14;
|
||||
|
||||
const drawTotalRow = (label: string, value: string, opts?: { bold?: boolean; size?: number; color?: [number, number, number] }) => {
|
||||
setFont(pdf, { size: opts?.size ?? 10, bold: opts?.bold, color: opts?.color ?? [30, 34, 44] });
|
||||
pdf.text(label, PAGE_W - MARGIN - 240, y);
|
||||
const w = pdf.getTextWidth(value);
|
||||
pdf.text(value, PAGE_W - MARGIN - w, y);
|
||||
y += 16;
|
||||
};
|
||||
|
||||
drawTotalRow("Subtotal", fmtCurrency(input.totals.subtotal));
|
||||
if (input.totals.tax > 0) drawTotalRow("Tax", fmtCurrency(input.totals.tax));
|
||||
drawTotalRow("Total", fmtCurrency(input.totals.total), { bold: true, size: 11, color: accent });
|
||||
if (input.totals.paid > 0) {
|
||||
drawTotalRow("Amount paid", `- ${fmtCurrency(input.totals.paid)}`, { color: muted });
|
||||
pdf.setDrawColor(...accent);
|
||||
pdf.setLineWidth(0.6);
|
||||
pdf.line(PAGE_W - MARGIN - 240, y - 8, PAGE_W - MARGIN, y - 8);
|
||||
drawTotalRow("Balance due", fmtCurrency(input.totals.balance), { bold: true, size: 12, color: accent });
|
||||
}
|
||||
|
||||
// ===== Notes =====
|
||||
if (input.invoice.notes) {
|
||||
ensure(40);
|
||||
y += 10;
|
||||
setFont(pdf, { bold: true, size: 9, color: accent });
|
||||
pdf.text("NOTES", MARGIN, y);
|
||||
y += 12;
|
||||
setFont(pdf, { size: 9, color: [40, 44, 54] });
|
||||
const lines = wrap(pdf, input.invoice.notes, CONTENT_W);
|
||||
for (const l of lines) {
|
||||
ensure(12);
|
||||
pdf.text(l, MARGIN, y);
|
||||
y += 12;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer on every page
|
||||
drawPageFooter(pdf, input);
|
||||
const total = pdf.getNumberOfPages();
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pdf.setPage(i);
|
||||
setFont(pdf, { size: 8, color: muted });
|
||||
const pageTxt = `Page ${i} of ${total}`;
|
||||
const w = pdf.getTextWidth(pageTxt);
|
||||
pdf.text(pageTxt, PAGE_W - MARGIN - w, PAGE_H - 22);
|
||||
}
|
||||
|
||||
pdf.save(filename.endsWith(".pdf") ? filename : `${filename}.pdf`);
|
||||
}
|
||||
|
||||
function drawPageFooter(pdf: jsPDF, input: InvoicePdfInput) {
|
||||
const muted: [number, number, number] = [110, 116, 128];
|
||||
pdf.setDrawColor(220, 224, 232);
|
||||
pdf.setLineWidth(0.5);
|
||||
pdf.line(MARGIN, PAGE_H - 36, PAGE_W - MARGIN, PAGE_H - 36);
|
||||
pdf.setFont("helvetica", "italic");
|
||||
pdf.setFontSize(8);
|
||||
pdf.setTextColor(...muted);
|
||||
const note = input.firm.footerNote || `Thank you for your business. Please remit payment to ${input.firm.name || "us"}.`;
|
||||
pdf.text(note, MARGIN, PAGE_H - 22);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as StatusIndexRouteImport } from './routes/status.index'
|
||||
import { Route as SettingsIndexRouteImport } from './routes/settings.index'
|
||||
import { Route as MessagesIndexRouteImport } from './routes/messages.index'
|
||||
import { Route as InvoicesIndexRouteImport } from './routes/invoices.index'
|
||||
import { Route as FilesIndexRouteImport } from './routes/files.index'
|
||||
import { Route as DocumentsIndexRouteImport } from './routes/documents.index'
|
||||
import { Route as ContactsIndexRouteImport } from './routes/contacts.index'
|
||||
@@ -24,6 +25,7 @@ import { Route as ClientsIndexRouteImport } from './routes/clients.index'
|
||||
import { Route as CasesIndexRouteImport } from './routes/cases.index'
|
||||
import { Route as SettingsWorkflowRouteImport } from './routes/settings.workflow'
|
||||
import { Route as SettingsFeesRouteImport } from './routes/settings.fees'
|
||||
import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId'
|
||||
import { Route as ContactsContactIdRouteImport } from './routes/contacts.$contactId'
|
||||
import { Route as CollectionsCollectionIdRouteImport } from './routes/collections.$collectionId'
|
||||
import { Route as ClientsClientIdRouteImport } from './routes/clients.$clientId'
|
||||
@@ -70,6 +72,11 @@ const MessagesIndexRoute = MessagesIndexRouteImport.update({
|
||||
path: '/messages/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const InvoicesIndexRoute = InvoicesIndexRouteImport.update({
|
||||
id: '/invoices/',
|
||||
path: '/invoices/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const FilesIndexRoute = FilesIndexRouteImport.update({
|
||||
id: '/files/',
|
||||
path: '/files/',
|
||||
@@ -110,6 +117,11 @@ const SettingsFeesRoute = SettingsFeesRouteImport.update({
|
||||
path: '/fees',
|
||||
getParentRoute: () => SettingsRoute,
|
||||
} as any)
|
||||
const InvoicesInvoiceIdRoute = InvoicesInvoiceIdRouteImport.update({
|
||||
id: '/invoices/$invoiceId',
|
||||
path: '/invoices/$invoiceId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ContactsContactIdRoute = ContactsContactIdRouteImport.update({
|
||||
id: '/contacts/$contactId',
|
||||
path: '/contacts/$contactId',
|
||||
@@ -173,6 +185,7 @@ export interface FileRoutesByFullPath {
|
||||
'/clients/$clientId': typeof ClientsClientIdRoute
|
||||
'/collections/$collectionId': typeof CollectionsCollectionIdRoute
|
||||
'/contacts/$contactId': typeof ContactsContactIdRoute
|
||||
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
|
||||
'/settings/fees': typeof SettingsFeesRoute
|
||||
'/settings/workflow': typeof SettingsWorkflowRoute
|
||||
'/cases/': typeof CasesIndexRoute
|
||||
@@ -181,6 +194,7 @@ export interface FileRoutesByFullPath {
|
||||
'/contacts/': typeof ContactsIndexRoute
|
||||
'/documents/': typeof DocumentsIndexRoute
|
||||
'/files/': typeof FilesIndexRoute
|
||||
'/invoices/': typeof InvoicesIndexRoute
|
||||
'/messages/': typeof MessagesIndexRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
'/status/': typeof StatusIndexRoute
|
||||
@@ -199,6 +213,7 @@ export interface FileRoutesByTo {
|
||||
'/clients/$clientId': typeof ClientsClientIdRoute
|
||||
'/collections/$collectionId': typeof CollectionsCollectionIdRoute
|
||||
'/contacts/$contactId': typeof ContactsContactIdRoute
|
||||
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
|
||||
'/settings/fees': typeof SettingsFeesRoute
|
||||
'/settings/workflow': typeof SettingsWorkflowRoute
|
||||
'/cases': typeof CasesIndexRoute
|
||||
@@ -207,6 +222,7 @@ export interface FileRoutesByTo {
|
||||
'/contacts': typeof ContactsIndexRoute
|
||||
'/documents': typeof DocumentsIndexRoute
|
||||
'/files': typeof FilesIndexRoute
|
||||
'/invoices': typeof InvoicesIndexRoute
|
||||
'/messages': typeof MessagesIndexRoute
|
||||
'/settings': typeof SettingsIndexRoute
|
||||
'/status': typeof StatusIndexRoute
|
||||
@@ -227,6 +243,7 @@ export interface FileRoutesById {
|
||||
'/clients/$clientId': typeof ClientsClientIdRoute
|
||||
'/collections/$collectionId': typeof CollectionsCollectionIdRoute
|
||||
'/contacts/$contactId': typeof ContactsContactIdRoute
|
||||
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
|
||||
'/settings/fees': typeof SettingsFeesRoute
|
||||
'/settings/workflow': typeof SettingsWorkflowRoute
|
||||
'/cases/': typeof CasesIndexRoute
|
||||
@@ -235,6 +252,7 @@ export interface FileRoutesById {
|
||||
'/contacts/': typeof ContactsIndexRoute
|
||||
'/documents/': typeof DocumentsIndexRoute
|
||||
'/files/': typeof FilesIndexRoute
|
||||
'/invoices/': typeof InvoicesIndexRoute
|
||||
'/messages/': typeof MessagesIndexRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
'/status/': typeof StatusIndexRoute
|
||||
@@ -256,6 +274,7 @@ export interface FileRouteTypes {
|
||||
| '/clients/$clientId'
|
||||
| '/collections/$collectionId'
|
||||
| '/contacts/$contactId'
|
||||
| '/invoices/$invoiceId'
|
||||
| '/settings/fees'
|
||||
| '/settings/workflow'
|
||||
| '/cases/'
|
||||
@@ -264,6 +283,7 @@ export interface FileRouteTypes {
|
||||
| '/contacts/'
|
||||
| '/documents/'
|
||||
| '/files/'
|
||||
| '/invoices/'
|
||||
| '/messages/'
|
||||
| '/settings/'
|
||||
| '/status/'
|
||||
@@ -282,6 +302,7 @@ export interface FileRouteTypes {
|
||||
| '/clients/$clientId'
|
||||
| '/collections/$collectionId'
|
||||
| '/contacts/$contactId'
|
||||
| '/invoices/$invoiceId'
|
||||
| '/settings/fees'
|
||||
| '/settings/workflow'
|
||||
| '/cases'
|
||||
@@ -290,6 +311,7 @@ export interface FileRouteTypes {
|
||||
| '/contacts'
|
||||
| '/documents'
|
||||
| '/files'
|
||||
| '/invoices'
|
||||
| '/messages'
|
||||
| '/settings'
|
||||
| '/status'
|
||||
@@ -309,6 +331,7 @@ export interface FileRouteTypes {
|
||||
| '/clients/$clientId'
|
||||
| '/collections/$collectionId'
|
||||
| '/contacts/$contactId'
|
||||
| '/invoices/$invoiceId'
|
||||
| '/settings/fees'
|
||||
| '/settings/workflow'
|
||||
| '/cases/'
|
||||
@@ -317,6 +340,7 @@ export interface FileRouteTypes {
|
||||
| '/contacts/'
|
||||
| '/documents/'
|
||||
| '/files/'
|
||||
| '/invoices/'
|
||||
| '/messages/'
|
||||
| '/settings/'
|
||||
| '/status/'
|
||||
@@ -337,12 +361,14 @@ export interface RootRouteChildren {
|
||||
ClientsClientIdRoute: typeof ClientsClientIdRoute
|
||||
CollectionsCollectionIdRoute: typeof CollectionsCollectionIdRoute
|
||||
ContactsContactIdRoute: typeof ContactsContactIdRoute
|
||||
InvoicesInvoiceIdRoute: typeof InvoicesInvoiceIdRoute
|
||||
CasesIndexRoute: typeof CasesIndexRoute
|
||||
ClientsIndexRoute: typeof ClientsIndexRoute
|
||||
CollectionsIndexRoute: typeof CollectionsIndexRoute
|
||||
ContactsIndexRoute: typeof ContactsIndexRoute
|
||||
DocumentsIndexRoute: typeof DocumentsIndexRoute
|
||||
FilesIndexRoute: typeof FilesIndexRoute
|
||||
InvoicesIndexRoute: typeof InvoicesIndexRoute
|
||||
MessagesIndexRoute: typeof MessagesIndexRoute
|
||||
StatusIndexRoute: typeof StatusIndexRoute
|
||||
DocumentsPleadingNewRoute: typeof DocumentsPleadingNewRoute
|
||||
@@ -402,6 +428,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof MessagesIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/invoices/': {
|
||||
id: '/invoices/'
|
||||
path: '/invoices'
|
||||
fullPath: '/invoices/'
|
||||
preLoaderRoute: typeof InvoicesIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/files/': {
|
||||
id: '/files/'
|
||||
path: '/files'
|
||||
@@ -458,6 +491,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SettingsFeesRouteImport
|
||||
parentRoute: typeof SettingsRoute
|
||||
}
|
||||
'/invoices/$invoiceId': {
|
||||
id: '/invoices/$invoiceId'
|
||||
path: '/invoices/$invoiceId'
|
||||
fullPath: '/invoices/$invoiceId'
|
||||
preLoaderRoute: typeof InvoicesInvoiceIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/contacts/$contactId': {
|
||||
id: '/contacts/$contactId'
|
||||
path: '/contacts/$contactId'
|
||||
@@ -558,12 +598,14 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ClientsClientIdRoute: ClientsClientIdRoute,
|
||||
CollectionsCollectionIdRoute: CollectionsCollectionIdRoute,
|
||||
ContactsContactIdRoute: ContactsContactIdRoute,
|
||||
InvoicesInvoiceIdRoute: InvoicesInvoiceIdRoute,
|
||||
CasesIndexRoute: CasesIndexRoute,
|
||||
ClientsIndexRoute: ClientsIndexRoute,
|
||||
CollectionsIndexRoute: CollectionsIndexRoute,
|
||||
ContactsIndexRoute: ContactsIndexRoute,
|
||||
DocumentsIndexRoute: DocumentsIndexRoute,
|
||||
FilesIndexRoute: FilesIndexRoute,
|
||||
InvoicesIndexRoute: InvoicesIndexRoute,
|
||||
MessagesIndexRoute: MessagesIndexRoute,
|
||||
StatusIndexRoute: StatusIndexRoute,
|
||||
DocumentsPleadingNewRoute: DocumentsPleadingNewRoute,
|
||||
|
||||
@@ -9,11 +9,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { ClientFormDialog } from "@/components/clients/client-form-dialog";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { ArrowLeft, Edit, Plus, Building2, User, MapPin, Mail, Phone, Users, Activity, Briefcase, FileDown, Contact } from "lucide-react";
|
||||
import { ArrowLeft, Edit, Plus, Building2, User, MapPin, Mail, Phone, Users, Activity, Briefcase, FileDown, Contact, Receipt } from "lucide-react";
|
||||
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
|
||||
import { formatCurrency, formatDate, formatDateTime, statusBadgeClass } from "@/lib/format";
|
||||
import { toast } from "sonner";
|
||||
import { downloadStatusReport } from "@/lib/status-pdf";
|
||||
import { GenerateInvoiceDialog } from "@/components/invoices/generate-invoice-dialog";
|
||||
|
||||
export const Route = createFileRoute("/clients/$clientId")({
|
||||
component: () => (
|
||||
@@ -32,6 +33,7 @@ function ClientDetail() {
|
||||
const [statusEntries, setStatusEntries] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -125,6 +127,9 @@ function ClientDetail() {
|
||||
<Edit className="h-4 w-4 mr-2" /> Edit
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setInvoiceOpen(true)}>
|
||||
<Receipt className="h-4 w-4 mr-2" /> Generate invoice
|
||||
</Button>
|
||||
<Button onClick={() => navigate({ to: "/cases/new", search: { clientId: client.id } })}>
|
||||
<Plus className="h-4 w-4 mr-2" /> New case
|
||||
</Button>
|
||||
@@ -286,6 +291,12 @@ function ClientDetail() {
|
||||
</div>
|
||||
|
||||
<ClientFormDialog open={editOpen} onOpenChange={setEditOpen} client={client} onSaved={load} />
|
||||
<GenerateInvoiceDialog
|
||||
open={invoiceOpen}
|
||||
onOpenChange={setInvoiceOpen}
|
||||
clientId={client.id}
|
||||
clientName={client.name}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { ProtectedLayout } from "@/components/protected-layout";
|
||||
import { PageContainer } from "@/components/app-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { ArrowLeft, FileDown, Plus, Trash2, Loader2, Send, Ban, CheckCircle2, DollarSign } from "lucide-react";
|
||||
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
|
||||
import { downloadInvoicePdf, type InvoicePdfInput } from "@/lib/invoice-pdf";
|
||||
import { recalcInvoiceTotals } from "@/lib/invoice-generation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/invoices/$invoiceId")({
|
||||
component: () => (
|
||||
<ProtectedLayout>
|
||||
<InvoiceDetail />
|
||||
</ProtectedLayout>
|
||||
),
|
||||
});
|
||||
|
||||
function InvoiceDetail() {
|
||||
const { invoiceId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, isAdmin } = useAuth();
|
||||
const [invoice, setInvoice] = useState<any>(null);
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [payments, setPayments] = useState<any[]>([]);
|
||||
const [firm, setFirm] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingItem, setSavingItem] = useState(false);
|
||||
const [payOpen, setPayOpen] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const [invRes, itemRes, payRes, firmRes] = await Promise.all([
|
||||
supabase
|
||||
.from("invoices")
|
||||
.select("*, client:clients(id, name, primary_contact_name, address_line1, address_line2, city, state, postal_code)")
|
||||
.eq("id", invoiceId)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from("invoice_line_items")
|
||||
.select("*, case:cases(id, case_number, title, practice_area), user:profiles(id, full_name, email)")
|
||||
.eq("invoice_id", invoiceId)
|
||||
.order("sort_order", { ascending: true }),
|
||||
supabase.from("invoice_payments").select("*").eq("invoice_id", invoiceId).order("paid_on", { ascending: false }),
|
||||
supabase.from("firm_settings").select("*").maybeSingle(),
|
||||
]);
|
||||
setInvoice(invRes.data);
|
||||
setItems(itemRes.data ?? []);
|
||||
setPayments(payRes.data ?? []);
|
||||
setFirm(firmRes.data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [invoiceId]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, { caseRow: any; items: any[]; subtotal: number }>();
|
||||
for (const it of items) {
|
||||
const key = it.case?.id ?? "_none";
|
||||
if (!map.has(key)) map.set(key, { caseRow: it.case, items: [], subtotal: 0 });
|
||||
const g = map.get(key)!;
|
||||
g.items.push(it);
|
||||
g.subtotal += Number(it.amount);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}, [items]);
|
||||
|
||||
if (loading) return <PageContainer><p className="text-muted-foreground">Loading…</p></PageContainer>;
|
||||
if (!invoice) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<p className="text-muted-foreground">Invoice not found.</p>
|
||||
<Button variant="outline" className="mt-3" asChild>
|
||||
<Link to="/invoices"><ArrowLeft className="h-4 w-4 mr-2" /> All invoices</Link>
|
||||
</Button>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const canEdit = isAdmin || invoice.created_by === user?.id;
|
||||
const isDraft = invoice.status === "draft";
|
||||
const balance = Number(invoice.total) - Number(invoice.amount_paid);
|
||||
|
||||
const setStatus = async (status: string) => {
|
||||
const { error } = await supabase.from("invoices").update({ status: status as any }).eq("id", invoiceId);
|
||||
if (error) toast.error(error.message);
|
||||
else { toast.success(`Marked ${status}`); load(); }
|
||||
};
|
||||
|
||||
const removeItem = async (id: string) => {
|
||||
if (!confirm("Remove this line item?")) return;
|
||||
const { error } = await supabase.from("invoice_line_items").delete().eq("id", id);
|
||||
if (error) return toast.error(error.message);
|
||||
await recalcInvoiceTotals(invoiceId);
|
||||
load();
|
||||
};
|
||||
|
||||
const updateItem = async (id: string, patch: any) => {
|
||||
const next: any = { ...patch };
|
||||
if (patch.quantity != null || patch.rate != null) {
|
||||
const cur = items.find((i) => i.id === id);
|
||||
const q = Number(patch.quantity ?? cur.quantity);
|
||||
const r = Number(patch.rate ?? cur.rate);
|
||||
next.amount = +(q * r).toFixed(2);
|
||||
}
|
||||
const { error } = await supabase.from("invoice_line_items").update(next).eq("id", id);
|
||||
if (error) return toast.error(error.message);
|
||||
await recalcInvoiceTotals(invoiceId);
|
||||
load();
|
||||
};
|
||||
|
||||
const addManualItem = async (caseId: string | null) => {
|
||||
setSavingItem(true);
|
||||
const sort_order = items.length ? Math.max(...items.map((i) => i.sort_order)) + 1 : 0;
|
||||
const { error } = await supabase.from("invoice_line_items").insert({
|
||||
invoice_id: invoiceId,
|
||||
case_id: caseId,
|
||||
kind: "manual",
|
||||
description: "New line",
|
||||
quantity: 1,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
sort_order,
|
||||
});
|
||||
setSavingItem(false);
|
||||
if (error) return toast.error(error.message);
|
||||
await recalcInvoiceTotals(invoiceId);
|
||||
load();
|
||||
};
|
||||
|
||||
const updateNotes = async (notes: string) => {
|
||||
await supabase.from("invoices").update({ notes }).eq("id", invoiceId);
|
||||
};
|
||||
|
||||
const updateTaxRate = async (ratePct: string) => {
|
||||
const subtotal = Number(invoice.subtotal);
|
||||
const taxRate = Math.max(0, Number(ratePct) || 0) / 100;
|
||||
const tax = +(subtotal * taxRate).toFixed(2);
|
||||
const total = +(subtotal + tax).toFixed(2);
|
||||
await supabase.from("invoices").update({ tax, total }).eq("id", invoiceId);
|
||||
load();
|
||||
};
|
||||
|
||||
const downloadPdf = async () => {
|
||||
let logoDataUrl: string | null = null;
|
||||
if (firm?.logo_storage_path) {
|
||||
const { data: signed } = await supabase.storage
|
||||
.from("firm-logos")
|
||||
.createSignedUrl(firm.logo_storage_path, 60);
|
||||
if (signed?.signedUrl) {
|
||||
try {
|
||||
const res = await fetch(signed.signedUrl);
|
||||
const blob = await res.blob();
|
||||
logoDataUrl = await new Promise<string>((resolve) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve(r.result as string);
|
||||
r.readAsDataURL(blob);
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
const input: InvoicePdfInput = {
|
||||
firm: {
|
||||
name: firm?.company_name,
|
||||
address1: firm?.address_line1,
|
||||
address2: firm?.address_line2,
|
||||
city: firm?.city,
|
||||
state: firm?.state,
|
||||
postal: firm?.postal_code,
|
||||
email: firm?.contact_email,
|
||||
phone: firm?.contact_phone,
|
||||
website: firm?.website,
|
||||
footerNote: firm?.footer_note,
|
||||
logoDataUrl,
|
||||
},
|
||||
client: {
|
||||
name: invoice.client?.name ?? "Client",
|
||||
contact: invoice.client?.primary_contact_name,
|
||||
address1: invoice.client?.address_line1,
|
||||
address2: invoice.client?.address_line2,
|
||||
city: invoice.client?.city,
|
||||
state: invoice.client?.state,
|
||||
postal: invoice.client?.postal_code,
|
||||
},
|
||||
invoice: {
|
||||
number: invoice.invoice_number,
|
||||
issueDate: invoice.issue_date,
|
||||
dueDate: invoice.due_date,
|
||||
status: invoice.status,
|
||||
notes: invoice.notes,
|
||||
},
|
||||
groups: groups.map((g) => ({
|
||||
caseNumber: g.caseRow?.case_number ?? "—",
|
||||
caseTitle: g.caseRow?.title ?? "(unassigned)",
|
||||
practiceArea: g.caseRow?.practice_area,
|
||||
subtotal: g.subtotal,
|
||||
items: g.items.map((it) => ({
|
||||
kind: it.kind,
|
||||
description: it.description,
|
||||
work_date: it.work_date,
|
||||
quantity: Number(it.quantity),
|
||||
rate: Number(it.rate),
|
||||
amount: Number(it.amount),
|
||||
user_name: it.user?.full_name || it.user?.email,
|
||||
})),
|
||||
})),
|
||||
totals: {
|
||||
subtotal: Number(invoice.subtotal),
|
||||
tax: Number(invoice.tax),
|
||||
total: Number(invoice.total),
|
||||
paid: Number(invoice.amount_paid),
|
||||
balance,
|
||||
},
|
||||
};
|
||||
await downloadInvoicePdf(input, `${invoice.invoice_number}.pdf`);
|
||||
};
|
||||
|
||||
const deleteInvoice = async () => {
|
||||
if (!confirm("Delete this draft invoice and unbill its time/expenses?")) return;
|
||||
// Unlink time/expenses
|
||||
await supabase.from("time_entries").update({ invoice_id: null }).eq("invoice_id", invoiceId);
|
||||
await supabase.from("expenses").update({ invoice_id: null }).eq("invoice_id", invoiceId);
|
||||
const { error } = await supabase.from("invoices").delete().eq("id", invoiceId);
|
||||
if (error) return toast.error(error.message);
|
||||
toast.success("Invoice deleted");
|
||||
navigate({ to: "/invoices" });
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Button variant="ghost" size="sm" asChild className="mb-3 -ml-2">
|
||||
<Link to="/invoices"><ArrowLeft className="h-4 w-4 mr-1" /> All invoices</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="text-xs uppercase tracking-widest text-muted-foreground">Invoice</span>
|
||||
<Badge variant="outline" className={statusBadgeClass(invoice.status)}>{invoice.status}</Badge>
|
||||
</div>
|
||||
<h1 className="font-serif text-3xl">{invoice.invoice_number}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<Link to="/clients/$clientId" params={{ clientId: invoice.client?.id }} className="hover:text-primary">
|
||||
{invoice.client?.name}
|
||||
</Link>
|
||||
{" · "}Issued {formatDate(invoice.issue_date)}
|
||||
{invoice.due_date && <> · Due {formatDate(invoice.due_date)}</>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={downloadPdf}><FileDown className="h-4 w-4 mr-2" /> Download PDF</Button>
|
||||
{canEdit && isDraft && (
|
||||
<Button onClick={() => setStatus("sent")}><Send className="h-4 w-4 mr-2" /> Mark sent</Button>
|
||||
)}
|
||||
{canEdit && (invoice.status === "sent" || invoice.status === "overdue") && (
|
||||
<Button variant="outline" onClick={() => setStatus("paid")}><CheckCircle2 className="h-4 w-4 mr-2" /> Mark paid</Button>
|
||||
)}
|
||||
{canEdit && invoice.status !== "void" && invoice.status !== "paid" && (
|
||||
<Button variant="outline" onClick={() => setStatus("void")}><Ban className="h-4 w-4 mr-2" /> Void</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button onClick={() => setPayOpen(true)}><DollarSign className="h-4 w-4 mr-2" /> Record payment</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-5">
|
||||
<div className="lg:col-span-2 space-y-5">
|
||||
{groups.length === 0 && (
|
||||
<Card className="border-border/60">
|
||||
<CardContent className="p-6 text-center text-muted-foreground">No line items.</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{groups.map((g, gi) => (
|
||||
<Card key={gi} className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-3 bg-muted/40 border-b flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-medium">{g.caseRow?.title ?? "Unassigned"}</div>
|
||||
<div className="text-xs text-muted-foreground">{g.caseRow?.case_number}{g.caseRow?.practice_area ? ` · ${g.caseRow.practice_area}` : ""}</div>
|
||||
</div>
|
||||
<div className="text-sm tabular-nums font-medium">{formatCurrency(g.subtotal)}</div>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 font-medium w-[100px]">Date</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Description</th>
|
||||
<th className="text-right px-3 py-2 font-medium w-[80px]">Qty</th>
|
||||
<th className="text-right px-3 py-2 font-medium w-[100px]">Rate</th>
|
||||
<th className="text-right px-3 py-2 font-medium w-[110px]">Amount</th>
|
||||
{canEdit && isDraft && <th className="w-[40px]" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{g.items.map((it) => (
|
||||
<tr key={it.id} className="border-t">
|
||||
<td className="px-3 py-2 text-muted-foreground text-xs">{it.work_date ? formatDate(it.work_date) : "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
{canEdit && isDraft ? (
|
||||
<Input className="h-8" defaultValue={it.description}
|
||||
onBlur={(e) => e.target.value !== it.description && updateItem(it.id, { description: e.target.value })} />
|
||||
) : (
|
||||
<div>
|
||||
<div>{it.description}</div>
|
||||
{it.user?.full_name && <div className="text-[11px] text-muted-foreground italic">{it.user.full_name}</div>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{canEdit && isDraft ? (
|
||||
<Input className="h-8 text-right tabular-nums" defaultValue={Number(it.quantity)}
|
||||
onBlur={(e) => Number(e.target.value) !== Number(it.quantity) && updateItem(it.id, { quantity: Number(e.target.value) })} />
|
||||
) : Number(it.quantity).toFixed(2)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{canEdit && isDraft ? (
|
||||
<Input className="h-8 text-right tabular-nums" defaultValue={Number(it.rate)}
|
||||
onBlur={(e) => Number(e.target.value) !== Number(it.rate) && updateItem(it.id, { rate: Number(e.target.value) })} />
|
||||
) : formatCurrency(it.rate)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-medium">{formatCurrency(it.amount)}</td>
|
||||
{canEdit && isDraft && (
|
||||
<td className="px-2 py-2 text-right">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => removeItem(it.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{canEdit && isDraft && (
|
||||
<div className="border-t px-3 py-2 bg-muted/20">
|
||||
<Button variant="ghost" size="sm" onClick={() => addManualItem(g.caseRow?.id ?? null)} disabled={savingItem}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" /> Add line to {g.caseRow?.case_number ?? "this group"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{canEdit && isDraft && groups.length === 0 && (
|
||||
<Button variant="outline" onClick={() => addManualItem(null)}>
|
||||
<Plus className="h-4 w-4 mr-2" /> Add first line item
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
|
||||
{canEdit && isDraft ? (
|
||||
<Textarea defaultValue={invoice.notes ?? ""} rows={3} placeholder="Payment terms, thank-you message…"
|
||||
onBlur={(e) => updateNotes(e.target.value)} />
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap">{invoice.notes || <span className="text-muted-foreground italic">—</span>}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="border-border/60">
|
||||
<CardContent className="p-5 space-y-2 text-sm">
|
||||
<Row label="Subtotal" value={formatCurrency(invoice.subtotal)} />
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Tax</span>
|
||||
{canEdit && isDraft ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input className="h-7 w-16 text-right tabular-nums" type="number" step="0.01"
|
||||
defaultValue={invoice.subtotal > 0 ? ((Number(invoice.tax) / Number(invoice.subtotal)) * 100).toFixed(2) : "0"}
|
||||
onBlur={(e) => updateTaxRate(e.target.value)} />
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
</div>
|
||||
) : <span className="tabular-nums">{formatCurrency(invoice.tax)}</span>}
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t font-medium">
|
||||
<span>Total</span><span className="tabular-nums font-serif text-lg">{formatCurrency(invoice.total)}</span>
|
||||
</div>
|
||||
{Number(invoice.amount_paid) > 0 && (
|
||||
<>
|
||||
<Row label="Paid" value={`- ${formatCurrency(invoice.amount_paid)}`} muted />
|
||||
<div className="flex justify-between pt-2 border-t font-medium">
|
||||
<span>Balance due</span><span className="tabular-nums font-serif text-lg text-primary">{formatCurrency(balance)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-serif text-sm">Payments</h3>
|
||||
{canEdit && (
|
||||
<Button variant="outline" size="sm" onClick={() => setPayOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{payments.length === 0 && <p className="text-sm text-muted-foreground">No payments recorded.</p>}
|
||||
<div className="space-y-1">
|
||||
{payments.map((p) => (
|
||||
<div key={p.id} className="flex justify-between items-baseline text-sm border-b py-1.5 last:border-0">
|
||||
<div>
|
||||
<div>{formatDate(p.paid_on)}</div>
|
||||
<div className="text-xs text-muted-foreground">{p.method || "—"}{p.reference ? ` · ${p.reference}` : ""}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="tabular-nums font-medium">{formatCurrency(p.amount)}</span>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
if (!confirm("Delete this payment?")) return;
|
||||
await supabase.from("invoice_payments").delete().eq("id", p.id);
|
||||
load();
|
||||
}}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canEdit && isDraft && (
|
||||
<Button variant="outline" className="w-full text-destructive hover:text-destructive" onClick={deleteInvoice}>
|
||||
<Trash2 className="h-4 w-4 mr-2" /> Delete draft
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RecordPaymentDialog
|
||||
open={payOpen}
|
||||
onOpenChange={setPayOpen}
|
||||
invoiceId={invoiceId}
|
||||
balance={balance}
|
||||
userId={user?.id ?? ""}
|
||||
onSaved={load}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, muted }: { label: string; value: string; muted?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className={muted ? "text-muted-foreground" : "text-muted-foreground"}>{label}</span>
|
||||
<span className={`tabular-nums ${muted ? "text-muted-foreground" : ""}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordPaymentDialog({ open, onOpenChange, invoiceId, balance, userId, onSaved }: {
|
||||
open: boolean; onOpenChange: (b: boolean) => void; invoiceId: string; balance: number; userId: string; onSaved: () => void;
|
||||
}) {
|
||||
const [amount, setAmount] = useState(balance > 0 ? balance.toFixed(2) : "");
|
||||
const [paidOn, setPaidOn] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [method, setMethod] = useState("check");
|
||||
const [reference, setReference] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => { if (open) setAmount(balance > 0 ? balance.toFixed(2) : ""); }, [open, balance]);
|
||||
|
||||
const save = async () => {
|
||||
const amt = Number(amount);
|
||||
if (!amt || amt <= 0) return toast.error("Enter a valid amount");
|
||||
setSaving(true);
|
||||
const { error } = await supabase.from("invoice_payments").insert({
|
||||
invoice_id: invoiceId,
|
||||
amount: amt,
|
||||
paid_on: paidOn,
|
||||
method,
|
||||
reference: reference || null,
|
||||
notes: notes || null,
|
||||
created_by: userId || null,
|
||||
});
|
||||
setSaving(false);
|
||||
if (error) return toast.error(error.message);
|
||||
toast.success("Payment recorded");
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Record payment</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Amount</Label>
|
||||
<Input type="number" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Paid on</Label>
|
||||
<Input type="date" value={paidOn} onChange={(e) => setPaidOn(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Method</Label>
|
||||
<Select value={method} onValueChange={setMethod}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="check">Check</SelectItem>
|
||||
<SelectItem value="ach">ACH / Wire</SelectItem>
|
||||
<SelectItem value="card">Credit Card</SelectItem>
|
||||
<SelectItem value="cash">Cash</SelectItem>
|
||||
<SelectItem value="trust">Trust transfer</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Reference</Label>
|
||||
<Input value={reference} onChange={(e) => setReference(e.target.value)} placeholder="Check #, txn id…" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Notes</Label>
|
||||
<Textarea rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}Save payment
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ProtectedLayout } from "@/components/protected-layout";
|
||||
import { PageContainer, PageHeader } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Receipt, Search } from "lucide-react";
|
||||
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export const Route = createFileRoute("/invoices/")({
|
||||
component: () => (
|
||||
<ProtectedLayout>
|
||||
<InvoicesIndex />
|
||||
</ProtectedLayout>
|
||||
),
|
||||
});
|
||||
|
||||
function InvoicesIndex() {
|
||||
const [invoices, setInvoices] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [q, setQ] = useState("");
|
||||
const [status, setStatus] = useState<string>("all");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { data } = await supabase
|
||||
.from("invoices")
|
||||
.select("*, client:clients(id, name), case:cases(id, case_number, title)")
|
||||
.order("created_at", { ascending: false });
|
||||
setInvoices(data ?? []);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return invoices.filter((i) => {
|
||||
if (status !== "all" && i.status !== status) return false;
|
||||
if (!q) return true;
|
||||
const s = q.toLowerCase();
|
||||
return (
|
||||
i.invoice_number?.toLowerCase().includes(s) ||
|
||||
i.client?.name?.toLowerCase().includes(s) ||
|
||||
i.case?.case_number?.toLowerCase().includes(s)
|
||||
);
|
||||
});
|
||||
}, [invoices, q, status]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const outstanding = filtered.reduce((s, i) => s + (Number(i.total) - Number(i.amount_paid)), 0);
|
||||
const paid = filtered.reduce((s, i) => s + Number(i.amount_paid), 0);
|
||||
return { outstanding, paid, count: filtered.length };
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="Invoices" description="All client invoices across the firm" />
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-3 mb-5">
|
||||
<Stat label="Invoices" value={totals.count.toString()} />
|
||||
<Stat label="Outstanding A/R" value={formatCurrency(totals.outstanding)} />
|
||||
<Stat label="Collected" value={formatCurrency(totals.paid)} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input className="pl-9" placeholder="Search by invoice #, client, case…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="sent">Sent</SelectItem>
|
||||
<SelectItem value="paid">Paid</SelectItem>
|
||||
<SelectItem value="overdue">Overdue</SelectItem>
|
||||
<SelectItem value="void">Void</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium">Invoice #</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Client</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Issued</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Due</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Total</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && <tr><td colSpan={7} className="text-center py-12 text-muted-foreground">Loading…</td></tr>}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
<Receipt className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
||||
No invoices yet. Generate one from a Client or Case page.
|
||||
</td></tr>
|
||||
)}
|
||||
{filtered.map((i) => {
|
||||
const balance = Number(i.total) - Number(i.amount_paid);
|
||||
return (
|
||||
<tr key={i.id} className="border-t hover:bg-muted/30">
|
||||
<td className="px-4 py-3">
|
||||
<Link to="/invoices/$invoiceId" params={{ invoiceId: i.id }} className="font-medium hover:text-primary">
|
||||
{i.invoice_number}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{i.client ? (
|
||||
<Link to="/clients/$clientId" params={{ clientId: i.client.id }} className="hover:text-primary">
|
||||
{i.client.name}
|
||||
</Link>
|
||||
) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{formatDate(i.issue_date)}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{formatDate(i.due_date)}</td>
|
||||
<td className="px-4 py-3"><Badge variant="outline" className={statusBadgeClass(i.status)}>{i.status}</Badge></td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(i.total)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(balance)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card className="border-border/60">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</div>
|
||||
<div className="font-serif text-2xl mt-1">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
-- Invoice line items: allow grouping by case + manual editing
|
||||
CREATE TABLE public.invoice_line_items (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
invoice_id uuid NOT NULL REFERENCES public.invoices(id) ON DELETE CASCADE,
|
||||
case_id uuid REFERENCES public.cases(id) ON DELETE SET NULL,
|
||||
kind text NOT NULL DEFAULT 'manual', -- 'time' | 'expense' | 'manual'
|
||||
description text NOT NULL DEFAULT '',
|
||||
work_date date,
|
||||
quantity numeric NOT NULL DEFAULT 1,
|
||||
rate numeric NOT NULL DEFAULT 0,
|
||||
amount numeric NOT NULL DEFAULT 0,
|
||||
time_entry_id uuid REFERENCES public.time_entries(id) ON DELETE SET NULL,
|
||||
expense_id uuid REFERENCES public.expenses(id) ON DELETE SET NULL,
|
||||
user_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_invoice_line_items_invoice ON public.invoice_line_items(invoice_id);
|
||||
CREATE INDEX idx_invoice_line_items_case ON public.invoice_line_items(case_id);
|
||||
|
||||
ALTER TABLE public.invoice_line_items ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "ili_select" ON public.invoice_line_items FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_line_items.invoice_id
|
||||
AND (public.is_admin(auth.uid()) OR i.created_by = auth.uid()
|
||||
OR (i.case_id IS NOT NULL AND public.can_access_case(i.case_id, auth.uid())))
|
||||
));
|
||||
|
||||
CREATE POLICY "ili_insert" ON public.invoice_line_items FOR INSERT TO authenticated
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_line_items.invoice_id
|
||||
AND (public.is_admin(auth.uid()) OR i.created_by = auth.uid()
|
||||
OR (i.case_id IS NOT NULL AND public.can_access_case(i.case_id, auth.uid())))
|
||||
));
|
||||
|
||||
CREATE POLICY "ili_update" ON public.invoice_line_items FOR UPDATE TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_line_items.invoice_id
|
||||
AND (public.is_admin(auth.uid()) OR i.created_by = auth.uid()
|
||||
OR (i.case_id IS NOT NULL AND public.can_access_case(i.case_id, auth.uid())))
|
||||
));
|
||||
|
||||
CREATE POLICY "ili_delete" ON public.invoice_line_items FOR DELETE TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_line_items.invoice_id
|
||||
AND (public.is_admin(auth.uid()) OR i.created_by = auth.uid()
|
||||
OR (i.case_id IS NOT NULL AND public.can_access_case(i.case_id, auth.uid())))
|
||||
));
|
||||
|
||||
CREATE TRIGGER trg_ili_updated BEFORE UPDATE ON public.invoice_line_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- Invoice payments
|
||||
CREATE TABLE public.invoice_payments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
invoice_id uuid NOT NULL REFERENCES public.invoices(id) ON DELETE CASCADE,
|
||||
amount numeric NOT NULL DEFAULT 0,
|
||||
paid_on date NOT NULL DEFAULT CURRENT_DATE,
|
||||
method text,
|
||||
reference text,
|
||||
notes text,
|
||||
created_by uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_invoice_payments_invoice ON public.invoice_payments(invoice_id);
|
||||
|
||||
ALTER TABLE public.invoice_payments ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "ip_select" ON public.invoice_payments FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_payments.invoice_id
|
||||
AND (public.is_admin(auth.uid()) OR i.created_by = auth.uid()
|
||||
OR (i.case_id IS NOT NULL AND public.can_access_case(i.case_id, auth.uid())))
|
||||
));
|
||||
|
||||
CREATE POLICY "ip_insert" ON public.invoice_payments FOR INSERT TO authenticated
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_payments.invoice_id
|
||||
AND (public.is_admin(auth.uid()) OR i.created_by = auth.uid()
|
||||
OR (i.case_id IS NOT NULL AND public.can_access_case(i.case_id, auth.uid())))
|
||||
));
|
||||
|
||||
CREATE POLICY "ip_delete" ON public.invoice_payments FOR DELETE TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_payments.invoice_id
|
||||
AND (public.is_admin(auth.uid()) OR i.created_by = auth.uid()
|
||||
OR (i.case_id IS NOT NULL AND public.can_access_case(i.case_id, auth.uid())))
|
||||
));
|
||||
|
||||
-- Trigger: recompute invoice amount_paid / paid_at / status from payments
|
||||
CREATE OR REPLACE FUNCTION public.tg_recompute_invoice_payments()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_invoice_id uuid;
|
||||
v_total numeric;
|
||||
v_paid numeric;
|
||||
v_due date;
|
||||
v_current_status invoice_status;
|
||||
v_new_status invoice_status;
|
||||
v_last_paid timestamptz;
|
||||
BEGIN
|
||||
v_invoice_id := COALESCE(NEW.invoice_id, OLD.invoice_id);
|
||||
|
||||
SELECT total, due_date, status INTO v_total, v_due, v_current_status
|
||||
FROM public.invoices WHERE id = v_invoice_id;
|
||||
|
||||
SELECT COALESCE(SUM(amount), 0), MAX(created_at)
|
||||
INTO v_paid, v_last_paid
|
||||
FROM public.invoice_payments WHERE invoice_id = v_invoice_id;
|
||||
|
||||
v_new_status := v_current_status;
|
||||
IF v_current_status <> 'void' AND v_current_status <> 'draft' THEN
|
||||
IF v_paid >= v_total AND v_total > 0 THEN
|
||||
v_new_status := 'paid';
|
||||
ELSIF v_due IS NOT NULL AND v_due < CURRENT_DATE AND v_paid < v_total THEN
|
||||
v_new_status := 'overdue';
|
||||
ELSIF v_current_status = 'paid' AND v_paid < v_total THEN
|
||||
v_new_status := 'sent';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
UPDATE public.invoices
|
||||
SET amount_paid = v_paid,
|
||||
paid_at = CASE WHEN v_paid >= v_total AND v_total > 0 THEN v_last_paid ELSE NULL END,
|
||||
status = v_new_status,
|
||||
updated_at = now()
|
||||
WHERE id = v_invoice_id;
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_invoice_payments_recompute
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.invoice_payments
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_recompute_invoice_payments();
|
||||
Reference in New Issue
Block a user