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 { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Checkbox } from "@/components/ui/checkbox"; import { SearchableSelect } from "@/components/ui/searchable-select"; import { supabase } from "@/integrations/supabase/client"; import { Plus, Search, Archive, ArchiveRestore, ArrowUp, ArrowDown, ArrowUpDown, Users } from "lucide-react"; import { formatDate, statusBadgeClass } from "@/lib/format"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { setArchived } from "@/lib/archive"; import { NewCaseDialog } from "@/components/cases/new-case-dialog"; import { BulkConvertToCollectionsDialog } from "@/components/cases/bulk-convert-to-collections-dialog"; import { StatusFlagBadges } from "@/components/collections/status-flag-badges"; import { formatDistanceToNow } from "date-fns"; import { toast } from "sonner"; type SortKey = "title" | "client" | "practice_area" | "status" | "attorney" | "opened_at" | "last_activity"; type SortDir = "asc" | "desc"; export const Route = createFileRoute("/cases/")({ component: () => ( ), }); function CasesList() { const navigate = useNavigate(); const [cases, setCases] = useState([]); const [users, setUsers] = useState([]); const [clientList, setClientList] = useState([]); const [q, setQ] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [view, setView] = useState<"active" | "archived">("active"); const [newOpen, setNewOpen] = useState(false); const [bulkCollectionsOpen, setBulkCollectionsOpen] = useState(false); const [sortKey, setSortKey] = useState("last_activity"); const [sortDir, setSortDir] = useState("desc"); const [selected, setSelected] = useState>(new Set()); const [assignAttorney, setAssignAttorney] = useState(""); const [assignClient, setAssignClient] = useState(""); const [assignStatus, setAssignStatus] = useState(""); const [busy, setBusy] = useState(false); const [page, setPage] = useState(1); const PAGE_SIZE = 150; const load = async () => { const { data: caseRows } = await supabase .from("cases") .select("*, client:clients(name), assignee:profiles!cases_assigned_attorney_id_fkey(full_name, email)") .order("updated_at", { ascending: false }); const baseRows = caseRows ?? []; // Aggregate latest activity from related child tables. const [ tasksRes, timeRes, expensesRes, docsRes, callRes, commentsRes, invoicesRes, eventsRes, collectionsRes, ] = await Promise.all([ supabase.from("tasks").select("case_id, updated_at").not("case_id", "is", null).limit(20000), supabase.from("time_entries").select("case_id, updated_at").limit(20000), supabase.from("expenses").select("case_id, updated_at").limit(20000), supabase.from("documents").select("case_id, created_at").limit(20000), supabase.from("call_logs").select("case_id, updated_at").limit(20000), supabase.from("comments").select("case_id, created_at").not("case_id", "is", null).limit(20000), supabase.from("invoices").select("case_id, updated_at").not("case_id", "is", null).limit(20000), supabase.from("events").select("case_id, updated_at").not("case_id", "is", null).limit(20000), supabase.from("collections").select("case_id, in_bankruptcy, on_payment_plan").limit(20000), ]); const latest: Record = {}; const ingest = (rows: any[] | null | undefined, field: "updated_at" | "created_at") => { for (const r of rows ?? []) { const cid = r.case_id as string | null; const ts = r[field] as string | null; if (!cid || !ts) continue; if (!latest[cid] || ts > latest[cid]) latest[cid] = ts; } }; ingest(tasksRes.data, "updated_at"); ingest(timeRes.data, "updated_at"); ingest(expensesRes.data, "updated_at"); ingest(docsRes.data, "created_at"); ingest(callRes.data, "updated_at"); ingest(commentsRes.data, "created_at"); ingest(invoicesRes.data, "updated_at"); ingest(eventsRes.data, "updated_at"); // Roll up BK / PP flags: case has the flag if ANY of its collections do. const flagMap: Record = {}; for (const r of collectionsRes.data ?? []) { const cid = (r as any).case_id as string | null; if (!cid) continue; const cur = flagMap[cid] ?? { bk: false, pp: false }; if ((r as any).in_bankruptcy) cur.bk = true; if ((r as any).on_payment_plan) cur.pp = true; flagMap[cid] = cur; } // Fallback to the case's own updated_at if no child activity found. const enriched = baseRows.map((c: any) => { const childTs = latest[c.id]; const lastActivity = childTs && (!c.updated_at || childTs > c.updated_at) ? childTs : c.updated_at; const flags = flagMap[c.id]; return { ...c, last_activity: lastActivity, in_bankruptcy: !!flags?.bk, on_payment_plan: !!flags?.pp }; }); setCases(enriched); }; const loadAux = async () => { const [{ data: usr }, { data: cli }] = await Promise.all([ supabase.from("profiles").select("id, full_name, email").order("full_name", { ascending: true }), supabase.from("clients").select("id, name").order("name", { ascending: true }), ]); setUsers(usr ?? []); setClientList(cli ?? []); }; useEffect(() => { load(); loadAux(); }, []); const visible = cases.filter((c) => view === "archived" ? c.archived_at : !c.archived_at, ); const filtered = visible.filter((c) => { const okStatus = statusFilter === "all" || c.status === statusFilter; const okQ = [c.title, c.case_number, c.client?.name].filter(Boolean).join(" ").toLowerCase().includes(q.toLowerCase()); return okStatus && okQ; }); const sorted = useMemo(() => { const getVal = (c: any): string => { switch (sortKey) { case "title": return (c.title ?? "").toLowerCase(); case "client": return (c.client?.name ?? "").toLowerCase(); case "practice_area": return (c.practice_area ?? "").toLowerCase(); case "status": return (c.status ?? "").toLowerCase(); case "attorney": return (c.assignee?.full_name || c.assignee?.email || "").toLowerCase(); case "opened_at": return c.opened_at ?? ""; case "last_activity": return c.last_activity ?? ""; } }; const arr = [...filtered]; arr.sort((a, b) => { const av = getVal(a); const bv = getVal(b); if (av < bv) return sortDir === "asc" ? -1 : 1; if (av > bv) return sortDir === "asc" ? 1 : -1; return 0; }); return arr; }, [filtered, sortKey, sortDir]); const toggleSort = (key: SortKey) => { if (sortKey === key) { setSortDir(sortDir === "asc" ? "desc" : "asc"); } else { setSortKey(key); setSortDir(key === "opened_at" || key === "last_activity" ? "desc" : "asc"); } }; const archivedCount = cases.filter((c) => c.archived_at).length; const activeCount = cases.length - archivedCount; const onArchive = async (id: string, archived: boolean) => { if (await setArchived("cases", id, archived)) load(); }; // Pagination — reset when filters/view change. useEffect(() => { setPage(1); }, [view, q, statusFilter, sortKey, sortDir]); const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); const currentPage = Math.min(page, totalPages); const pageStart = (currentPage - 1) * PAGE_SIZE; const paged = useMemo(() => sorted.slice(pageStart, pageStart + PAGE_SIZE), [sorted, pageStart]); // Selection helpers — clear when switching views. useEffect(() => { setSelected(new Set()); }, [view]); const visibleIds = useMemo(() => paged.map((c) => c.id), [paged]); const allSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id)); const someSelected = selected.size > 0 && !allSelected; const toggleAll = () => setSelected(allSelected ? new Set() : new Set(visibleIds)); const toggleOne = (id: string) => { const next = new Set(selected); if (next.has(id)) next.delete(id); else next.add(id); setSelected(next); }; const bulkArchive = async (archived: boolean) => { if (selected.size === 0) return; setBusy(true); const ids = Array.from(selected); const { data, error } = await supabase .from("cases") .update({ archived_at: archived ? new Date().toISOString() : null }) .in("id", ids) .select("id"); setBusy(false); if (error) { toast.error(error.message); return; } const updatedCount = data?.length ?? 0; if (updatedCount === 0) { toast.error("No cases were updated. You may not have permission."); return; } if (updatedCount < ids.length) { toast.warning(`${updatedCount} of ${ids.length} case(s) ${archived ? "archived" : "restored"} (others skipped due to permissions)`); } else { toast.success(`${updatedCount} case(s) ${archived ? "archived" : "restored"}`); } setSelected(new Set()); load(); }; const bulkAssignAttorney = async () => { if (selected.size === 0 || !assignAttorney) return; setBusy(true); const ids = Array.from(selected); const { data, error } = await supabase .from("cases") .update({ assigned_attorney_id: assignAttorney }) .in("id", ids) .select("id"); setBusy(false); if (error) { toast.error(error.message); return; } const n = data?.length ?? 0; if (n === 0) { toast.error("No cases were updated. You may not have permission."); return; } if (n < ids.length) toast.warning(`${n} of ${ids.length} case(s) reassigned (others skipped)`); else toast.success(`${n} case(s) reassigned`); setSelected(new Set()); setAssignAttorney(""); load(); }; const bulkAssignClient = async () => { if (selected.size === 0 || !assignClient) return; setBusy(true); const ids = Array.from(selected); const { data, error } = await supabase .from("cases") .update({ client_id: assignClient }) .in("id", ids) .select("id"); setBusy(false); if (error) { toast.error(error.message); return; } const n = data?.length ?? 0; if (n === 0) { toast.error("No cases were updated. You may not have permission."); return; } if (n < ids.length) toast.warning(`${n} of ${ids.length} case(s) re-linked (others skipped)`); else toast.success(`${n} case(s) re-linked to client`); setSelected(new Set()); setAssignClient(""); load(); }; const bulkAssignStatus = async () => { if (selected.size === 0 || !assignStatus) return; setBusy(true); const ids = Array.from(selected); const isClosed = assignStatus.startsWith("closed"); const { data, error } = await supabase .from("cases") .update({ status: assignStatus as "active" | "closed" | "closed_lost" | "closed_won" | "intake" | "on_hold", closed_at: isClosed ? new Date().toISOString().slice(0, 10) : null, }) .in("id", ids) .select("id"); setBusy(false); if (error) { toast.error(error.message); return; } const n = data?.length ?? 0; if (n === 0) { toast.error("No cases were updated. You may not have permission."); return; } if (n < ids.length) toast.warning(`${n} of ${ids.length} case(s) updated (others skipped)`); else toast.success(`${n} case(s) status updated`); setSelected(new Set()); setAssignStatus(""); load(); }; const userOptions = users.map((u) => ({ value: u.id, label: u.full_name || u.email || u.id, keywords: `${u.full_name || ""} ${u.email || ""}`, })); const clientOptions = clientList.map((c) => ({ value: c.id, label: c.name, keywords: c.name })); const SortHeader = ({ label, k, align = "left" }: { label: string; k: SortKey; align?: "left" | "right" }) => { const active = sortKey === k; const Icon = !active ? ArrowUpDown : sortDir === "asc" ? ArrowUp : ArrowDown; return ( ); }; return ( setNewOpen(true)}> New case } />
setView(v as "active" | "archived")}> Active ({activeCount}) Archived ({archivedCount})
setQ(e.target.value)} placeholder="Search cases…" className="pl-9" />
{selected.size > 0 && (
{selected.size} selected
{view === "active" ? ( ) : ( )}
Attorney:
Client:
Status:
)} selected.has(c.id))} onCreated={() => { setSelected(new Set()); load(); }} /> {sorted.length === 0 && ( )} {paged.map((c) => ( navigate({ to: "/cases/$caseId", params: { caseId: c.id } })} > ))}
{view === "archived" ? "No archived cases." : "No cases."}
e.stopPropagation()}> toggleOne(c.id)} aria-label={`Select ${c.title}`} /> e.stopPropagation()} > {c.title}
{c.case_number}
{c.client?.name ?? "—"} {c.practice_area ? ( {c.practice_area} ) : ( — )} {c.status.replace("_", " ")} {c.assignee?.full_name || c.assignee?.email || "—"} {formatDate(c.opened_at)} {c.last_activity ? `${formatDistanceToNow(new Date(c.last_activity))} ago` : "—"} e.stopPropagation()}>
{sorted.length > 0 && (
Showing {pageStart + 1}–{Math.min(pageStart + PAGE_SIZE, sorted.length)} of {sorted.length}
Page {currentPage} of {totalPages}
)} ); }