Files
mylegal-stage-law/src/routes/collections.$collectionId.tsx
T
2026-04-19 16:48:47 +00:00

662 lines
24 KiB
TypeScript

import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer } from "@/components/app-shell";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { CollectionDetail } from "@/components/cases/collections-tab";
import { PaymentPlansPanel } from "@/components/collections/payment-plans-panel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
import { CaseCallLogsTab } from "@/components/cases/call-logs-tab";
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 { CaseCustomFieldsTab } from "@/components/cases/custom-fields-tab";
import { formatDate } from "@/lib/format";
import {
ArrowLeft,
ArrowRight,
CheckCircle2,
Circle,
ListChecks,
Loader2,
Plus,
Trash2,
Workflow,
Scale,
Activity,
FileText,
Contact,
Clock,
DollarSign,
Receipt,
FilePlus,
Phone,
Tag,
} from "lucide-react";
import { toast } from "sonner";
import { spawnNextCollectionWorkflowTask } from "@/lib/workflow-chain";
import { ApplyCollectionWorkflowDialog } from "@/components/collections/apply-collection-workflow-dialog";
export const Route = createFileRoute("/collections/$collectionId")({
component: CollectionDetailRoute,
});
interface Stage {
key: string;
label: string;
sort_order: number;
}
interface Profile {
id: string;
full_name: string;
email: string;
}
interface Task {
id: string;
collection_id: string;
stage_key: string | null;
title: string;
due_date: string | null;
assignee_id: string | null;
done: boolean;
done_at: string | null;
sort_order: number;
}
function CollectionDetailRoute() {
const { collectionId } = Route.useParams();
const navigate = useNavigate();
const { user } = useAuth();
const [collection, setCollection] = useState<any | null>(null);
const [stages, setStages] = useState<Stage[]>([]);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [escalating, setEscalating] = useState(false);
const [addTaskOpen, setAddTaskOpen] = useState(false);
const [applyWfOpen, setApplyWfOpen] = useState(false);
const [reload, setReload] = useState(0);
useEffect(() => {
let active = true;
(async () => {
setLoading(true);
const [{ data: col }, { data: stgs }, { data: profs }] = await Promise.all([
supabase
.from("collections")
.select(
"*, homeowner:homeowners(*), case:cases(id, case_number, title, default_hourly_rate, client:clients(id, name, annual_interest_rate))",
)
.eq("id", collectionId)
.maybeSingle(),
supabase.from("collection_workflow_stages").select("*").order("sort_order"),
supabase.from("profiles").select("id, full_name, email"),
]);
if (!active) return;
setCollection(col);
setStages((stgs ?? []) as Stage[]);
setProfiles((profs ?? []) as Profile[]);
const { data: tsks } = await supabase
.from("collection_tasks")
.select("*")
.eq("collection_id", collectionId)
.order("done")
.order("sort_order")
.order("created_at");
if (!active) return;
setTasks((tsks ?? []) as Task[]);
setLoading(false);
})();
return () => {
active = false;
};
}, [collectionId, reload]);
const refreshTasks = async () => {
const { data } = await supabase
.from("collection_tasks")
.select("*")
.eq("collection_id", collectionId)
.order("done")
.order("sort_order")
.order("created_at");
setTasks((data ?? []) as Task[]);
};
const currentStageIdx = useMemo(
() => stages.findIndex((s) => s.key === collection?.current_stage),
[stages, collection?.current_stage],
);
const nextStage = currentStageIdx >= 0 ? stages[currentStageIdx + 1] : null;
const isLastStage = currentStageIdx === stages.length - 1;
const escalate = async () => {
if (!collection || !nextStage) return;
if (
!confirm(
`Escalate to "${nextStage.label}"? This will populate the checklist tasks for that stage.`,
)
)
return;
setEscalating(true);
// Update stage
const { error: updErr } = await supabase
.from("collections")
.update({ current_stage: nextStage.key })
.eq("id", collection.id);
if (updErr) {
setEscalating(false);
toast.error("Could not escalate", { description: updErr.message });
return;
}
// Pull templates for the new stage
const { data: templates } = await supabase
.from("collection_workflow_tasks")
.select("*")
.eq("stage_key", nextStage.key)
.order("sort_order");
if (templates && templates.length) {
const today = new Date();
const inserts = templates.map((t: any) => {
const due = new Date(today);
due.setDate(due.getDate() + (t.days_to_due ?? 7));
return {
collection_id: collection.id,
stage_key: nextStage.key,
title: t.title,
due_date: due.toISOString().slice(0, 10),
sort_order: t.sort_order,
created_by: user?.id,
};
});
const { error: insErr } = await supabase
.from("collection_tasks")
.insert(inserts);
if (insErr) {
toast.error("Stage advanced but task creation failed", {
description: insErr.message,
});
}
}
setEscalating(false);
toast.success(`Escalated to ${nextStage.label}`);
setReload((r) => r + 1);
};
const toggleDone = async (t: Task) => {
const next = !t.done;
const { error } = await supabase
.from("collection_tasks")
.update({
done: next,
done_at: next ? new Date().toISOString() : null,
})
.eq("id", t.id);
if (error) {
toast.error("Could not update", { description: error.message });
return;
}
if (next) {
// Auto-spawn the next workflow task in the chain (if any)
await spawnNextCollectionWorkflowTask(t.id);
}
refreshTasks();
};
const deleteTask = async (id: string) => {
if (!confirm("Delete this task?")) return;
const { error } = await supabase
.from("collection_tasks")
.delete()
.eq("id", id);
if (error) toast.error("Could not delete", { description: error.message });
else refreshTasks();
};
const updateAssignee = async (id: string, assignee_id: string | null) => {
const { error } = await supabase
.from("collection_tasks")
.update({ assignee_id })
.eq("id", id);
if (error) toast.error("Could not update", { description: error.message });
else refreshTasks();
};
const updateDueDate = async (id: string, due_date: string) => {
const { error } = await supabase
.from("collection_tasks")
.update({ due_date: due_date || null })
.eq("id", id);
if (error) toast.error("Could not update", { description: error.message });
else refreshTasks();
};
if (loading) {
return (
<ProtectedLayout>
<PageContainer>
<div className="p-12 text-center text-muted-foreground">Loading…</div>
</PageContainer>
</ProtectedLayout>
);
}
if (!collection) {
return (
<ProtectedLayout>
<PageContainer>
<div className="p-12 text-center text-muted-foreground">
Collection not found.{" "}
<Link to="/collections" className="underline">
Back
</Link>
</div>
</PageContainer>
</ProtectedLayout>
);
}
const annualRate = collection.case?.client?.annual_interest_rate ?? null;
const balanceFromOpening = Number(collection.homeowner?.opening_balance ?? 0);
const currentStageLabel =
stages.find((s) => s.key === collection.current_stage)?.label ?? "—";
const openTasks = tasks.filter((t) => !t.done);
const doneTasks = tasks.filter((t) => t.done);
return (
<ProtectedLayout>
<PageContainer>
<div className="flex items-center justify-between gap-2 mb-3">
<Button
variant="ghost"
size="sm"
onClick={() => navigate({ to: "/collections" })}
className="-ml-2"
>
<ArrowLeft className="h-4 w-4 mr-1" /> All collections
</Button>
{collection.case?.client?.id && (
<Button size="sm" onClick={() => navigate({ to: "/invoices/new/$clientId", params: { clientId: collection.case.client.id }, search: { caseId: collection.case.id } })}>
<FilePlus className="h-4 w-4 mr-1.5" /> Generate invoice
</Button>
)}
</div>
{/* Tabs: Workflow (collections-specific) + same case tabs */}
<Tabs defaultValue="workflow">
<TabsList className="mb-4 flex-wrap h-auto">
<TabsTrigger value="workflow"><Workflow className="h-3.5 w-3.5 mr-1.5" />Workflow</TabsTrigger>
{collection.case && (
<>
<TabsTrigger value="litigation"><Scale className="h-3.5 w-3.5 mr-1.5" />Litigation</TabsTrigger>
<TabsTrigger value="status"><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>
<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>
</>
)}
</TabsList>
<TabsContent value="workflow">
{/* Workflow header */}
<Card className="border-border/60 mb-4">
<CardContent className="p-4 flex flex-wrap items-center justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1">
Workflow stage
</div>
<div className="flex items-center gap-2 flex-wrap">
{stages.map((s, i) => {
const isCurrent = s.key === collection.current_stage;
const isPast = i < currentStageIdx;
return (
<div key={s.key} className="flex items-center gap-2">
<Badge
variant={isCurrent ? "default" : "outline"}
className={
isCurrent
? ""
: isPast
? "opacity-60 line-through"
: "opacity-50"
}
>
{s.label}
</Badge>
{i < stages.length - 1 && (
<ArrowRight className="h-3 w-3 text-muted-foreground" />
)}
</div>
);
})}
</div>
</div>
<Button
onClick={escalate}
disabled={escalating || isLastStage || !nextStage}
size="lg"
>
{escalating ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<ArrowRight className="h-4 w-4 mr-2" />
)}
{isLastStage
? "Final stage"
: `Escalate → ${nextStage?.label ?? ""}`}
</Button>
</CardContent>
</Card>
{/* Tasks */}
<Card className="border-border/60 mb-6">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-serif text-lg flex items-center gap-2">
<ListChecks className="h-4 w-4 text-muted-foreground" />
Tasks
{openTasks.length > 0 && (
<Badge variant="secondary" className="ml-1">
{openTasks.length} open
</Badge>
)}
</h3>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setApplyWfOpen(true)}>
<Workflow className="h-3.5 w-3.5 mr-1" /> Apply workflow
</Button>
<Button size="sm" variant="outline" onClick={() => setAddTaskOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add task
</Button>
</div>
</div>
{tasks.length === 0 ? (
<p className="text-sm text-muted-foreground italic py-4">
No tasks yet. Escalate to populate the next stage's checklist, or
add one manually.
</p>
) : (
<div className="space-y-1">
{[...openTasks, ...doneTasks].map((t) => {
const stageLabel =
stages.find((s) => s.key === t.stage_key)?.label;
const overdue =
!t.done &&
t.due_date &&
new Date(t.due_date) < new Date(new Date().toDateString());
return (
<div
key={t.id}
className={`flex items-center gap-3 px-2 py-2 rounded-md hover:bg-muted/40 ${
t.done ? "opacity-60" : ""
}`}
>
<button
onClick={() => toggleDone(t)}
className="shrink-0"
aria-label={t.done ? "Mark not done" : "Mark done"}
>
{t.done ? (
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
) : (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<div className={`text-sm ${t.done ? "line-through" : ""}`}>
{t.title}
</div>
{stageLabel && (
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
{stageLabel}
</div>
)}
</div>
<Input
type="date"
className={`h-8 w-[150px] text-xs ${
overdue ? "border-destructive text-destructive" : ""
}`}
value={t.due_date ?? ""}
onChange={(e) => updateDueDate(t.id, e.target.value)}
/>
<Select
value={t.assignee_id ?? "unassigned"}
onValueChange={(v) =>
updateAssignee(t.id, v === "unassigned" ? null : v)
}
>
<SelectTrigger className="h-8 w-[180px] text-xs">
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Unassigned</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.full_name || p.email}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => deleteTask(t.id)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Payment plans */}
<PaymentPlansPanel collectionId={collection.id} />
{/* Ledger */}
<CollectionDetail
collection={collection}
annualRate={annualRate}
currentBalance={balanceFromOpening /* recalculated by detail */}
onBack={() => navigate({ to: "/collections" })}
onChange={() => setReload((r) => r + 1)}
/>
</TabsContent>
{collection.case && (
<>
<TabsContent value="litigation"><CaseLitigationTab caseRecord={collection.case} canManage={true} onSaved={() => setReload((r) => r + 1)} /></TabsContent>
<TabsContent value="status"><CaseStatusTab caseId={collection.case.id} caseLabel={`${collection.case.case_number} — ${collection.case.title}`} /></TabsContent>
<TabsContent value="documents"><CaseDocumentsTab caseId={collection.case.id} /></TabsContent>
<TabsContent value="contacts"><ContactsLinkTab parentId={collection.case.id} parentTable="case_contacts" /></TabsContent>
<TabsContent value="time"><CaseTimeTab caseRecord={collection.case} onInvoice={() => {}} /></TabsContent>
<TabsContent value="expenses"><CaseExpensesTab caseId={collection.case.id} /></TabsContent>
<TabsContent value="invoices"><CaseInvoicesTab caseRecord={collection.case} /></TabsContent>
<TabsContent value="calls"><CaseCallLogsTab caseId={collection.case.id} /></TabsContent>
<TabsContent value="custom"><CaseCustomFieldsTab caseId={collection.case.id} /></TabsContent>
</>
)}
</Tabs>
<AddTaskDialog
open={addTaskOpen}
onOpenChange={setAddTaskOpen}
collectionId={collection.id}
currentStageKey={collection.current_stage}
profiles={profiles}
userId={user?.id}
onSaved={refreshTasks}
/>
<ApplyCollectionWorkflowDialog
open={applyWfOpen}
onOpenChange={setApplyWfOpen}
collectionId={collection.id}
caseId={collection.case?.id ?? null}
onApplied={refreshTasks}
/>
</PageContainer>
</ProtectedLayout>
);
}
function AddTaskDialog({
open,
onOpenChange,
collectionId,
currentStageKey,
profiles,
userId,
onSaved,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
collectionId: string;
currentStageKey: string | null;
profiles: Profile[];
userId?: string;
onSaved: () => void;
}) {
const [title, setTitle] = useState("");
const [dueDate, setDueDate] = useState(() => {
const d = new Date();
d.setDate(d.getDate() + 7);
return d.toISOString().slice(0, 10);
});
const [assignee, setAssignee] = useState<string>("unassigned");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) {
setTitle("");
const d = new Date();
d.setDate(d.getDate() + 7);
setDueDate(d.toISOString().slice(0, 10));
setAssignee("unassigned");
}
}, [open]);
const submit = async () => {
if (!title.trim()) return toast.error("Title required");
setSubmitting(true);
const { error } = await supabase.from("collection_tasks").insert({
collection_id: collectionId,
stage_key: currentStageKey,
title: title.trim(),
due_date: dueDate || null,
assignee_id: assignee === "unassigned" ? null : assignee,
created_by: userId,
});
setSubmitting(false);
if (error) {
toast.error("Could not add", { description: error.message });
return;
}
toast.success("Task added");
onOpenChange(false);
onSaved();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="font-serif">Add task</DialogTitle>
<DialogDescription>
Manual task on this collection. Will be tagged with the current stage.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Title</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Call homeowner re: payment plan"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Due date</Label>
<Input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<div>
<Label>Assignee</Label>
<Select value={assignee} onValueChange={setAssignee}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Unassigned</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.full_name || p.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Add task
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}