From 2c5bb6cb50cdc11ed8f6fef7391ddc32c169df9d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:13:25 +0000 Subject: [PATCH 1/6] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 142 ++++++++++++++++++ ...2_d0143111-f431-43ae-bac6-df5c9315f6c1.sql | 73 +++++++++ 2 files changed, 215 insertions(+) create mode 100644 supabase/migrations/20260417011322_d0143111-f431-43ae-bac6-df5c9315f6c1.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index fd008f6..8e7d9c6 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -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"] diff --git a/supabase/migrations/20260417011322_d0143111-f431-43ae-bac6-df5c9315f6c1.sql b/supabase/migrations/20260417011322_d0143111-f431-43ae-bac6-df5c9315f6c1.sql new file mode 100644 index 0000000..e20e1f0 --- /dev/null +++ b/supabase/migrations/20260417011322_d0143111-f431-43ae-bac6-df5c9315f6c1.sql @@ -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; \ No newline at end of file From 8c51bb002e1d53aeec2674eb7135657e685bebf0 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:13:41 +0000 Subject: [PATCH 2/6] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/collections-tab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/cases/collections-tab.tsx b/src/components/cases/collections-tab.tsx index bfa2873..fc9a269 100644 --- a/src/components/cases/collections-tab.tsx +++ b/src/components/cases/collections-tab.tsx @@ -299,7 +299,7 @@ export function CaseCollectionsTab({ } // ─── Collection detail (ledger) ───────────────────────────────────── -function CollectionDetail({ +export function CollectionDetail({ collection, annualRate, currentBalance, From 46ed67e332141b95b330e28e66351ce5f2c9fa52 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:13:55 +0000 Subject: [PATCH 3/6] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/app-shell.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index db29988..cc2e251 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -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 }, From ed20e47fd42bc8625d4b4f3db40e606a3c402b92 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:14:01 +0000 Subject: [PATCH 4/6] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/settings.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index 5f3e848..885d169 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -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() { From 12bf212912f8715cb0a01c2d7e32d04220f504c0 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:16:02 +0000 Subject: [PATCH 5/6] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routeTree.gen.ts | 72 +++ src/routes/collections.$collectionId.tsx | 568 +++++++++++++++++++++++ src/routes/collections.index.tsx | 251 ++++++++++ src/routes/settings.workflow.tsx | 448 ++++++++++++++++++ 4 files changed, 1339 insertions(+) create mode 100644 src/routes/collections.$collectionId.tsx create mode 100644 src/routes/collections.index.tsx create mode 100644 src/routes/settings.workflow.tsx diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 2be5b92..e26b158 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/src/routes/collections.$collectionId.tsx b/src/routes/collections.$collectionId.tsx new file mode 100644 index 0000000..ac99102 --- /dev/null +++ b/src/routes/collections.$collectionId.tsx @@ -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(null); + const [stages, setStages] = useState([]); + const [profiles, setProfiles] = useState([]); + const [tasks, setTasks] = useState([]); + 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 ( + + +
Loading…
+
+
+ ); + } + + if (!collection) { + return ( + + +
+ Collection not found.{" "} + + Back + +
+
+
+ ); + } + + 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 ( + + + + + {/* Workflow header */} + + +
+
+ Workflow stage +
+
+ {stages.map((s, i) => { + const isCurrent = s.key === collection.current_stage; + const isPast = i < currentStageIdx; + return ( +
+ + {s.label} + + {i < stages.length - 1 && ( + + )} +
+ ); + })} +
+
+ +
+
+ + {/* Tasks */} + + +
+

+ + Tasks + {openTasks.length > 0 && ( + + {openTasks.length} open + + )} +

+ +
+ {tasks.length === 0 ? ( +

+ No tasks yet. Escalate to populate the next stage's checklist, or + add one manually. +

+ ) : ( +
+ {[...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 ( +
+ +
+
+ {t.title} +
+ {stageLabel && ( +
+ {stageLabel} +
+ )} +
+ updateDueDate(t.id, e.target.value)} + /> + + +
+ ); + })} +
+ )} +
+
+ + {/* Ledger */} + navigate({ to: "/collections" })} + onChange={() => setReload((r) => r + 1)} + /> + + +
+
+ ); +} + +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("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 ( + + + + Add task + + Manual task on this collection. Will be tagged with the current stage. + + +
+
+ + setTitle(e.target.value)} + placeholder="e.g. Call homeowner re: payment plan" + /> +
+
+
+ + setDueDate(e.target.value)} + /> +
+
+ + +
+
+
+ + + + +
+
+ ); +} diff --git a/src/routes/collections.index.tsx b/src/routes/collections.index.tsx new file mode 100644 index 0000000..e87b9bc --- /dev/null +++ b/src/routes/collections.index.tsx @@ -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([]); + const [balances, setBalances] = useState>({}); + const [openTaskCounts, setOpenTaskCounts] = useState>({}); + const [stages, setStages] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [stageFilter, setStageFilter] = useState("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 = {}; + 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 = {}; + (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 ( + + + +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ + + + {loading ? ( +
+ Loading… +
+ ) : filtered.length === 0 ? ( +
+ + No collections found. +
+ ) : ( + + + + Homeowner + HOA / Case + Stage + Open tasks + Opened + Balance + + + + + {filtered.map((r) => { + const bal = balances[r.id] ?? 0; + const tcount = openTaskCounts[r.id] ?? 0; + return ( + {}} + > + + + {r.homeowner?.last_name}, {r.homeowner?.first_name} + {r.homeowner?.unit_number && ( + + · Unit {r.homeowner.unit_number} + + )} + + + +
{r.case?.client?.name}
+
+ {r.case?.case_number} · {r.case?.title} +
+
+ + + {stageLabel(r.current_stage)} + + + + {tcount > 0 ? ( + {tcount} + ) : ( + + — + + )} + + + {formatDate(r.opened_at)} + + 0 + ? "text-destructive font-semibold" + : bal < 0 + ? "text-emerald-600" + : "" + }`} + > + {formatCurrency(Math.abs(bal))} + {bal < 0 ? " CR" : ""} + + + + + + +
+ ); + })} +
+
+ )} +
+
+
+
+ ); +} diff --git a/src/routes/settings.workflow.tsx b/src/routes/settings.workflow.tsx new file mode 100644 index 0000000..be1b08f --- /dev/null +++ b/src/routes/settings.workflow.tsx @@ -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([]); + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [stageEdit, setStageEdit] = useState | null>(null); + const [taskEdit, setTaskEdit] = useState | 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 ( +
+

+ Define escalation stages for homeowner collections. Each stage has a + checklist of tasks that auto-populate when you click "Escalate" on a + collection. +

+ +
+
+

+ + Stages +

+ +
+ +
+ {loading ? ( + + + Loading… + + + ) : ( + stages.map((s) => { + const stageTasks = tasks.filter((t) => t.stage_key === s.key); + return ( + + +
+
+
{s.label}
+
+ {s.key} · sort {s.sort_order} +
+
+
+ + +
+
+ +
+
+ + +
+ {stageTasks.length === 0 ? ( +

+ No tasks. Escalating to this stage won't create any. +

+ ) : ( +
    + {stageTasks.map((t) => ( +
  • + {t.title} + + due in {t.days_to_due}d + + + +
  • + ))} +
+ )} +
+
+
+ ); + }) + )} +
+
+ + setStageEdit(null)} + onSaved={load} + /> + setTaskEdit(null)} + onSaved={load} + /> +
+ ); +} + +function StageDialog({ + item, + onClose, + onSaved, +}: { + item: Partial | null; + onClose: () => void; + onSaved: () => void; +}) { + const [form, setForm] = useState>({}); + 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 ( + !v && onClose()}> + + + + {form.id ? "Edit stage" : "New stage"} + + + Stages appear in sort order on every collection's escalation path. + + +
+
+ + setForm({ ...form, label: e.target.value })} + placeholder="e.g. Notice of Intent to Lien" + /> +
+
+
+ + setForm({ ...form, key: e.target.value })} + placeholder="auto-generated from label" + disabled={!!form.id} + /> +
+
+ + + setForm({ ...form, sort_order: parseInt(e.target.value) || 0 }) + } + /> +
+
+
+ + + + +
+
+ ); +} + +function TaskDialog({ + item, + stages, + onClose, + onSaved, +}: { + item: Partial | null; + stages: Stage[]; + onClose: () => void; + onSaved: () => void; +}) { + const [form, setForm] = useState>({}); + 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 ( + !v && onClose()}> + + + + {form.id ? "Edit task template" : "New task template"} + + + Auto-created on collections when escalating to{" "} + {stages.find((s) => s.key === form.stage_key)?.label ?? "this stage"}. + + +
+
+ + setForm({ ...form, title: e.target.value })} + placeholder="e.g. Send certified mail" + /> +
+
+
+ + + setForm({ + ...form, + days_to_due: parseInt(e.target.value) || 0, + }) + } + /> +
+
+ + + setForm({ + ...form, + sort_order: parseInt(e.target.value) || 0, + }) + } + /> +
+
+
+ + + + +
+
+ ); +} From 2b4219627aca7640017bbd3b448f5c85c98d7575 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:16:16 +0000 Subject: [PATCH 6/6] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routeTree.gen.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index e26b158..b27c8fb 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -363,12 +363,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } -}