Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
ed20e47fd4
commit
12bf212912
@@ -0,0 +1,568 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user