Files
mylegal-stage-law/src/routes/cases.index.tsx
T
2026-04-20 00:21:46 +00:00

515 lines
22 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 { 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 { 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: () => (
<ProtectedLayout>
<CasesList />
</ProtectedLayout>
),
});
function CasesList() {
const navigate = useNavigate();
const [cases, setCases] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [clientList, setClientList] = useState<any[]>([]);
const [q, setQ] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [view, setView] = useState<"active" | "archived">("active");
const [newOpen, setNewOpen] = useState(false);
const [bulkCollectionsOpen, setBulkCollectionsOpen] = useState(false);
const [sortKey, setSortKey] = useState<SortKey>("last_activity");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [assignAttorney, setAssignAttorney] = useState<string>("");
const [assignClient, setAssignClient] = useState<string>("");
const [assignStatus, setAssignStatus] = useState<string>("");
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,
] = 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),
]);
const latest: Record<string, string> = {};
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");
// 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;
return { ...c, last_activity: lastActivity };
});
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 (
<th className={`px-4 py-3 font-medium text-${align}`}>
<button
type="button"
onClick={() => toggleSort(k)}
className={`inline-flex items-center gap-1 hover:text-foreground transition-colors ${active ? "text-foreground" : ""}`}
>
{label}
<Icon className="h-3 w-3" />
</button>
</th>
);
};
return (
<PageContainer>
<PageHeader
title="Cases"
description="Matters assigned to you and ones you've created."
actions={
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-2" /> New case
</Button>
}
/>
<NewCaseDialog open={newOpen} onOpenChange={setNewOpen} />
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<Tabs value={view} onValueChange={(v) => setView(v as "active" | "archived")}>
<TabsList>
<TabsTrigger value="active">Active ({activeCount})</TabsTrigger>
<TabsTrigger value="archived">Archived ({archivedCount})</TabsTrigger>
</TabsList>
</Tabs>
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search cases…" className="pl-9" />
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="intake">Intake</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="on_hold">On hold</SelectItem>
<SelectItem value="closed_won">Closed — won</SelectItem>
<SelectItem value="closed_lost">Closed — lost</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
{selected.size > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-3 py-2">
<span className="text-sm font-medium">{selected.size} selected</span>
<Button size="sm" variant="outline" onClick={() => setSelected(new Set())} disabled={busy}>
Clear
</Button>
<div className="mx-2 h-5 w-px bg-border" />
{view === "active" ? (
<Button size="sm" variant="outline" onClick={() => bulkArchive(true)} disabled={busy}>
<Archive className="h-4 w-4 mr-2" /> Archive
</Button>
) : (
<Button size="sm" variant="outline" onClick={() => bulkArchive(false)} disabled={busy}>
<ArchiveRestore className="h-4 w-4 mr-2" /> Restore
</Button>
)}
<div className="mx-2 h-5 w-px bg-border" />
<span className="text-sm text-muted-foreground">Attorney:</span>
<div className="w-[200px]">
<SearchableSelect
value={assignAttorney}
onValueChange={setAssignAttorney}
options={userOptions}
placeholder="Select user…"
searchPlaceholder="Search users…"
/>
</div>
<Button size="sm" onClick={bulkAssignAttorney} disabled={busy || !assignAttorney}>
Apply
</Button>
<div className="mx-2 h-5 w-px bg-border" />
<span className="text-sm text-muted-foreground">Client:</span>
<div className="w-[200px]">
<SearchableSelect
value={assignClient}
onValueChange={setAssignClient}
options={clientOptions}
placeholder="Select client…"
searchPlaceholder="Search clients…"
/>
</div>
<Button size="sm" onClick={bulkAssignClient} disabled={busy || !assignClient}>
Apply
</Button>
<div className="mx-2 h-5 w-px bg-border" />
<span className="text-sm text-muted-foreground">Status:</span>
<Select value={assignStatus} onValueChange={setAssignStatus}>
<SelectTrigger className="w-[160px] h-9"><SelectValue placeholder="Select status…" /></SelectTrigger>
<SelectContent>
<SelectItem value="intake">Intake</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="on_hold">On hold</SelectItem>
<SelectItem value="closed_won">Closed — won</SelectItem>
<SelectItem value="closed_lost">Closed — lost</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Button size="sm" onClick={bulkAssignStatus} disabled={busy || !assignStatus}>
Apply
</Button>
<div className="mx-2 h-5 w-px bg-border" />
<Button
size="sm"
variant="outline"
onClick={() => setBulkCollectionsOpen(true)}
disabled={busy}
>
<Users className="h-4 w-4 mr-2" /> Designate as collections
</Button>
</div>
)}
<BulkConvertToCollectionsDialog
open={bulkCollectionsOpen}
onOpenChange={setBulkCollectionsOpen}
cases={cases.filter((c) => selected.has(c.id))}
onCreated={() => { setSelected(new Set()); load(); }}
/>
<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-4 py-3 w-10">
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</th>
<SortHeader label="Case" k="title" />
<SortHeader label="Client" k="client" />
<SortHeader label="Practice area" k="practice_area" />
<SortHeader label="Status" k="status" />
<SortHeader label="Attorney" k="attorney" />
<SortHeader label="Opened" k="opened_at" />
<SortHeader label="Last activity" k="last_activity" />
<th className="text-right px-4 py-3 font-medium w-12"></th>
</tr>
</thead>
<tbody>
{sorted.length === 0 && (
<tr><td colSpan={8} className="text-center py-12 text-muted-foreground">
{view === "archived" ? "No archived cases." : "No cases."}
</td></tr>
)}
{paged.map((c) => (
<tr
key={c.id}
className="border-t hover:bg-muted/30 cursor-pointer transition-colors"
onClick={() => navigate({ to: "/cases/$caseId", params: { caseId: c.id } })}
>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected.has(c.id)}
onCheckedChange={() => toggleOne(c.id)}
aria-label={`Select ${c.title}`}
/>
</td>
<td className="px-4 py-3">
<Link
to="/cases/$caseId"
params={{ caseId: c.id }}
className="font-medium hover:text-primary"
onClick={(e) => e.stopPropagation()}
>
{c.title}
</Link>
<div className="text-xs text-muted-foreground">{c.case_number}</div>
</td>
<td className="px-4 py-3 text-muted-foreground">{c.client?.name ?? "—"}</td>
<td className="px-4 py-3">
<Badge variant="outline" className={statusBadgeClass(c.status)}>{c.status.replace("_", " ")}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">{c.assignee?.full_name || c.assignee?.email || "—"}</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(c.opened_at)}</td>
<td className="px-4 py-3 text-muted-foreground" title={c.last_activity ? new Date(c.last_activity).toLocaleString() : ""}>
{c.last_activity ? `${formatDistanceToNow(new Date(c.last_activity))} ago` : "—"}
</td>
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<Button
size="icon"
variant="ghost"
title={c.archived_at ? "Restore" : "Archive"}
onClick={() => onArchive(c.id, !c.archived_at)}
>
{c.archived_at ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
{sorted.length > 0 && (
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
<div>
Showing {pageStart + 1}–{Math.min(pageStart + PAGE_SIZE, sorted.length)} of {sorted.length}
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" disabled={currentPage <= 1} onClick={() => setPage(currentPage - 1)}>
Previous
</Button>
<span>Page {currentPage} of {totalPages}</span>
<Button size="sm" variant="outline" disabled={currentPage >= totalPages} onClick={() => setPage(currentPage + 1)}>
Next
</Button>
</div>
</div>
)}
</PageContainer>
);
}