Files
mylegal-stage-law/src/routes/invoices.index.tsx
T
2026-04-19 18:42:52 +00:00

553 lines
25 KiB
TypeScript

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: () => (
<ProtectedLayout>
<InvoicesIndex />
</ProtectedLayout>
),
});
type UnbilledStatus = "unbilled" | "marked" | "all";
function InvoicesIndex() {
const navigate = useNavigate();
const { user } = useAuth();
const [invoices, setInvoices] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [q, setQ] = useState("");
const [status, setStatus] = useState<string>("all");
const [time, setTime] = useState<any[]>([]);
const [expenses, setExpenses] = useState<any[]>([]);
const [tabsLoaded, setTabsLoaded] = useState(false);
const [timeFilter, setTimeFilter] = useState<UnbilledStatus>("unbilled");
const [expFilter, setExpFilter] = useState<UnbilledStatus>("unbilled");
const [selectedTime, setSelectedTime] = useState<Set<string>>(new Set());
const [selectedExp, setSelectedExp] = useState<Set<string>>(new Set());
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set());
const [working, setWorking] = useState(false);
// Map of placeholder invoice id -> true (placeholder = MARKED-INVOICED-* or IMPORT-PREBILLED-*)
const [placeholderIds, setPlaceholderIds] = useState<Set<string>>(new Set());
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);
// Track placeholder invoices so we can identify "marked-invoiced" items
const placeholders = new Set<string>(
(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);
})();
}, []);
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 = 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]);
// 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<string>, 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<string>,
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<string, { caseId: string; rowIds: string[] }>();
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
const { data: inv } = await supabase
.from("invoices")
.select("*, client:clients(id, name), case:cases(id, case_number, title)")
.order("created_at", { ascending: false });
setInvoices(inv ?? []);
setPlaceholderIds(new Set(
(inv ?? [])
.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),
));
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);
}
};
return (
<PageContainer>
<PageHeader
title="Invoices"
description="All client invoices across the firm"
actions={
<Button onClick={() => navigate({ to: "/invoices/new" })}>
<FilePlus className="h-4 w-4 mr-2" /> New invoice
</Button>
}
/>
<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>
<Tabs defaultValue="invoices">
<TabsList>
<TabsTrigger value="invoices">
<Receipt className="h-4 w-4 mr-1.5" /> Invoices
</TabsTrigger>
<TabsTrigger value="time">
<Clock className="h-4 w-4 mr-1.5" /> Time
</TabsTrigger>
<TabsTrigger value="expenses">
<DollarSign className="h-4 w-4 mr-1.5" /> Expenses
</TabsTrigger>
</TabsList>
<TabsContent value="invoices" className="mt-4">
<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. Click <strong>New invoice</strong> to generate one.
</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>
</TabsContent>
<TabsContent value="time" className="mt-4">
<UnbilledToolbar
label="time entries"
filter={timeFilter}
setFilter={setTimeFilter}
count={visibleTime.length}
total={visibleTimeTotal}
selectedCount={selectedTime.size}
working={working}
onMarkInvoiced={() => bulkUpdate("time_entries", visibleTime, selectedTime, true)}
onMarkUnbilled={() => bulkUpdate("time_entries", visibleTime, selectedTime, false)}
onClearSel={() => setSelectedTime(new Set())}
/>
<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="px-3 py-3 w-9">
<Checkbox
checked={visibleTime.length > 0 && selectedTime.size === visibleTime.length}
onCheckedChange={(c) => setSelectedTime(c ? new Set(visibleTime.map((t) => t.id)) : new Set())}
/>
</th>
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-left px-4 py-3 font-medium">Client / Case</th>
<th className="text-left px-4 py-3 font-medium">Description</th>
<th className="text-left px-4 py-3 font-medium">User</th>
<th className="text-right px-4 py-3 font-medium">Hours</th>
<th className="text-right px-4 py-3 font-medium">Rate</th>
<th className="text-right px-4 py-3 font-medium">Amount</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
{!tabsLoaded && <tr><td colSpan={9} className="text-center py-12 text-muted-foreground">Loading…</td></tr>}
{tabsLoaded && visibleTime.length === 0 && (
<tr><td colSpan={9} className="text-center py-12 text-muted-foreground">
<Clock className="h-8 w-8 mx-auto mb-2 opacity-40" />
No matching time entries.
</td></tr>
)}
{visibleTime.map((t) => {
const amount = Number(t.hours) * Number(t.hourly_rate);
const marked = isPlaceholder(t.invoice_id);
return (
<tr key={t.id} className="border-t hover:bg-muted/30">
<td className="px-3 py-3">
<Checkbox
checked={selectedTime.has(t.id)}
onCheckedChange={() => setSelectedTime((s) => toggleSel(s, t.id))}
/>
</td>
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">{formatDate(t.work_date)}</td>
<td className="px-4 py-3">
{t.case ? (
<div className="min-w-0">
{t.case.client && (
<Link to="/clients/$clientId" params={{ clientId: t.case.client.id }} className="text-xs text-muted-foreground hover:text-primary block truncate">
{t.case.client.name}
</Link>
)}
<Link to="/cases/$caseId" params={{ caseId: t.case.id }} className="hover:text-primary truncate block">
{t.case.case_number} · {t.case.title}
</Link>
</div>
) : "—"}
</td>
<td className="px-4 py-3 text-muted-foreground max-w-md truncate">{t.description}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{t.profile?.full_name || t.profile?.email || "—"}</td>
<td className="px-4 py-3 text-right tabular-nums">{Number(t.hours).toFixed(2)}</td>
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{formatCurrency(t.hourly_rate)}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(amount)}</td>
<td className="px-4 py-3 text-xs">
{marked ? (
<Badge variant="outline" className="text-muted-foreground">Marked invoiced</Badge>
) : (
<Badge variant="outline">Unbilled</Badge>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="expenses" className="mt-4">
<UnbilledToolbar
label="expenses"
filter={expFilter}
setFilter={setExpFilter}
count={visibleExp.length}
total={visibleExpTotal}
selectedCount={selectedExp.size}
working={working}
onMarkInvoiced={() => bulkUpdate("expenses", visibleExp, selectedExp, true)}
onMarkUnbilled={() => bulkUpdate("expenses", visibleExp, selectedExp, false)}
onClearSel={() => setSelectedExp(new Set())}
/>
<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="px-3 py-3 w-9">
<Checkbox
checked={visibleExp.length > 0 && selectedExp.size === visibleExp.length}
onCheckedChange={(c) => setSelectedExp(c ? new Set(visibleExp.map((e) => e.id)) : new Set())}
/>
</th>
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-left px-4 py-3 font-medium">Client / Case</th>
<th className="text-left px-4 py-3 font-medium">Description</th>
<th className="text-left px-4 py-3 font-medium">User</th>
<th className="text-right px-4 py-3 font-medium">Amount</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
{!tabsLoaded && <tr><td colSpan={7} className="text-center py-12 text-muted-foreground">Loading…</td></tr>}
{tabsLoaded && visibleExp.length === 0 && (
<tr><td colSpan={7} className="text-center py-12 text-muted-foreground">
<DollarSign className="h-8 w-8 mx-auto mb-2 opacity-40" />
No matching expenses.
</td></tr>
)}
{visibleExp.map((e) => {
const marked = isPlaceholder(e.invoice_id);
return (
<tr key={e.id} className="border-t hover:bg-muted/30">
<td className="px-3 py-3">
<Checkbox
checked={selectedExp.has(e.id)}
onCheckedChange={() => setSelectedExp((s) => toggleSel(s, e.id))}
/>
</td>
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">{formatDate(e.expense_date)}</td>
<td className="px-4 py-3">
{e.case ? (
<div className="min-w-0">
{e.case.client && (
<Link to="/clients/$clientId" params={{ clientId: e.case.client.id }} className="text-xs text-muted-foreground hover:text-primary block truncate">
{e.case.client.name}
</Link>
)}
<Link to="/cases/$caseId" params={{ caseId: e.case.id }} className="hover:text-primary truncate block">
{e.case.case_number} · {e.case.title}
</Link>
</div>
) : "—"}
</td>
<td className="px-4 py-3 text-muted-foreground max-w-md truncate">{e.description}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{e.profile?.full_name || e.profile?.email || "—"}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(e.amount)}</td>
<td className="px-4 py-3 text-xs">
{marked ? (
<Badge variant="outline" className="text-muted-foreground">Marked invoiced</Badge>
) : (
<Badge variant="outline">Unbilled</Badge>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</PageContainer>
);
}
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 (
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-3">
<Select value={filter} onValueChange={(v) => setFilter(v as UnbilledStatus)}>
<SelectTrigger className="w-[200px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="unbilled">Unbilled only</SelectItem>
<SelectItem value="marked">Marked invoiced</SelectItem>
<SelectItem value="all">All (unbilled + marked)</SelectItem>
</SelectContent>
</Select>
<div className="text-sm text-muted-foreground">{count} {label}</div>
</div>
<div className="flex items-center gap-2">
{selectedCount > 0 && (
<>
<span className="text-xs text-muted-foreground">{selectedCount} selected</span>
<Button variant="outline" size="sm" disabled={working} onClick={onMarkInvoiced}>
<CheckCircle2 className="h-3.5 w-3.5 mr-1.5" /> Mark invoiced
</Button>
<Button variant="outline" size="sm" disabled={working} onClick={onMarkUnbilled}>
<Undo2 className="h-3.5 w-3.5 mr-1.5" /> Mark unbilled
</Button>
<Button variant="ghost" size="sm" onClick={onClearSel}>Clear</Button>
</>
)}
<div className="font-serif text-xl tabular-nums ml-3">{formatCurrency(total)}</div>
</div>
</div>
);
}
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>
);
}