Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
569 lines
18 KiB
TypeScript
569 lines
18 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 { formatDate } from "@/lib/format";
|
|
import {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
CheckCircle2,
|
|
Circle,
|
|
ListChecks,
|
|
Loader2,
|
|
Plus,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
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 [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 });
|
|
else 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>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => navigate({ to: "/collections" })}
|
|
className="-ml-2 mb-3"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 mr-1" /> All collections
|
|
</Button>
|
|
|
|
{/* 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>
|
|
<Button size="sm" variant="outline" onClick={() => setAddTaskOpen(true)}>
|
|
<Plus className="h-3.5 w-3.5 mr-1" /> Add task
|
|
</Button>
|
|
</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>
|
|
|
|
{/* Ledger */}
|
|
<CollectionDetail
|
|
collection={collection}
|
|
annualRate={annualRate}
|
|
currentBalance={balanceFromOpening /* recalculated by detail */}
|
|
onBack={() => navigate({ to: "/collections" })}
|
|
onChange={() => setReload((r) => r + 1)}
|
|
/>
|
|
|
|
<AddTaskDialog
|
|
open={addTaskOpen}
|
|
onOpenChange={setAddTaskOpen}
|
|
collectionId={collection.id}
|
|
currentStageKey={collection.current_stage}
|
|
profiles={profiles}
|
|
userId={user?.id}
|
|
onSaved={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>
|
|
);
|
|
}
|