Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 01:16:02 +00:00
co-authored by renee-png
parent ed20e47fd4
commit 12bf212912
4 changed files with 1339 additions and 0 deletions
+72
View File
@@ -14,9 +14,12 @@ import { Route as SettingsRouteImport } from './routes/settings'
import { Route as LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SettingsIndexRouteImport } from './routes/settings.index'
import { Route as CollectionsIndexRouteImport } from './routes/collections.index'
import { Route as ClientsIndexRouteImport } from './routes/clients.index'
import { Route as CasesIndexRouteImport } from './routes/cases.index'
import { Route as SettingsWorkflowRouteImport } from './routes/settings.workflow'
import { Route as SettingsFeesRouteImport } from './routes/settings.fees'
import { Route as CollectionsCollectionIdRouteImport } from './routes/collections.$collectionId'
import { Route as ClientsClientIdRouteImport } from './routes/clients.$clientId'
import { Route as CasesNewRouteImport } from './routes/cases.new'
import { Route as CasesCaseIdRouteImport } from './routes/cases.$caseId'
@@ -47,6 +50,11 @@ const SettingsIndexRoute = SettingsIndexRouteImport.update({
path: '/',
getParentRoute: () => SettingsRoute,
} as any)
const CollectionsIndexRoute = CollectionsIndexRouteImport.update({
id: '/collections/',
path: '/collections/',
getParentRoute: () => rootRouteImport,
} as any)
const ClientsIndexRoute = ClientsIndexRouteImport.update({
id: '/clients/',
path: '/clients/',
@@ -57,11 +65,21 @@ const CasesIndexRoute = CasesIndexRouteImport.update({
path: '/cases/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsWorkflowRoute = SettingsWorkflowRouteImport.update({
id: '/workflow',
path: '/workflow',
getParentRoute: () => SettingsRoute,
} as any)
const SettingsFeesRoute = SettingsFeesRouteImport.update({
id: '/fees',
path: '/fees',
getParentRoute: () => SettingsRoute,
} as any)
const CollectionsCollectionIdRoute = CollectionsCollectionIdRouteImport.update({
id: '/collections/$collectionId',
path: '/collections/$collectionId',
getParentRoute: () => rootRouteImport,
} as any)
const ClientsClientIdRoute = ClientsClientIdRouteImport.update({
id: '/clients/$clientId',
path: '/clients/$clientId',
@@ -92,9 +110,12 @@ export interface FileRoutesByFullPath {
'/cases/$caseId': typeof CasesCaseIdRoute
'/cases/new': typeof CasesNewRoute
'/clients/$clientId': typeof ClientsClientIdRoute
'/collections/$collectionId': typeof CollectionsCollectionIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/workflow': typeof SettingsWorkflowRoute
'/cases/': typeof CasesIndexRoute
'/clients/': typeof ClientsIndexRoute
'/collections/': typeof CollectionsIndexRoute
'/settings/': typeof SettingsIndexRoute
}
export interface FileRoutesByTo {
@@ -105,9 +126,12 @@ export interface FileRoutesByTo {
'/cases/$caseId': typeof CasesCaseIdRoute
'/cases/new': typeof CasesNewRoute
'/clients/$clientId': typeof ClientsClientIdRoute
'/collections/$collectionId': typeof CollectionsCollectionIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/workflow': typeof SettingsWorkflowRoute
'/cases': typeof CasesIndexRoute
'/clients': typeof ClientsIndexRoute
'/collections': typeof CollectionsIndexRoute
'/settings': typeof SettingsIndexRoute
}
export interface FileRoutesById {
@@ -120,9 +144,12 @@ export interface FileRoutesById {
'/cases/$caseId': typeof CasesCaseIdRoute
'/cases/new': typeof CasesNewRoute
'/clients/$clientId': typeof ClientsClientIdRoute
'/collections/$collectionId': typeof CollectionsCollectionIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/workflow': typeof SettingsWorkflowRoute
'/cases/': typeof CasesIndexRoute
'/clients/': typeof ClientsIndexRoute
'/collections/': typeof CollectionsIndexRoute
'/settings/': typeof SettingsIndexRoute
}
export interface FileRouteTypes {
@@ -136,9 +163,12 @@ export interface FileRouteTypes {
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/collections/$collectionId'
| '/settings/fees'
| '/settings/workflow'
| '/cases/'
| '/clients/'
| '/collections/'
| '/settings/'
fileRoutesByTo: FileRoutesByTo
to:
@@ -149,9 +179,12 @@ export interface FileRouteTypes {
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/collections/$collectionId'
| '/settings/fees'
| '/settings/workflow'
| '/cases'
| '/clients'
| '/collections'
| '/settings'
id:
| '__root__'
@@ -163,9 +196,12 @@ export interface FileRouteTypes {
| '/cases/$caseId'
| '/cases/new'
| '/clients/$clientId'
| '/collections/$collectionId'
| '/settings/fees'
| '/settings/workflow'
| '/cases/'
| '/clients/'
| '/collections/'
| '/settings/'
fileRoutesById: FileRoutesById
}
@@ -178,8 +214,10 @@ export interface RootRouteChildren {
CasesCaseIdRoute: typeof CasesCaseIdRoute
CasesNewRoute: typeof CasesNewRoute
ClientsClientIdRoute: typeof ClientsClientIdRoute
CollectionsCollectionIdRoute: typeof CollectionsCollectionIdRoute
CasesIndexRoute: typeof CasesIndexRoute
ClientsIndexRoute: typeof ClientsIndexRoute
CollectionsIndexRoute: typeof CollectionsIndexRoute
}
declare module '@tanstack/react-router' {
@@ -219,6 +257,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsIndexRouteImport
parentRoute: typeof SettingsRoute
}
'/collections/': {
id: '/collections/'
path: '/collections'
fullPath: '/collections/'
preLoaderRoute: typeof CollectionsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/clients/': {
id: '/clients/'
path: '/clients'
@@ -233,6 +278,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CasesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/workflow': {
id: '/settings/workflow'
path: '/workflow'
fullPath: '/settings/workflow'
preLoaderRoute: typeof SettingsWorkflowRouteImport
parentRoute: typeof SettingsRoute
}
'/settings/fees': {
id: '/settings/fees'
path: '/fees'
@@ -240,6 +292,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsFeesRouteImport
parentRoute: typeof SettingsRoute
}
'/collections/$collectionId': {
id: '/collections/$collectionId'
path: '/collections/$collectionId'
fullPath: '/collections/$collectionId'
preLoaderRoute: typeof CollectionsCollectionIdRouteImport
parentRoute: typeof rootRouteImport
}
'/clients/$clientId': {
id: '/clients/$clientId'
path: '/clients/$clientId'
@@ -273,11 +332,13 @@ declare module '@tanstack/react-router' {
interface SettingsRouteChildren {
SettingsFeesRoute: typeof SettingsFeesRoute
SettingsWorkflowRoute: typeof SettingsWorkflowRoute
SettingsIndexRoute: typeof SettingsIndexRoute
}
const SettingsRouteChildren: SettingsRouteChildren = {
SettingsFeesRoute: SettingsFeesRoute,
SettingsWorkflowRoute: SettingsWorkflowRoute,
SettingsIndexRoute: SettingsIndexRoute,
}
@@ -294,9 +355,20 @@ const rootRouteChildren: RootRouteChildren = {
CasesCaseIdRoute: CasesCaseIdRoute,
CasesNewRoute: CasesNewRoute,
ClientsClientIdRoute: ClientsClientIdRoute,
CollectionsCollectionIdRoute: CollectionsCollectionIdRoute,
CasesIndexRoute: CasesIndexRoute,
ClientsIndexRoute: ClientsIndexRoute,
CollectionsIndexRoute: CollectionsIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+568
View File
@@ -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>
);
}
+251
View File
@@ -0,0 +1,251 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
import { supabase } from "@/integrations/supabase/client";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { formatCurrency, formatDate } from "@/lib/format";
import { ChevronRight, Wallet, Search } from "lucide-react";
export const Route = createFileRoute("/collections/")({
component: CollectionsIndexPage,
});
interface Stage {
key: string;
label: string;
sort_order: number;
}
function CollectionsIndexPage() {
const [rows, setRows] = useState<any[]>([]);
const [balances, setBalances] = useState<Record<string, number>>({});
const [openTaskCounts, setOpenTaskCounts] = useState<Record<string, number>>({});
const [stages, setStages] = useState<Stage[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [stageFilter, setStageFilter] = useState<string>("all");
const load = async () => {
setLoading(true);
const [{ data: cols }, { data: stgs }] = await Promise.all([
supabase
.from("collections")
.select(
"id, status, current_stage, opened_at, homeowner:homeowners(id, first_name, last_name, unit_number, opening_balance), case:cases(id, case_number, title, client:clients(id, name))",
)
.order("created_at", { ascending: false }),
supabase
.from("collection_workflow_stages")
.select("*")
.order("sort_order"),
]);
const list = cols ?? [];
setRows(list);
setStages((stgs ?? []) as Stage[]);
if (list.length) {
const ids = list.map((c: any) => c.id);
const [{ data: ents }, { data: tasks }] = await Promise.all([
supabase
.from("collection_ledger_entries")
.select("collection_id, debit, credit")
.in("collection_id", ids),
supabase
.from("collection_tasks")
.select("collection_id")
.in("collection_id", ids)
.eq("done", false),
]);
const balMap: Record<string, number> = {};
list.forEach((c: any) => {
balMap[c.id] = Number(c.homeowner?.opening_balance ?? 0);
});
(ents ?? []).forEach((e: any) => {
balMap[e.collection_id] =
(balMap[e.collection_id] ?? 0) + Number(e.debit) - Number(e.credit);
});
setBalances(balMap);
const taskMap: Record<string, number> = {};
(tasks ?? []).forEach((t: any) => {
taskMap[t.collection_id] = (taskMap[t.collection_id] ?? 0) + 1;
});
setOpenTaskCounts(taskMap);
}
setLoading(false);
};
useEffect(() => {
load();
}, []);
const stageLabel = (key: string | null) =>
stages.find((s) => s.key === key)?.label ?? key ?? "—";
const filtered = rows.filter((r) => {
if (stageFilter !== "all" && r.current_stage !== stageFilter) return false;
if (!search.trim()) return true;
const q = search.toLowerCase();
return (
`${r.homeowner?.first_name ?? ""} ${r.homeowner?.last_name ?? ""}`
.toLowerCase()
.includes(q) ||
(r.case?.case_number ?? "").toLowerCase().includes(q) ||
(r.case?.client?.name ?? "").toLowerCase().includes(q) ||
(r.homeowner?.unit_number ?? "").toLowerCase().includes(q)
);
});
return (
<ProtectedLayout>
<PageContainer>
<PageHeader
title="Collections"
description="All homeowner collections across every matter."
/>
<div className="flex gap-3 mb-4 flex-wrap">
<div className="relative flex-1 min-w-[260px] max-w-md">
<Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search homeowner, HOA, case…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={stageFilter} onValueChange={setStageFilter}>
<SelectTrigger className="w-[260px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All stages</SelectItem>
{stages.map((s) => (
<SelectItem key={s.key} value={s.key}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card className="border-border/60">
<CardContent className="p-0">
{loading ? (
<div className="p-12 text-center text-muted-foreground text-sm">
Loading…
</div>
) : filtered.length === 0 ? (
<div className="p-16 text-center text-muted-foreground text-sm">
<Wallet className="h-8 w-8 mx-auto mb-3 opacity-40" />
No collections found.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Homeowner</TableHead>
<TableHead>HOA / Case</TableHead>
<TableHead>Stage</TableHead>
<TableHead className="text-center">Open tasks</TableHead>
<TableHead>Opened</TableHead>
<TableHead className="text-right">Balance</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((r) => {
const bal = balances[r.id] ?? 0;
const tcount = openTaskCounts[r.id] ?? 0;
return (
<TableRow
key={r.id}
className="cursor-pointer"
onClick={() => {}}
>
<TableCell className="font-medium">
<Link
to="/collections/$collectionId"
params={{ collectionId: r.id }}
className="hover:underline"
>
{r.homeowner?.last_name}, {r.homeowner?.first_name}
{r.homeowner?.unit_number && (
<span className="text-muted-foreground font-normal ml-1">
· Unit {r.homeowner.unit_number}
</span>
)}
</Link>
</TableCell>
<TableCell className="text-sm">
<div>{r.case?.client?.name}</div>
<div className="text-xs text-muted-foreground">
{r.case?.case_number} · {r.case?.title}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px]">
{stageLabel(r.current_stage)}
</Badge>
</TableCell>
<TableCell className="text-center">
{tcount > 0 ? (
<Badge className="text-[10px]">{tcount}</Badge>
) : (
<span className="text-xs text-muted-foreground">
—
</span>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDate(r.opened_at)}
</TableCell>
<TableCell
className={`text-right font-mono ${
bal > 0
? "text-destructive font-semibold"
: bal < 0
? "text-emerald-600"
: ""
}`}
>
{formatCurrency(Math.abs(bal))}
{bal < 0 ? " CR" : ""}
</TableCell>
<TableCell className="text-right">
<Link
to="/collections/$collectionId"
params={{ collectionId: r.id }}
>
<ChevronRight className="h-4 w-4 text-muted-foreground inline" />
</Link>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</PageContainer>
</ProtectedLayout>
);
}
+448
View File
@@ -0,0 +1,448 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, Pencil, Plus, Trash2, Workflow } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/workflow")({
component: WorkflowSettingsPage,
});
interface Stage {
id: string;
key: string;
label: string;
sort_order: number;
}
interface TaskTpl {
id: string;
stage_key: string;
title: string;
days_to_due: number;
sort_order: number;
}
function WorkflowSettingsPage() {
const [stages, setStages] = useState<Stage[]>([]);
const [tasks, setTasks] = useState<TaskTpl[]>([]);
const [loading, setLoading] = useState(true);
const [stageEdit, setStageEdit] = useState<Partial<Stage> | null>(null);
const [taskEdit, setTaskEdit] = useState<Partial<TaskTpl> | null>(null);
const load = async () => {
setLoading(true);
const [{ data: s }, { data: t }] = await Promise.all([
supabase.from("collection_workflow_stages").select("*").order("sort_order"),
supabase
.from("collection_workflow_tasks")
.select("*")
.order("sort_order"),
]);
setStages((s ?? []) as Stage[]);
setTasks((t ?? []) as TaskTpl[]);
setLoading(false);
};
useEffect(() => {
load();
}, []);
const removeStage = async (id: string) => {
if (
!confirm(
"Delete this stage? Its task templates will be removed and any active collections will lose their stage reference.",
)
)
return;
const { error } = await supabase
.from("collection_workflow_stages")
.delete()
.eq("id", id);
if (error) toast.error("Could not delete", { description: error.message });
else {
toast.success("Stage deleted");
load();
}
};
const removeTask = async (id: string) => {
if (!confirm("Delete this task template?")) return;
const { error } = await supabase
.from("collection_workflow_tasks")
.delete()
.eq("id", id);
if (error) toast.error("Could not delete", { description: error.message });
else {
toast.success("Task removed");
load();
}
};
return (
<div className="space-y-6 max-w-4xl">
<p className="text-sm text-muted-foreground">
Define escalation stages for homeowner collections. Each stage has a
checklist of tasks that auto-populate when you click "Escalate" on a
collection.
</p>
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-serif text-lg flex items-center gap-2">
<Workflow className="h-4 w-4 text-muted-foreground" />
Stages
</h3>
<Button
size="sm"
onClick={() =>
setStageEdit({
sort_order: (stages[stages.length - 1]?.sort_order ?? 0) + 10,
})
}
>
<Plus className="h-3.5 w-3.5 mr-1" /> Add stage
</Button>
</div>
<div className="space-y-3">
{loading ? (
<Card className="border-border/60">
<CardContent className="p-6 text-center text-sm text-muted-foreground">
Loading…
</CardContent>
</Card>
) : (
stages.map((s) => {
const stageTasks = tasks.filter((t) => t.stage_key === s.key);
return (
<Card key={s.id} className="border-border/60">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div>
<div className="font-serif text-base">{s.label}</div>
<div className="text-xs text-muted-foreground font-mono">
{s.key} · sort {s.sort_order}
</div>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setStageEdit(s)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => removeStage(s.id)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
<div className="border-t pt-3">
<div className="flex items-center justify-between mb-2">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
Task checklist
</Label>
<Button
variant="outline"
size="sm"
onClick={() =>
setTaskEdit({
stage_key: s.key,
days_to_due: 7,
sort_order:
(stageTasks[stageTasks.length - 1]?.sort_order ??
0) + 10,
})
}
>
<Plus className="h-3 w-3 mr-1" /> Task
</Button>
</div>
{stageTasks.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No tasks. Escalating to this stage won't create any.
</p>
) : (
<ul className="space-y-1">
{stageTasks.map((t) => (
<li
key={t.id}
className="flex items-center gap-2 text-sm"
>
<span className="flex-1">{t.title}</span>
<span className="text-xs text-muted-foreground">
due in {t.days_to_due}d
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setTaskEdit(t)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => removeTask(t.id)}
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</li>
))}
</ul>
)}
</div>
</CardContent>
</Card>
);
})
)}
</div>
</div>
<StageDialog
item={stageEdit}
onClose={() => setStageEdit(null)}
onSaved={load}
/>
<TaskDialog
item={taskEdit}
stages={stages}
onClose={() => setTaskEdit(null)}
onSaved={load}
/>
</div>
);
}
function StageDialog({
item,
onClose,
onSaved,
}: {
item: Partial<Stage> | null;
onClose: () => void;
onSaved: () => void;
}) {
const [form, setForm] = useState<Partial<Stage>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (item) setForm(item);
}, [item]);
const submit = async () => {
if (!form.label?.trim()) return toast.error("Label required");
const key =
form.key?.trim() ||
form.label
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
setSaving(true);
const payload = {
key,
label: form.label.trim(),
sort_order: Number(form.sort_order) || 0,
};
const { error } = form.id
? await supabase
.from("collection_workflow_stages")
.update(payload)
.eq("id", form.id)
: await supabase.from("collection_workflow_stages").insert(payload);
setSaving(false);
if (error) {
toast.error("Could not save", { description: error.message });
return;
}
toast.success("Stage saved");
onClose();
onSaved();
};
return (
<Dialog open={!!item} onOpenChange={(v) => !v && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="font-serif">
{form.id ? "Edit stage" : "New stage"}
</DialogTitle>
<DialogDescription>
Stages appear in sort order on every collection's escalation path.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Label</Label>
<Input
value={form.label ?? ""}
onChange={(e) => setForm({ ...form, label: e.target.value })}
placeholder="e.g. Notice of Intent to Lien"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Key (identifier)</Label>
<Input
value={form.key ?? ""}
onChange={(e) => setForm({ ...form, key: e.target.value })}
placeholder="auto-generated from label"
disabled={!!form.id}
/>
</div>
<div>
<Label>Sort order</Label>
<Input
type="number"
value={form.sort_order ?? 0}
onChange={(e) =>
setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })
}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function TaskDialog({
item,
stages,
onClose,
onSaved,
}: {
item: Partial<TaskTpl> | null;
stages: Stage[];
onClose: () => void;
onSaved: () => void;
}) {
const [form, setForm] = useState<Partial<TaskTpl>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (item) setForm(item);
}, [item]);
const submit = async () => {
if (!form.title?.trim()) return toast.error("Title required");
if (!form.stage_key) return toast.error("Stage required");
setSaving(true);
const payload = {
stage_key: form.stage_key,
title: form.title.trim(),
days_to_due: Number(form.days_to_due) || 7,
sort_order: Number(form.sort_order) || 0,
};
const { error } = form.id
? await supabase
.from("collection_workflow_tasks")
.update(payload)
.eq("id", form.id)
: await supabase.from("collection_workflow_tasks").insert(payload);
setSaving(false);
if (error) {
toast.error("Could not save", { description: error.message });
return;
}
toast.success("Task saved");
onClose();
onSaved();
};
return (
<Dialog open={!!item} onOpenChange={(v) => !v && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="font-serif">
{form.id ? "Edit task template" : "New task template"}
</DialogTitle>
<DialogDescription>
Auto-created on collections when escalating to{" "}
{stages.find((s) => s.key === form.stage_key)?.label ?? "this stage"}.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Title</Label>
<Input
value={form.title ?? ""}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="e.g. Send certified mail"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Days until due</Label>
<Input
type="number"
min="0"
value={form.days_to_due ?? 7}
onChange={(e) =>
setForm({
...form,
days_to_due: parseInt(e.target.value) || 0,
})
}
/>
</div>
<div>
<Label>Sort order</Label>
<Input
type="number"
value={form.sort_order ?? 0}
onChange={(e) =>
setForm({
...form,
sort_order: parseInt(e.target.value) || 0,
})
}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}