Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 02:37:00 +00:00
co-authored by renee-png
parent ef7bc8adc2
commit 970a94c7d5
4 changed files with 953 additions and 0 deletions
@@ -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>
);
}
+51
View File
@@ -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,
@@ -574,3 +616,12 @@ const rootRouteChildren: RootRouteChildren = {
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+552
View File
@@ -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>
);
}
+148
View File
@@ -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>
);
}