Files
mylegal-stage-law/src/routes/cases.$caseId.tsx
T
2026-04-19 20:07:41 +00:00

414 lines
18 KiB
TypeScript

import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState, useCallback, useMemo } 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 { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { supabase } from "@/integrations/supabase/client";
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone, Tag, Archive, ArchiveRestore, ArrowRightLeft, Search, GitFork, Wallet } from "lucide-react";
import { TrustAccountPanel } from "@/components/trust/trust-account-panel";
import { ConvertToCollectionsDialog } from "@/components/cases/convert-to-collections-dialog";
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
import { CaseCallLogsTab } from "@/components/cases/call-logs-tab";
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
import { CaseDocumentsTab } from "@/components/cases/documents-tab";
import { CaseTimeTab } from "@/components/cases/time-tab";
import { CaseExpensesTab } from "@/components/cases/expenses-tab";
import { CaseStatusTab } from "@/components/cases/status-tab";
import { CaseInvoicesTab } from "@/components/cases/invoices-tab";
import { CaseLitigationTab } from "@/components/cases/litigation-tab";
import { CaseCollectionsTab } from "@/components/cases/collections-tab";
import { CaseCustomFieldsTab } from "@/components/cases/custom-fields-tab";
import { setArchived } from "@/lib/archive";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth";
export const Route = createFileRoute("/cases/$caseId")({
component: () => (
<ProtectedLayout>
<CaseDetail />
</ProtectedLayout>
),
});
function CaseDetail() {
const { caseId } = Route.useParams();
const { user, isAdmin } = useAuth();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [users, setUsers] = useState<any[]>([]);
const [clients, setClients] = useState<any[]>([]);
const [reassignOpen, setReassignOpen] = useState(false);
const [convertOpen, setConvertOpen] = useState(false);
const [activeTab, setActiveTab] = useState("litigation");
const [clientSearch, setClientSearch] = useState("");
const [selectedClientId, setSelectedClientId] = useState<string>("");
const [reassigning, setReassigning] = useState(false);
const load = useCallback(async () => {
const [{ data: c, error }, { data: us }, { data: cl }] = await Promise.all([
supabase
.from("cases")
.select("*, client:clients(id, name, client_type, annual_interest_rate), assignee:profiles!cases_assigned_attorney_id_fkey(id, full_name, email)")
.eq("id", caseId)
.maybeSingle(),
supabase.from("profiles").select("id, full_name, email"),
supabase.from("clients").select("id, name, client_type").is("archived_at", null).order("name"),
]);
if (error) toast.error("Failed to load case", { description: error.message });
setData(c);
setUsers(us ?? []);
setClients(cl ?? []);
setLoading(false);
}, [caseId]);
useEffect(() => {
load();
}, [load]);
const updateStatus = async (status: string) => {
const { error } = await supabase
.from("cases")
.update({ status: status as "active" | "closed" | "closed_lost" | "closed_won" | "intake" | "on_hold", closed_at: status.startsWith("closed") ? new Date().toISOString().slice(0, 10) : null })
.eq("id", caseId);
if (error) toast.error("Could not update", { description: error.message });
else {
toast.success("Status updated");
load();
}
};
const updateAssignee = async (assigned_attorney_id: string) => {
const { error } = await supabase.from("cases").update({ assigned_attorney_id }).eq("id", caseId);
if (error) toast.error(error.message);
else { toast.success("Assignee updated"); load(); }
};
const updateRate = async (rateInput: string) => {
const trimmed = rateInput.trim();
const next = trimmed === "" ? null : Number(trimmed);
if (next !== null && (!Number.isFinite(next) || next < 0)) {
toast.error("Enter a valid rate (0 or higher)");
return false;
}
const { error } = await supabase
.from("cases")
.update({ default_hourly_rate: next })
.eq("id", caseId);
if (error) { toast.error(error.message); return false; }
toast.success(next === null ? "Case rate cleared" : `Case rate set to $${next.toFixed(2)}/hr`);
load();
return true;
};
const filteredClients = useMemo(() => {
const q = clientSearch.trim().toLowerCase();
if (!q) return clients.slice(0, 50);
return clients.filter((c) => c.name.toLowerCase().includes(q)).slice(0, 50);
}, [clients, clientSearch]);
const reassignClient = async () => {
if (!selectedClientId || selectedClientId === data.client?.id) {
setReassignOpen(false);
return;
}
setReassigning(true);
const { error } = await supabase.from("cases").update({ client_id: selectedClientId }).eq("id", caseId);
setReassigning(false);
if (error) toast.error("Could not reassign", { description: error.message });
else {
toast.success("Case reassigned to new client");
setReassignOpen(false);
setClientSearch("");
setSelectedClientId("");
load();
}
};
if (loading) return <PageContainer><p className="text-muted-foreground">Loading…</p></PageContainer>;
if (!data) {
return (
<PageContainer>
<p className="text-muted-foreground">Case not found or you don't have access.</p>
<Button variant="outline" className="mt-3" asChild>
<Link to="/cases"><ArrowLeft className="h-4 w-4 mr-2" /> All cases</Link>
</Button>
</PageContainer>
);
}
const canManage = isAdmin || data.assigned_attorney_id === user?.id || data.created_by === user?.id;
return (
<PageContainer>
<Button variant="ghost" size="sm" asChild className="mb-3 -ml-2">
<Link to="/cases"><ArrowLeft className="h-4 w-4 mr-1" /> All cases</Link>
</Button>
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-4 mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="text-xs uppercase tracking-widest text-muted-foreground">{data.case_number}</span>
<Badge variant="outline" className={statusBadgeClass(data.status)}>{data.status.replace("_", " ")}</Badge>
{data.archived_at && (
<Badge variant="outline" className="bg-muted text-muted-foreground">
<Archive className="h-3 w-3 mr-1" /> Archived
</Badge>
)}
</div>
<h1 className="font-serif text-3xl">{data.title}</h1>
<p className="text-sm text-muted-foreground mt-1">
<Link to="/clients/$clientId" params={{ clientId: data.client.id }} className="hover:text-primary underline-offset-4 hover:underline">
{data.client.name}
</Link>
{" · "}{data.practice_area || "—"}{" · "}opened {formatDate(data.opened_at)}
</p>
</div>
{canManage && (
<div className="flex flex-wrap gap-2">
<Select value={data.assigned_attorney_id ?? ""} onValueChange={updateAssignee}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder="Assign attorney" /></SelectTrigger>
<SelectContent>
{users.map((u) => <SelectItem key={u.id} value={u.id}>{u.full_name || u.email}</SelectItem>)}
</SelectContent>
</Select>
<Select value={data.status} onValueChange={updateStatus}>
<SelectTrigger className="w-[160px]"><SelectValue /></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
variant="outline"
onClick={() => {
setSelectedClientId(data.client?.id ?? "");
setReassignOpen(true);
}}
>
<ArrowRightLeft className="h-4 w-4 mr-2" /> Reassign client
</Button>
{(data.client?.client_type === "hoa" || data.client?.client_type === "condo") && (
<Button variant="outline" onClick={() => setConvertOpen(true)}>
<GitFork className="h-4 w-4 mr-2" /> Convert to collections
</Button>
)}
<Button
variant="outline"
onClick={async () => {
if (await setArchived("cases", caseId, !data.archived_at)) load();
}}
>
{data.archived_at ? (
<><ArchiveRestore className="h-4 w-4 mr-2" /> Restore</>
) : (
<><Archive className="h-4 w-4 mr-2" /> Archive</>
)}
</Button>
</div>
)}
</div>
<Dialog open={reassignOpen} onOpenChange={setReassignOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Reassign case to a different client</DialogTitle>
<DialogDescription>
Currently assigned to <span className="font-medium">{data.client?.name ?? "—"}</span>. Pick a new client below.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search clients…"
value={clientSearch}
onChange={(e) => setClientSearch(e.target.value)}
className="pl-8"
autoFocus
/>
</div>
<div className="max-h-72 overflow-y-auto border rounded-md divide-y">
{filteredClients.length === 0 ? (
<p className="text-sm text-muted-foreground p-3">No clients match.</p>
) : (
filteredClients.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setSelectedClientId(c.id)}
className={`w-full text-left px-3 py-2 text-sm hover:bg-muted flex items-center justify-between ${
selectedClientId === c.id ? "bg-muted" : ""
}`}
>
<span>{c.name}</span>
<span className="text-xs text-muted-foreground uppercase">{c.client_type}</span>
</button>
))
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setReassignOpen(false)}>Cancel</Button>
<Button
onClick={reassignClient}
disabled={reassigning || !selectedClientId || selectedClientId === data.client?.id}
>
{reassigning ? "Reassigning…" : "Reassign"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{data.description && (
<Card className="mb-6 border-border/60">
<CardContent className="p-4">
<p className="text-sm whitespace-pre-wrap">{data.description}</p>
</CardContent>
</Card>
)}
<CaseTabs data={data} caseId={caseId} canManage={canManage} load={load} tab={activeTab} onTabChange={setActiveTab} />
<ConvertToCollectionsDialog
open={convertOpen}
onOpenChange={setConvertOpen}
caseId={caseId}
clientId={data.client?.id ?? ""}
caseTitle={data.title || ""}
onCreated={() => {
setActiveTab("collections");
load();
}}
/>
<div className="mt-8 grid grid-cols-2 sm:grid-cols-4 gap-3 text-center text-xs">
{canManage ? (
<EditableRateStat value={data.default_hourly_rate} onSave={updateRate} />
) : (
<SmallStat label="Case rate" value={data.default_hourly_rate ? formatCurrency(data.default_hourly_rate) + "/hr" : "—"} />
)}
<SmallStat label="Attorney" value={data.assignee?.full_name || data.assignee?.email || "—"} />
<SmallStat label="Closed" value={data.closed_at ? formatDate(data.closed_at) : "—"} />
<SmallStat label="Updated" value={formatDate(data.updated_at)} />
</div>
</PageContainer>
);
}
function CaseTabs({ data, caseId, canManage, load, tab, onTabChange }: { data: any; caseId: string; canManage: boolean; load: () => void; tab: string; onTabChange: (v: string) => void }) {
return (
<Tabs value={tab} onValueChange={onTabChange}>
<TabsList className="mb-4 flex-wrap h-auto">
<TabsTrigger value="litigation"><Scale className="h-3.5 w-3.5 mr-1.5" />Litigation</TabsTrigger>
<TabsTrigger value="overview"><Activity className="h-3.5 w-3.5 mr-1.5" />Status log</TabsTrigger>
<TabsTrigger value="documents"><FileText className="h-3.5 w-3.5 mr-1.5" />Documents</TabsTrigger>
<TabsTrigger value="contacts"><Contact className="h-3.5 w-3.5 mr-1.5" />Contacts</TabsTrigger>
<TabsTrigger value="time"><Clock className="h-3.5 w-3.5 mr-1.5" />Time</TabsTrigger>
<TabsTrigger value="expenses"><DollarSign className="h-3.5 w-3.5 mr-1.5" />Expenses</TabsTrigger>
<TabsTrigger value="invoices"><Receipt className="h-3.5 w-3.5 mr-1.5" />Invoices</TabsTrigger>
{data.client_id && (
<TabsTrigger value="trust"><Wallet className="h-3.5 w-3.5 mr-1.5" />Trust</TabsTrigger>
)}
<TabsTrigger value="calls"><Phone className="h-3.5 w-3.5 mr-1.5" />Calls</TabsTrigger>
<TabsTrigger value="custom"><Tag className="h-3.5 w-3.5 mr-1.5" />Custom fields</TabsTrigger>
{(data.client?.client_type === "hoa" || data.client?.client_type === "condo") && (
<TabsTrigger value="collections"><Users className="h-3.5 w-3.5 mr-1.5" />Collections</TabsTrigger>
)}
</TabsList>
<TabsContent value="litigation"><CaseLitigationTab caseRecord={data} canManage={canManage} onSaved={load} /></TabsContent>
<TabsContent value="overview"><CaseStatusTab caseId={caseId} caseLabel={`${data.case_number} — ${data.title}`} /></TabsContent>
<TabsContent value="documents"><CaseDocumentsTab caseId={caseId} /></TabsContent>
<TabsContent value="contacts"><ContactsLinkTab parentId={caseId} parentTable="case_contacts" /></TabsContent>
<TabsContent value="time"><CaseTimeTab caseRecord={data} onInvoice={() => onTabChange("invoices")} /></TabsContent>
<TabsContent value="expenses"><CaseExpensesTab caseId={caseId} /></TabsContent>
<TabsContent value="invoices"><CaseInvoicesTab caseRecord={data} /></TabsContent>
{data.client_id && (
<TabsContent value="trust"><TrustAccountPanel clientId={data.client_id} caseId={caseId} /></TabsContent>
)}
<TabsContent value="calls"><CaseCallLogsTab caseId={caseId} /></TabsContent>
<TabsContent value="custom"><CaseCustomFieldsTab caseId={caseId} /></TabsContent>
{(data.client?.client_type === "hoa" || data.client?.client_type === "condo") && (
<TabsContent value="collections"><CaseCollectionsTab caseRecord={data} /></TabsContent>
)}
</Tabs>
);
}
function SmallStat({ label, value }: { label: string; value: string }) {
return (
<div className="border rounded-md py-2 px-3">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="text-sm font-medium mt-0.5 truncate">{value}</div>
</div>
);
}
function EditableRateStat({
value,
onSave,
}: {
value: number | null;
onSave: (raw: string) => Promise<boolean>;
}) {
const [editing, setEditing] = useState(false);
const [raw, setRaw] = useState(value != null ? String(value) : "");
const [saving, setSaving] = useState(false);
useEffect(() => {
setRaw(value != null ? String(value) : "");
}, [value]);
const commit = async () => {
setSaving(true);
const ok = await onSave(raw);
setSaving(false);
if (ok) setEditing(false);
};
return (
<div className="border rounded-md py-2 px-3 text-left">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground text-center">Case rate</div>
{editing ? (
<div className="flex items-center gap-1 mt-1">
<span className="text-sm text-muted-foreground">$</span>
<Input
type="number"
step="0.01"
min="0"
value={raw}
placeholder="0.00"
onChange={(e) => setRaw(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") { setRaw(value != null ? String(value) : ""); setEditing(false); }
}}
autoFocus
className="h-7 px-2 text-sm"
/>
<span className="text-xs text-muted-foreground">/hr</span>
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs" disabled={saving} onClick={commit}>Save</Button>
</div>
) : (
<button
type="button"
onClick={() => setEditing(true)}
className="text-sm font-medium mt-0.5 truncate hover:text-primary w-full text-center"
title="Click to edit case rate"
>
{value ? formatCurrency(value) + "/hr" : "Set rate…"}
</button>
)}
</div>
);
}