import { createFileRoute, useNavigate, Outlet, useMatches } 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 { supabase } from "@/integrations/supabase/client"; import { Search, ArrowLeft } from "lucide-react"; import { formatCurrency } from "@/lib/format"; export const Route = createFileRoute("/invoices/new")({ component: NewInvoiceLayout, }); function NewInvoiceLayout() { // If a child route (e.g. /invoices/new/$clientId) is matched, render only it. const matches = useMatches(); const hasChild = matches.some((m) => m.routeId !== "/invoices/new" && m.routeId.startsWith("/invoices/new/")); if (hasChild) return ; return ( ); } interface ClientLite { id: string; name: string; unbilledTotal: number; unbilledTimeAmount: number; unbilledExpenseAmount: number; caseCount: number; } function NewInvoicePickClient() { const navigate = useNavigate(); const [clients, setClients] = useState([]); const [loading, setLoading] = useState(true); const [q, setQ] = useState(""); useEffect(() => { (async () => { setLoading(true); 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, }; }); 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); })(); }, []); const filtered = useMemo(() => { if (!q) return clients; const s = q.toLowerCase(); return clients.filter((c) => c.name.toLowerCase().includes(s)); }, [clients, q]); return ( navigate({ to: "/invoices" })}> Back to invoices } />
setQ(e.target.value)} />
{loading &&
Loading clients…
} {!loading && filtered.length === 0 && (
No matching clients.
)} {!loading && filtered.map((c) => ( ))}
); }