Files
mylegal-stage-law/src/routes/invoices.index.tsx
T
2026-04-19 20:05:48 +00:00

732 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
const PAGE_SIZE = 100;
const UNBILLED_PAGE_SIZE = 1000;
async function fetchAllPages<T>(
loader: (from: number, to: number) => Promise<{ data: T[] | null; error: { message: string } | null }>,
) {
const rows: T[] = [];
let from = 0;
while (true) {
const to = from + UNBILLED_PAGE_SIZE - 1;
const { data, error } = await loader(from, to);
if (error) throw new Error(error.message);
const batch = data ?? [];
rows.push(...batch);
if (batch.length < UNBILLED_PAGE_SIZE) break;
from += UNBILLED_PAGE_SIZE;
}
return rows;
}
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 [page, setPage] = useState(0);
const [totalCount, setTotalCount] = useState(0);
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);
const [timePage, setTimePage] = useState(0);
const [expPage, setExpPage] = useState(0);
const TAB_PAGE_SIZE = 200;
// Map of placeholder invoice id -> true (placeholder = MARKED-INVOICED-* or IMPORT-PREBILLED-*)
const [placeholderIds, setPlaceholderIds] = useState<Set<string>>(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<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);
};
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 () => {
try {
setTabsLoaded(false);
const [timeRows, expenseRows] = await Promise.all([
fetchAllPages<any>(async (from, to) => {
const result = await 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))")
.eq("billable", true)
.order("work_date", { ascending: false })
.range(from, to);
return { data: result.data, error: result.error ? { message: result.error.message } : null };
}),
fetchAllPages<any>(async (from, to) => {
const result = await 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))")
.eq("billable", true)
.order("expense_date", { ascending: false })
.range(from, to);
return { data: result.data, error: result.error ? { message: result.error.message } : null };
}),
]);
const userIds = Array.from(new Set([
...timeRows.map((row) => row.user_id).filter(Boolean),
...expenseRows.map((row) => row.user_id).filter(Boolean),
]));
let profileMap: Record<string, any> = {};
if (userIds.length) {
const { data: profiles, error: profilesError } = await supabase
.from("profiles")
.select("id, full_name, email")
.in("id", userIds);
if (profilesError) throw new Error(profilesError.message);
profileMap = Object.fromEntries((profiles ?? []).map((profile: any) => [profile.id, profile]));
}
setTime(timeRows.map((row) => ({ ...row, profile: row.user_id ? profileMap[row.user_id] ?? null : null })));
setExpenses(expenseRows.map((row) => ({ ...row, profile: row.user_id ? profileMap[row.user_id] ?? null : null })));
} catch (error: any) {
toast.error(error?.message ?? "Could not load unbilled items");
setTime([]);
setExpenses([]);
} finally {
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],
);
// Reset to page 0 when filter or underlying data changes
useEffect(() => { setTimePage(0); }, [timeFilter, time.length]);
useEffect(() => { setExpPage(0); }, [expFilter, expenses.length]);
const timeTotalPages = Math.max(1, Math.ceil(visibleTime.length / TAB_PAGE_SIZE));
const expTotalPages = Math.max(1, Math.ceil(visibleExp.length / TAB_PAGE_SIZE));
const pagedTime = useMemo(
() => visibleTime.slice(timePage * TAB_PAGE_SIZE, (timePage + 1) * TAB_PAGE_SIZE),
[visibleTime, timePage],
);
const pagedExp = useMemo(
() => visibleExp.slice(expPage * TAB_PAGE_SIZE, (expPage + 1) * TAB_PAGE_SIZE),
[visibleExp, expPage],
);
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
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 (
<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={(v) => { setPage(0); setStatus(v); }}>
<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">
{selectedInvoices.size > 0 && (
<div className="flex items-center justify-between gap-2 px-4 py-2 bg-muted/40 border-b">
<span className="text-xs text-muted-foreground">{selectedInvoices.size} selected</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={working} onClick={() => bulkInvoiceAction("sent")}>
<Send className="h-3.5 w-3.5 mr-1.5" /> Mark sent
</Button>
<Button variant="outline" size="sm" disabled={working} onClick={() => bulkInvoiceAction("void")}>
<Ban className="h-3.5 w-3.5 mr-1.5" /> Mark void
</Button>
<Button variant="outline" size="sm" disabled={working} onClick={() => bulkInvoiceAction("delete")} className="text-destructive hover:text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-1.5" /> Delete
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedInvoices(new Set())}>Clear</Button>
</div>
</div>
)}
<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={filtered.length > 0 && selectedInvoices.size === filtered.length}
onCheckedChange={(c) => setSelectedInvoices(c ? new Set(filtered.map((i) => i.id)) : new Set())}
/>
</th>
<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={8} className="text-center py-12 text-muted-foreground">Loading…</td></tr>}
{!loading && filtered.length === 0 && (
<tr><td colSpan={8} 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-3 py-3" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedInvoices.has(i.id)}
onCheckedChange={() => setSelectedInvoices((s) => toggleSel(s, i.id))}
/>
</td>
<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>
{totalCount > PAGE_SIZE && (
<div className="flex items-center justify-between mt-3 text-sm">
<span className="text-muted-foreground">
Showing {totalCount === 0 ? 0 : page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, totalCount)} of {totalCount}
</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page === 0 || loading} onClick={() => setPage((p) => Math.max(0, p - 1))}>
Previous
</Button>
<span className="text-muted-foreground">Page {page + 1} of {totalPages}</span>
<Button variant="outline" size="sm" disabled={page + 1 >= totalPages || loading} onClick={() => setPage((p) => p + 1)}>
Next
</Button>
</div>
</div>
)}
</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={pagedTime.length > 0 && pagedTime.every((t) => selectedTime.has(t.id))}
onCheckedChange={(c) => setSelectedTime((prev) => {
const next = new Set(prev);
if (c) pagedTime.forEach((t) => next.add(t.id));
else pagedTime.forEach((t) => next.delete(t.id));
return next;
})}
/>
</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>
)}
{pagedTime.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>
{visibleTime.length > TAB_PAGE_SIZE && (
<div className="flex items-center justify-between mt-3 text-sm">
<span className="text-muted-foreground">
Showing {timePage * TAB_PAGE_SIZE + 1}–{Math.min((timePage + 1) * TAB_PAGE_SIZE, visibleTime.length)} of {visibleTime.length}
</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={timePage === 0} onClick={() => setTimePage((p) => Math.max(0, p - 1))}>Previous</Button>
<span className="text-muted-foreground">Page {timePage + 1} of {timeTotalPages}</span>
<Button variant="outline" size="sm" disabled={timePage + 1 >= timeTotalPages} onClick={() => setTimePage((p) => p + 1)}>Next</Button>
</div>
</div>
)}
</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={pagedExp.length > 0 && pagedExp.every((e) => selectedExp.has(e.id))}
onCheckedChange={(c) => setSelectedExp((prev) => {
const next = new Set(prev);
if (c) pagedExp.forEach((e) => next.add(e.id));
else pagedExp.forEach((e) => next.delete(e.id));
return next;
})}
/>
</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>
)}
{pagedExp.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>
{visibleExp.length > TAB_PAGE_SIZE && (
<div className="flex items-center justify-between mt-3 text-sm">
<span className="text-muted-foreground">
Showing {expPage * TAB_PAGE_SIZE + 1}–{Math.min((expPage + 1) * TAB_PAGE_SIZE, visibleExp.length)} of {visibleExp.length}
</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={expPage === 0} onClick={() => setExpPage((p) => Math.max(0, p - 1))}>Previous</Button>
<span className="text-muted-foreground">Page {expPage + 1} of {expTotalPages}</span>
<Button variant="outline" size="sm" disabled={expPage + 1 >= expTotalPages} onClick={() => setExpPage((p) => p + 1)}>Next</Button>
</div>
</div>
)}
</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>
);
}