import { createFileRoute, Link, useNavigate } 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 { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { supabase } from "@/integrations/supabase/client"; import { Receipt, Search, FilePlus, Clock, DollarSign, CheckCircle2, Undo2, Trash2, Send, Ban } from "lucide-react"; import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format"; import { ensureMarkedInvoicedPlaceholder } from "@/lib/invoice-generation"; import { useAuth } from "@/lib/auth"; import { toast } from "sonner"; export const Route = createFileRoute("/invoices/")({ component: () => ( ), }); type UnbilledStatus = "unbilled" | "marked" | "all"; const PAGE_SIZE = 100; function InvoicesIndex() { const navigate = useNavigate(); const { user } = useAuth(); const [invoices, setInvoices] = useState([]); const [loading, setLoading] = useState(true); const [q, setQ] = useState(""); const [status, setStatus] = useState("all"); const [page, setPage] = useState(0); const [totalCount, setTotalCount] = useState(0); const [time, setTime] = useState([]); const [expenses, setExpenses] = useState([]); const [tabsLoaded, setTabsLoaded] = useState(false); const [timeFilter, setTimeFilter] = useState("unbilled"); const [expFilter, setExpFilter] = useState("unbilled"); const [selectedTime, setSelectedTime] = useState>(new Set()); const [selectedExp, setSelectedExp] = useState>(new Set()); const [selectedInvoices, setSelectedInvoices] = useState>(new Set()); const [working, setWorking] = useState(false); // Map of placeholder invoice id -> true (placeholder = MARKED-INVOICED-* or IMPORT-PREBILLED-*) const [placeholderIds, setPlaceholderIds] = useState>(new Set()); const fetchInvoices = async () => { setLoading(true); let query = supabase .from("invoices") .select("*, client:clients(id, name), case:cases(id, case_number, title)", { count: "exact" }) .order("issue_date", { ascending: false, nullsFirst: false }) .order("created_at", { ascending: false }); if (status !== "all") query = query.eq("status", status as any); if (q.trim()) { const s = q.trim().replace(/[%,]/g, " "); query = query.or(`invoice_number.ilike.%${s}%`); } const from = page * PAGE_SIZE; const to = from + PAGE_SIZE - 1; const { data, count } = await query.range(from, to); setInvoices(data ?? []); setTotalCount(count ?? 0); setLoading(false); const placeholders = new Set( (data ?? []) .filter((i: any) => typeof i.invoice_number === "string" && (i.invoice_number.startsWith("MARKED-INVOICED-") || i.invoice_number.startsWith("IMPORT-PREBILLED-")) ) .map((i: any) => i.id), ); setPlaceholderIds(placeholders); }; useEffect(() => { fetchInvoices(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [page, status]); // Debounce search useEffect(() => { const t = setTimeout(() => { setPage(0); fetchInvoices(); }, 300); return () => clearTimeout(t); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [q]); const loadUnbilled = async () => { const [{ data: t }, { data: e }] = await Promise.all([ supabase .from("time_entries") .select("id, work_date, description, hours, hourly_rate, billable, user_id, invoice_id, case_id, case:cases(id, case_number, title, client_id, client:clients(id, name)), profile:profiles!time_entries_user_id_fkey(id, full_name, email)") .eq("billable", true) .order("work_date", { ascending: false }) .limit(2000), supabase .from("expenses") .select("id, expense_date, description, amount, billable, user_id, invoice_id, case_id, case:cases(id, case_number, title, client_id, client:clients(id, name)), profile:profiles!expenses_user_id_fkey(id, full_name, email)") .eq("billable", true) .order("expense_date", { ascending: false }) .limit(2000), ]); setTime(t ?? []); setExpenses(e ?? []); setTabsLoaded(true); }; useEffect(() => { loadUnbilled(); }, []); const filtered = invoices; 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: totalCount }; }, [filtered, totalCount]); const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); // Classify each row: unbilled (no invoice_id), marked (linked to placeholder), real (linked to real invoice — not shown) const isPlaceholder = (invId: string | null) => !!invId && placeholderIds.has(invId); const rowStatus = (invId: string | null): UnbilledStatus => !invId ? "unbilled" : isPlaceholder(invId) ? "marked" : "all"; // "all" sentinel for real-invoice rows we hide const visibleTime = useMemo(() => { return time.filter((t) => { const s = rowStatus(t.invoice_id); if (s === "all" /* real invoice */) return false; if (timeFilter === "all") return true; return s === timeFilter; }); }, [time, timeFilter, placeholderIds]); const visibleExp = useMemo(() => { return expenses.filter((e) => { const s = rowStatus(e.invoice_id); if (s === "all") return false; if (expFilter === "all") return true; return s === expFilter; }); }, [expenses, expFilter, placeholderIds]); const visibleTimeTotal = useMemo( () => visibleTime.reduce((s, t) => s + Number(t.hours) * Number(t.hourly_rate), 0), [visibleTime], ); const visibleExpTotal = useMemo( () => visibleExp.reduce((s, e) => s + Number(e.amount), 0), [visibleExp], ); const toggleSel = (set: Set, id: string) => { const next = new Set(set); if (next.has(id)) next.delete(id); else next.add(id); return next; }; const bulkUpdate = async ( table: "time_entries" | "expenses", rows: any[], ids: Set, markInvoiced: boolean, ) => { if (!user?.id) { toast.error("Not signed in"); return; } if (ids.size === 0) { toast.error("Select at least one item"); return; } setWorking(true); try { if (markInvoiced) { // Group by client_id; create/find placeholder per client. const byClient = new Map(); const skipped: string[] = []; for (const r of rows) { if (!ids.has(r.id)) continue; const cid = r.case?.client_id ?? r.case?.client?.id ?? null; if (!cid || !r.case_id) { skipped.push(r.id); continue; } const slot = byClient.get(cid); if (slot) slot.rowIds.push(r.id); else byClient.set(cid, { caseId: r.case_id, rowIds: [r.id] }); } for (const [cid, slot] of byClient) { const invId = await ensureMarkedInvoicedPlaceholder(cid, slot.caseId, user.id); const { error } = await supabase.from(table).update({ invoice_id: invId }).in("id", slot.rowIds); if (error) throw error; } if (skipped.length) toast.warning(`${skipped.length} item(s) skipped — missing client/case`); toast.success(`Marked ${ids.size - skipped.length} item(s) as invoiced`); } else { const idArr = Array.from(ids); const { error } = await supabase.from(table).update({ invoice_id: null }).in("id", idArr); if (error) throw error; toast.success(`Marked ${idArr.length} item(s) as unbilled`); } // Refetch invoices to refresh placeholder set + unbilled lists await fetchInvoices(); await loadUnbilled(); if (table === "time_entries") setSelectedTime(new Set()); else setSelectedExp(new Set()); } catch (err: any) { toast.error(err?.message ?? "Bulk update failed"); } finally { setWorking(false); } }; const refreshInvoices = async () => { await fetchInvoices(); }; const bulkInvoiceAction = async (action: "delete" | "sent" | "void") => { if (selectedInvoices.size === 0) { toast.error("Select at least one invoice"); return; } const ids = Array.from(selectedInvoices); const verb = action === "delete" ? "delete" : `mark as ${action}`; if (!confirm(`${verb.charAt(0).toUpperCase() + verb.slice(1)} ${ids.length} invoice(s)?`)) return; setWorking(true); try { if (action === "delete") { const { error } = await supabase.from("invoices").delete().in("id", ids); if (error) throw error; toast.success(`Deleted ${ids.length} invoice(s)`); } else { const { error } = await supabase.from("invoices").update({ status: action }).in("id", ids); if (error) throw error; toast.success(`Marked ${ids.length} invoice(s) as ${action}`); } setSelectedInvoices(new Set()); await refreshInvoices(); } catch (err: any) { toast.error(err?.message ?? "Bulk action failed"); } finally { setWorking(false); } }; return ( navigate({ to: "/invoices/new" })}> New invoice } />
Invoices Time Expenses
setQ(e.target.value)} />
{selectedInvoices.size > 0 && (
{selectedInvoices.size} selected
)} {loading && } {!loading && filtered.length === 0 && ( )} {filtered.map((i) => { const balance = Number(i.total) - Number(i.amount_paid); return ( ); })}
0 && selectedInvoices.size === filtered.length} onCheckedChange={(c) => setSelectedInvoices(c ? new Set(filtered.map((i) => i.id)) : new Set())} /> Invoice # Client Issued Due Status Total Balance
Loading…
No invoices yet. Click New invoice to generate one.
e.stopPropagation()}> setSelectedInvoices((s) => toggleSel(s, i.id))} /> {i.invoice_number} {i.client ? ( {i.client.name} ) : "—"} {formatDate(i.issue_date)} {formatDate(i.due_date)} {i.status} {formatCurrency(i.total)} {formatCurrency(balance)}
{totalCount > PAGE_SIZE && (
Showing {totalCount === 0 ? 0 : page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, totalCount)} of {totalCount}
Page {page + 1} of {totalPages}
)}
bulkUpdate("time_entries", visibleTime, selectedTime, true)} onMarkUnbilled={() => bulkUpdate("time_entries", visibleTime, selectedTime, false)} onClearSel={() => setSelectedTime(new Set())} /> {!tabsLoaded && } {tabsLoaded && visibleTime.length === 0 && ( )} {visibleTime.map((t) => { const amount = Number(t.hours) * Number(t.hourly_rate); const marked = isPlaceholder(t.invoice_id); return ( ); })}
0 && selectedTime.size === visibleTime.length} onCheckedChange={(c) => setSelectedTime(c ? new Set(visibleTime.map((t) => t.id)) : new Set())} /> Date Client / Case Description User Hours Rate Amount Status
Loading…
No matching time entries.
setSelectedTime((s) => toggleSel(s, t.id))} /> {formatDate(t.work_date)} {t.case ? (
{t.case.client && ( {t.case.client.name} )} {t.case.case_number} · {t.case.title}
) : "—"}
{t.description} {t.profile?.full_name || t.profile?.email || "—"} {Number(t.hours).toFixed(2)} {formatCurrency(t.hourly_rate)} {formatCurrency(amount)} {marked ? ( Marked invoiced ) : ( Unbilled )}
bulkUpdate("expenses", visibleExp, selectedExp, true)} onMarkUnbilled={() => bulkUpdate("expenses", visibleExp, selectedExp, false)} onClearSel={() => setSelectedExp(new Set())} /> {!tabsLoaded && } {tabsLoaded && visibleExp.length === 0 && ( )} {visibleExp.map((e) => { const marked = isPlaceholder(e.invoice_id); return ( ); })}
0 && selectedExp.size === visibleExp.length} onCheckedChange={(c) => setSelectedExp(c ? new Set(visibleExp.map((e) => e.id)) : new Set())} /> Date Client / Case Description User Amount Status
Loading…
No matching expenses.
setSelectedExp((s) => toggleSel(s, e.id))} /> {formatDate(e.expense_date)} {e.case ? (
{e.case.client && ( {e.case.client.name} )} {e.case.case_number} · {e.case.title}
) : "—"}
{e.description} {e.profile?.full_name || e.profile?.email || "—"} {formatCurrency(e.amount)} {marked ? ( Marked invoiced ) : ( Unbilled )}
); } function UnbilledToolbar({ label, filter, setFilter, count, total, selectedCount, working, onMarkInvoiced, onMarkUnbilled, onClearSel, }: { label: string; filter: UnbilledStatus; setFilter: (v: UnbilledStatus) => void; count: number; total: number; selectedCount: number; working: boolean; onMarkInvoiced: () => void; onMarkUnbilled: () => void; onClearSel: () => void; }) { return (
{count} {label}
{selectedCount > 0 && ( <> {selectedCount} selected )}
{formatCurrency(total)}
); } function Stat({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); }