Added workflow & tasks UI
X-Lovable-Edit-ID: edt-f7ed0edd-9913-4ea1-a2e8-4e4c881f00f2 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
Scale,
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -28,6 +29,7 @@ const NAV: NavItem[] = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/clients", label: "Clients", icon: Users },
|
||||
{ to: "/cases", label: "Cases", icon: Briefcase },
|
||||
{ to: "/collections", label: "Collections", icon: Wallet },
|
||||
{ to: "/invoices", label: "Invoices", icon: Receipt },
|
||||
{ to: "/admin/users", label: "Users", icon: ShieldCheck, adminOnly: true },
|
||||
{ to: "/settings", label: "Settings", icon: Settings, adminOnly: true },
|
||||
|
||||
@@ -299,7 +299,7 @@ export function CaseCollectionsTab({
|
||||
}
|
||||
|
||||
// ─── Collection detail (ledger) ─────────────────────────────────────
|
||||
function CollectionDetail({
|
||||
export function CollectionDetail({
|
||||
collection,
|
||||
annualRate,
|
||||
currentBalance,
|
||||
|
||||
@@ -250,12 +250,145 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
collection_tasks: {
|
||||
Row: {
|
||||
assignee_id: string | null
|
||||
collection_id: string
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
done: boolean
|
||||
done_at: string | null
|
||||
due_date: string | null
|
||||
id: string
|
||||
sort_order: number
|
||||
stage_key: string | null
|
||||
title: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
assignee_id?: string | null
|
||||
collection_id: string
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
done?: boolean
|
||||
done_at?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
sort_order?: number
|
||||
stage_key?: string | null
|
||||
title: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
assignee_id?: string | null
|
||||
collection_id?: string
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
done?: boolean
|
||||
done_at?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
sort_order?: number
|
||||
stage_key?: string | null
|
||||
title?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "collection_tasks_assignee_id_fkey"
|
||||
columns: ["assignee_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "collection_tasks_collection_id_fkey"
|
||||
columns: ["collection_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "collections"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "collection_tasks_stage_key_fkey"
|
||||
columns: ["stage_key"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "collection_workflow_stages"
|
||||
referencedColumns: ["key"]
|
||||
},
|
||||
]
|
||||
}
|
||||
collection_workflow_stages: {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: string
|
||||
key: string
|
||||
label: string
|
||||
sort_order: number
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
key: string
|
||||
label: string
|
||||
sort_order?: number
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
key?: string
|
||||
label?: string
|
||||
sort_order?: number
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
collection_workflow_tasks: {
|
||||
Row: {
|
||||
created_at: string
|
||||
days_to_due: number
|
||||
id: string
|
||||
sort_order: number
|
||||
stage_key: string
|
||||
title: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
days_to_due?: number
|
||||
id?: string
|
||||
sort_order?: number
|
||||
stage_key: string
|
||||
title: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
days_to_due?: number
|
||||
id?: string
|
||||
sort_order?: number
|
||||
stage_key?: string
|
||||
title?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "collection_workflow_tasks_stage_key_fkey"
|
||||
columns: ["stage_key"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "collection_workflow_stages"
|
||||
referencedColumns: ["key"]
|
||||
},
|
||||
]
|
||||
}
|
||||
collections: {
|
||||
Row: {
|
||||
case_id: string
|
||||
closed_at: string | null
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
current_stage: string | null
|
||||
homeowner_id: string
|
||||
id: string
|
||||
notes: string | null
|
||||
@@ -268,6 +401,7 @@ export type Database = {
|
||||
closed_at?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
current_stage?: string | null
|
||||
homeowner_id: string
|
||||
id?: string
|
||||
notes?: string | null
|
||||
@@ -280,6 +414,7 @@ export type Database = {
|
||||
closed_at?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
current_stage?: string | null
|
||||
homeowner_id?: string
|
||||
id?: string
|
||||
notes?: string | null
|
||||
@@ -295,6 +430,13 @@ export type Database = {
|
||||
referencedRelation: "cases"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "collections_current_stage_fkey"
|
||||
columns: ["current_stage"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "collection_workflow_stages"
|
||||
referencedColumns: ["key"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "collections_homeowner_id_fkey"
|
||||
columns: ["homeowner_id"]
|
||||
|
||||
@@ -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,8 +355,10 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
CasesCaseIdRoute: CasesCaseIdRoute,
|
||||
CasesNewRoute: CasesNewRoute,
|
||||
ClientsClientIdRoute: ClientsClientIdRoute,
|
||||
CollectionsCollectionIdRoute: CollectionsCollectionIdRoute,
|
||||
CasesIndexRoute: CasesIndexRoute,
|
||||
ClientsIndexRoute: ClientsIndexRoute,
|
||||
CollectionsIndexRoute: CollectionsIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const Route = createFileRoute("/settings")({
|
||||
const TABS = [
|
||||
{ to: "/settings", label: "Company", exact: true },
|
||||
{ to: "/settings/fees", label: "Fee schedule" },
|
||||
{ to: "/settings/workflow", label: "Collections workflow" },
|
||||
];
|
||||
|
||||
function SettingsLayout() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
CREATE TABLE public.collection_workflow_stages (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key text NOT NULL UNIQUE,
|
||||
label text NOT NULL,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE public.collection_workflow_stages ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "stages_select_auth" ON public.collection_workflow_stages FOR SELECT TO authenticated USING (true);
|
||||
CREATE POLICY "stages_insert_admin" ON public.collection_workflow_stages FOR INSERT TO authenticated WITH CHECK (public.is_admin(auth.uid()));
|
||||
CREATE POLICY "stages_update_admin" ON public.collection_workflow_stages FOR UPDATE TO authenticated USING (public.is_admin(auth.uid()));
|
||||
CREATE POLICY "stages_delete_admin" ON public.collection_workflow_stages FOR DELETE TO authenticated USING (public.is_admin(auth.uid()));
|
||||
CREATE TRIGGER tg_stages_updated_at BEFORE UPDATE ON public.collection_workflow_stages FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
CREATE TABLE public.collection_workflow_tasks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
stage_key text NOT NULL REFERENCES public.collection_workflow_stages(key) ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
days_to_due integer NOT NULL DEFAULT 7,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE public.collection_workflow_tasks ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "wf_tasks_select_auth" ON public.collection_workflow_tasks FOR SELECT TO authenticated USING (true);
|
||||
CREATE POLICY "wf_tasks_insert_admin" ON public.collection_workflow_tasks FOR INSERT TO authenticated WITH CHECK (public.is_admin(auth.uid()));
|
||||
CREATE POLICY "wf_tasks_update_admin" ON public.collection_workflow_tasks FOR UPDATE TO authenticated USING (public.is_admin(auth.uid()));
|
||||
CREATE POLICY "wf_tasks_delete_admin" ON public.collection_workflow_tasks FOR DELETE TO authenticated USING (public.is_admin(auth.uid()));
|
||||
CREATE TRIGGER tg_wf_tasks_updated_at BEFORE UPDATE ON public.collection_workflow_tasks FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
ALTER TABLE public.collections ADD COLUMN current_stage text REFERENCES public.collection_workflow_stages(key) ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE public.collection_tasks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
collection_id uuid NOT NULL REFERENCES public.collections(id) ON DELETE CASCADE,
|
||||
stage_key text REFERENCES public.collection_workflow_stages(key) ON UPDATE CASCADE,
|
||||
title text NOT NULL,
|
||||
due_date date,
|
||||
assignee_id uuid REFERENCES public.profiles(id),
|
||||
done boolean NOT NULL DEFAULT false,
|
||||
done_at timestamptz,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE public.collection_tasks ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "ctasks_select_case" ON public.collection_tasks FOR SELECT TO authenticated USING (EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_tasks.collection_id AND public.can_access_case(c.case_id, auth.uid())));
|
||||
CREATE POLICY "ctasks_insert_case" ON public.collection_tasks FOR INSERT TO authenticated WITH CHECK (EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_tasks.collection_id AND public.can_access_case(c.case_id, auth.uid())));
|
||||
CREATE POLICY "ctasks_update_case" ON public.collection_tasks FOR UPDATE TO authenticated USING (EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_tasks.collection_id AND public.can_access_case(c.case_id, auth.uid())));
|
||||
CREATE POLICY "ctasks_delete_case" ON public.collection_tasks FOR DELETE TO authenticated USING (EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_tasks.collection_id AND public.can_access_case(c.case_id, auth.uid())));
|
||||
CREATE TRIGGER tg_ctasks_updated_at BEFORE UPDATE ON public.collection_tasks FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
CREATE INDEX idx_ctasks_collection ON public.collection_tasks(collection_id);
|
||||
CREATE INDEX idx_ctasks_assignee_open ON public.collection_tasks(assignee_id) WHERE done = false;
|
||||
|
||||
INSERT INTO public.collection_workflow_stages (key, label, sort_order) VALUES
|
||||
('notice_of_late_assessment', 'Notice of Late Assessment', 10),
|
||||
('notice_of_intent_to_lien', 'Notice of Intent to Lien', 20),
|
||||
('notice_of_intent_to_foreclose', 'Notice of Intent to Foreclose', 30),
|
||||
('lien_foreclosure', 'Lien Foreclosure', 40);
|
||||
|
||||
INSERT INTO public.collection_workflow_tasks (stage_key, title, days_to_due, sort_order) VALUES
|
||||
('notice_of_late_assessment', 'Send Notice of Late Assessment letter', 7, 10),
|
||||
('notice_of_late_assessment', 'Confirm delivery / certified mail receipt', 14, 20),
|
||||
('notice_of_intent_to_lien', 'Prepare and send Notice of Intent to Lien', 7, 10),
|
||||
('notice_of_intent_to_lien', 'Wait statutory cure period', 30, 20),
|
||||
('notice_of_intent_to_foreclose', 'File and record lien with county recorder', 7, 10),
|
||||
('notice_of_intent_to_foreclose', 'Serve Notice of Intent to Foreclose', 14, 20),
|
||||
('lien_foreclosure', 'Engage trustee / file foreclosure complaint', 14, 10),
|
||||
('lien_foreclosure', 'Schedule foreclosure sale', 60, 20);
|
||||
|
||||
UPDATE public.collections SET current_stage = 'notice_of_late_assessment' WHERE current_stage IS NULL;
|
||||
Reference in New Issue
Block a user