Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
414 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|