diff --git a/src/routes/invoices.index.tsx b/src/routes/invoices.index.tsx index b216abc..5f8a165 100644 --- a/src/routes/invoices.index.tsx +++ b/src/routes/invoices.index.tsx @@ -5,10 +5,13 @@ 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 } from "lucide-react"; +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: () => ( @@ -18,12 +21,25 @@ export const Route = createFileRoute("/invoices/")({ ), }); +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 @@ -56,7 +72,15 @@ function InvoicesIndex() { return ( - + setPickerOpen(true)}> + New invoice + + } + />
@@ -101,7 +125,7 @@ function InvoicesIndex() { {!loading && filtered.length === 0 && ( - No invoices yet. Generate one from a Client or Case page. + No invoices yet. Click New invoice to generate one. )} {filtered.map((i) => { @@ -132,6 +156,24 @@ function InvoicesIndex() { + + { + setPickerOpen(false); + setPickedClient(c); + }} + /> + + {pickedClient && ( + { if (!b) setPickedClient(null); }} + clientId={pickedClient.id} + clientName={pickedClient.name} + /> + )} ); } @@ -146,3 +188,166 @@ function Stat({ label, value }: { label: string; value: string }) { ); } + +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. +

+
+ + + +
+
+ ); +}