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 { Button } from "@/components/ui/button"; 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 { Receipt, Search, FilePlus } from "lucide-react"; import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format"; import { GenerateInvoiceDialog } from "@/components/invoices/generate-invoice-dialog"; export const Route = createFileRoute("/invoices/")({ component: () => ( ), }); interface ClientLite { id: string; name: string; unbilledTotal: number; unbilledTimeAmount: number; unbilledExpenseAmount: number; caseCount: number; } function InvoicesIndex() { const [invoices, setInvoices] = useState([]); const [loading, setLoading] = useState(true); const [q, setQ] = useState(""); const [status, setStatus] = useState("all"); // New-invoice flow: first pick a client, then open the generate dialog. const [pickerOpen, setPickerOpen] = useState(false); const [pickedClient, setPickedClient] = useState<{ id: string; name: string } | null>(null); 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 ( setPickerOpen(true)}> New invoice } />
setQ(e.target.value)} />
{loading && } {!loading && filtered.length === 0 && ( )} {filtered.map((i) => { const balance = Number(i.total) - Number(i.amount_paid); return ( ); })}
Invoice # Client Issued Due Status Total Balance
Loading…
No invoices yet. Click New invoice to generate one.
{i.invoice_number} {i.client ? ( {i.client.name} ) : "—"} {formatDate(i.issue_date)} {formatDate(i.due_date)} {i.status} {formatCurrency(i.total)} {formatCurrency(balance)}
{ setPickerOpen(false); setPickedClient(c); }} /> {pickedClient && ( { if (!b) setPickedClient(null); }} clientId={pickedClient.id} clientName={pickedClient.name} /> )}
); } function Stat({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } function ClientPickerDialog({ open, onOpenChange, onPick, }: { open: boolean; onOpenChange: (b: boolean) => void; onPick: (c: { id: string; name: string }) => void; }) { const [clients, setClients] = useState([]); const [loading, setLoading] = useState(false); const [q, setQ] = useState(""); useEffect(() => { if (!open) return; (async () => { setLoading(true); // Pull every active client and their cases, then aggregate unbilled time/expenses. const { data: cs } = await supabase .from("clients") .select("id, name") .is("archived_at", null) .order("name", { ascending: true }); const clientList = cs ?? []; if (clientList.length === 0) { setClients([]); setLoading(false); return; } const { data: cases } = await supabase .from("cases") .select("id, client_id") .is("archived_at", null) .in("client_id", clientList.map((c) => c.id)); const caseToClient = new Map(); const clientCaseCount = new Map(); for (const c of cases ?? []) { if (!c.client_id) continue; caseToClient.set(c.id, c.client_id); clientCaseCount.set(c.client_id, (clientCaseCount.get(c.client_id) ?? 0) + 1); } const caseIds = Array.from(caseToClient.keys()); const tally = new Map(); if (caseIds.length > 0) { 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), ]); for (const t of time ?? []) { const cid = caseToClient.get(t.case_id); if (!cid) continue; const cur = tally.get(cid) ?? { time: 0, expense: 0 }; cur.time += Number(t.hours) * Number(t.hourly_rate); tally.set(cid, cur); } for (const e of exp ?? []) { const cid = caseToClient.get(e.case_id); if (!cid) continue; const cur = tally.get(cid) ?? { time: 0, expense: 0 }; cur.expense += Number(e.amount); tally.set(cid, cur); } } const enriched: ClientLite[] = clientList.map((c) => { const t = tally.get(c.id) ?? { time: 0, expense: 0 }; return { id: c.id, name: c.name, unbilledTimeAmount: t.time, unbilledExpenseAmount: t.expense, unbilledTotal: t.time + t.expense, caseCount: clientCaseCount.get(c.id) ?? 0, }; }); // Sort: clients with unbilled work first, then alphabetical. enriched.sort((a, b) => { if ((b.unbilledTotal > 0 ? 1 : 0) !== (a.unbilledTotal > 0 ? 1 : 0)) { return (b.unbilledTotal > 0 ? 1 : 0) - (a.unbilledTotal > 0 ? 1 : 0); } if (b.unbilledTotal !== a.unbilledTotal) return b.unbilledTotal - a.unbilledTotal; return a.name.localeCompare(b.name); }); setClients(enriched); setLoading(false); })(); }, [open]); const filtered = useMemo(() => { if (!q) return clients; const s = q.toLowerCase(); return clients.filter((c) => c.name.toLowerCase().includes(s)); }, [clients, q]); return ( New invoice — choose a client
setQ(e.target.value)} />
{loading &&
Loading clients…
} {!loading && filtered.length === 0 && (
No matching clients.
)} {!loading && filtered.map((c) => ( ))}

Pick a client to see a checklist of their cases and a summary of unbilled time and expenses on each.

); }