From e4b17c1f8c7d380f4fe5287aac6052e76d15dd3e Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:42:18 +0000 Subject: [PATCH 01/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 30 +++++++++++++++++++ ...2_ca04f69b-9fb3-4d85-bd8f-dca0a67f08c7.sql | 14 +++++++++ 2 files changed, 44 insertions(+) create mode 100644 supabase/migrations/20260418234212_ca04f69b-9fb3-4d85-bd8f-dca0a67f08c7.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 6487e8a..8107b43 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -657,8 +657,10 @@ export type Database = { id: string sort_order: number stage_key: string | null + template_task_id: string | null title: string updated_at: string + workflow_template_id: string | null } Insert: { assignee_id?: string | null @@ -671,8 +673,10 @@ export type Database = { id?: string sort_order?: number stage_key?: string | null + template_task_id?: string | null title: string updated_at?: string + workflow_template_id?: string | null } Update: { assignee_id?: string | null @@ -685,8 +689,10 @@ export type Database = { id?: string sort_order?: number stage_key?: string | null + template_task_id?: string | null title?: string updated_at?: string + workflow_template_id?: string | null } Relationships: [ { @@ -710,6 +716,20 @@ export type Database = { referencedRelation: "collection_workflow_stages" referencedColumns: ["key"] }, + { + foreignKeyName: "collection_tasks_template_task_id_fkey" + columns: ["template_task_id"] + isOneToOne: false + referencedRelation: "workflow_template_tasks" + referencedColumns: ["id"] + }, + { + foreignKeyName: "collection_tasks_workflow_template_id_fkey" + columns: ["workflow_template_id"] + isOneToOne: false + referencedRelation: "workflow_templates" + referencedColumns: ["id"] + }, ] } collection_workflow_stages: { @@ -2701,6 +2721,7 @@ export type Database = { priority: Database["public"]["Enums"]["task_priority"] sort_order: number status: Database["public"]["Enums"]["task_status"] + template_task_id: string | null title: string updated_at: string workflow_template_id: string | null @@ -2719,6 +2740,7 @@ export type Database = { priority?: Database["public"]["Enums"]["task_priority"] sort_order?: number status?: Database["public"]["Enums"]["task_status"] + template_task_id?: string | null title: string updated_at?: string workflow_template_id?: string | null @@ -2737,6 +2759,7 @@ export type Database = { priority?: Database["public"]["Enums"]["task_priority"] sort_order?: number status?: Database["public"]["Enums"]["task_status"] + template_task_id?: string | null title?: string updated_at?: string workflow_template_id?: string | null @@ -2756,6 +2779,13 @@ export type Database = { referencedRelation: "tasks" referencedColumns: ["id"] }, + { + foreignKeyName: "tasks_template_task_id_fkey" + columns: ["template_task_id"] + isOneToOne: false + referencedRelation: "workflow_template_tasks" + referencedColumns: ["id"] + }, { foreignKeyName: "tasks_workflow_template_id_fkey" columns: ["workflow_template_id"] diff --git a/supabase/migrations/20260418234212_ca04f69b-9fb3-4d85-bd8f-dca0a67f08c7.sql b/supabase/migrations/20260418234212_ca04f69b-9fb3-4d85-bd8f-dca0a67f08c7.sql new file mode 100644 index 0000000..02cbbad --- /dev/null +++ b/supabase/migrations/20260418234212_ca04f69b-9fb3-4d85-bd8f-dca0a67f08c7.sql @@ -0,0 +1,14 @@ + +-- Add template_task_id to tasks for sequential chaining +ALTER TABLE public.tasks + ADD COLUMN IF NOT EXISTS template_task_id uuid REFERENCES public.workflow_template_tasks(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_tasks_template_task ON public.tasks(template_task_id); + +-- Allow workflow_templates on collections too +ALTER TABLE public.collection_tasks + ADD COLUMN IF NOT EXISTS workflow_template_id uuid REFERENCES public.workflow_templates(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS template_task_id uuid REFERENCES public.workflow_template_tasks(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_collection_tasks_workflow_template ON public.collection_tasks(workflow_template_id); +CREATE INDEX IF NOT EXISTS idx_collection_tasks_template_task ON public.collection_tasks(template_task_id); From 2aafeb6d6be37b79916a037aff3f9f969ceca12e Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:42:51 +0000 Subject: [PATCH 02/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/workflow-chain.ts | 151 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/lib/workflow-chain.ts diff --git a/src/lib/workflow-chain.ts b/src/lib/workflow-chain.ts new file mode 100644 index 0000000..660dedd --- /dev/null +++ b/src/lib/workflow-chain.ts @@ -0,0 +1,151 @@ +import { supabase } from "@/integrations/supabase/client"; +import { addDays, format } from "date-fns"; + +/** + * After a task is marked complete, spawn the next task from its workflow + * template (if any). Sequential mode: only one task at a time per chain. + * + * Returns the new task id, or null if nothing was spawned. + */ +export async function spawnNextWorkflowTask(taskId: string): Promise { + // Pull the just-completed task's workflow context + const { data: task } = await supabase + .from("tasks") + .select("id, case_id, workflow_template_id, template_task_id, created_by, completed_at") + .eq("id", taskId) + .maybeSingle(); + + if (!task?.workflow_template_id || !task.template_task_id) return null; + + // Find current template task to know its sort_order + const { data: currentTpl } = await supabase + .from("workflow_template_tasks") + .select("id, sort_order") + .eq("id", task.template_task_id) + .maybeSingle(); + if (!currentTpl) return null; + + // Find the next template task (smallest sort_order greater than current) + const { data: nextTpl } = await supabase + .from("workflow_template_tasks") + .select("*") + .eq("template_id", task.workflow_template_id) + .gt("sort_order", currentTpl.sort_order) + .order("sort_order", { ascending: true }) + .limit(1) + .maybeSingle(); + + if (!nextTpl) return null; + + // Don't double-spawn: skip if there's already a task in this chain for that template_task_id + const { data: existing } = await supabase + .from("tasks") + .select("id") + .eq("workflow_template_id", task.workflow_template_id) + .eq("template_task_id", nextTpl.id) + .eq("case_id", task.case_id ?? "") + .maybeSingle(); + if (existing) return existing.id; + + const baseDate = task.completed_at ? new Date(task.completed_at) : new Date(); + const dueDate = format(addDays(baseDate, nextTpl.days_from_start ?? 0), "yyyy-MM-dd"); + + const { data: created, error } = await supabase + .from("tasks") + .insert({ + title: nextTpl.title, + description: nextTpl.description, + priority: nextTpl.priority, + due_date: dueDate, + case_id: task.case_id, + workflow_template_id: task.workflow_template_id, + template_task_id: nextTpl.id, + created_by: task.created_by, + sort_order: nextTpl.sort_order, + }) + .select("id") + .single(); + + if (error || !created) return null; + + if (nextTpl.default_assignee_id) { + await supabase.from("task_assignees").insert({ + task_id: created.id, + user_id: nextTpl.default_assignee_id, + assigned_by: task.created_by, + }); + } + await supabase.from("task_history").insert({ + task_id: created.id, + actor_id: task.created_by, + event: "spawned_from_workflow", + detail: { template_id: task.workflow_template_id, after_task: taskId }, + }); + + return created.id; +} + +/** + * Same idea for collection_tasks (collections workflow chain). + */ +export async function spawnNextCollectionWorkflowTask( + collectionTaskId: string, +): Promise { + const { data: ct } = await supabase + .from("collection_tasks") + .select( + "id, collection_id, workflow_template_id, template_task_id, created_by, done_at, assignee_id", + ) + .eq("id", collectionTaskId) + .maybeSingle(); + + if (!ct?.workflow_template_id || !ct.template_task_id) return null; + + const { data: currentTpl } = await supabase + .from("workflow_template_tasks") + .select("id, sort_order") + .eq("id", ct.template_task_id) + .maybeSingle(); + if (!currentTpl) return null; + + const { data: nextTpl } = await supabase + .from("workflow_template_tasks") + .select("*") + .eq("template_id", ct.workflow_template_id) + .gt("sort_order", currentTpl.sort_order) + .order("sort_order", { ascending: true }) + .limit(1) + .maybeSingle(); + + if (!nextTpl) return null; + + const { data: existing } = await supabase + .from("collection_tasks") + .select("id") + .eq("collection_id", ct.collection_id) + .eq("workflow_template_id", ct.workflow_template_id) + .eq("template_task_id", nextTpl.id) + .maybeSingle(); + if (existing) return existing.id; + + const baseDate = ct.done_at ? new Date(ct.done_at) : new Date(); + const dueDate = format(addDays(baseDate, nextTpl.days_from_start ?? 0), "yyyy-MM-dd"); + + const { data: created, error } = await supabase + .from("collection_tasks") + .insert({ + collection_id: ct.collection_id, + title: nextTpl.title, + due_date: dueDate, + sort_order: nextTpl.sort_order, + assignee_id: nextTpl.default_assignee_id ?? null, + workflow_template_id: ct.workflow_template_id, + template_task_id: nextTpl.id, + created_by: ct.created_by, + }) + .select("id") + .single(); + + if (error || !created) return null; + return created.id; +} From b9fdd33dbc8031c01afd5cdf4e4044a856d60714 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:43:06 +0000 Subject: [PATCH 03/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/tasks-tab.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/cases/tasks-tab.tsx b/src/components/cases/tasks-tab.tsx index b01488a..eb12757 100644 --- a/src/components/cases/tasks-tab.tsx +++ b/src/components/cases/tasks-tab.tsx @@ -95,6 +95,10 @@ export function CaseTasksTab({ caseId }: { caseId: string }) { completed_at: checked ? (new Date().toISOString() as any) : null, }) .eq("id", t.id); + if (checked) { + // Spawn the next task in the workflow chain (if any) + await spawnNextWorkflowTask(t.id); + } }; const today = startOfDay(new Date()); From 06537f508ba4eb032d5a13402730ee07bc5f9dbe Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:43:16 +0000 Subject: [PATCH 04/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/tasks-tab.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/cases/tasks-tab.tsx b/src/components/cases/tasks-tab.tsx index eb12757..024aa9c 100644 --- a/src/components/cases/tasks-tab.tsx +++ b/src/components/cases/tasks-tab.tsx @@ -11,6 +11,7 @@ import { TaskDetailPanel } from "@/components/tasks/task-detail-panel"; import { NewTaskDialog } from "@/components/tasks/new-task-dialog"; import { ApplyWorkflowDialog } from "@/components/tasks/apply-workflow-dialog"; import { cn } from "@/lib/utils"; +import { spawnNextWorkflowTask } from "@/lib/workflow-chain"; interface TaskRow { id: string; From 92f2fda5661a7640b98f6f2d622a69f1223008c7 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:43:30 +0000 Subject: [PATCH 05/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/tasks/task-detail-panel.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/tasks/task-detail-panel.tsx b/src/components/tasks/task-detail-panel.tsx index a99b006..b2eaa9e 100644 --- a/src/components/tasks/task-detail-panel.tsx +++ b/src/components/tasks/task-detail-panel.tsx @@ -193,6 +193,9 @@ export function TaskDetailPanel({ }, checked ? "completed" : "reopened", ); + if (checked) { + await spawnNextWorkflowTask(task.id); + } }; const saveTitle = async () => { From d650594b56fd1cd5d364050755fb5773afb9f44b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:43:39 +0000 Subject: [PATCH 06/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/tasks/task-detail-panel.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/tasks/task-detail-panel.tsx b/src/components/tasks/task-detail-panel.tsx index b2eaa9e..cefb154 100644 --- a/src/components/tasks/task-detail-panel.tsx +++ b/src/components/tasks/task-detail-panel.tsx @@ -28,6 +28,7 @@ import { } from "@/components/ui/popover"; import { Calendar } from "@/components/ui/calendar"; import { toast } from "sonner"; +import { spawnNextWorkflowTask } from "@/lib/workflow-chain"; import { Loader2, Plus, Trash2, X, CalendarIcon, UserPlus } from "lucide-react"; import { format } from "date-fns"; import type { Database } from "@/integrations/supabase/types"; From 5846dcfbaddc1b74784f154f9fd68d03392aebb7 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:43:48 +0000 Subject: [PATCH 07/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/tasks.index.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/routes/tasks.index.tsx b/src/routes/tasks.index.tsx index ba5b12a..7a20614 100644 --- a/src/routes/tasks.index.tsx +++ b/src/routes/tasks.index.tsx @@ -22,6 +22,7 @@ import { format, isBefore, startOfDay, isSameDay, addDays, differenceInCalendarD import { TaskDetailPanel } from "@/components/tasks/task-detail-panel"; import { NewTaskDialog } from "@/components/tasks/new-task-dialog"; import { cn } from "@/lib/utils"; +import { spawnNextWorkflowTask } from "@/lib/workflow-chain"; export const Route = createFileRoute("/tasks/")({ component: TasksPage, @@ -189,6 +190,9 @@ function TasksPage() { event: checked ? "completed" : "reopened", detail: {}, }); + if (checked) { + await spawnNextWorkflowTask(t.id); + } }; const overdueCount = grouped.Overdue.length; From 096f9171bba2e1e3c4fdf179778245d93233384c Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:44:09 +0000 Subject: [PATCH 08/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- .../tasks/apply-workflow-dialog.tsx | 79 ++++++++++--------- 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/src/components/tasks/apply-workflow-dialog.tsx b/src/components/tasks/apply-workflow-dialog.tsx index 5f39b3a..f77f25a 100644 --- a/src/components/tasks/apply-workflow-dialog.tsx +++ b/src/components/tasks/apply-workflow-dialog.tsx @@ -71,54 +71,59 @@ export function ApplyWorkflowDialog({ toast.error("Template has no tasks."); return; } - const inserts = items.map((it: any, idx: number) => ({ - title: it.title, - description: it.description, - priority: it.priority, - due_date: format(addDays(startDate, it.days_from_start), "yyyy-MM-dd"), - case_id: caseId, - workflow_template_id: templateId, - created_by: user.id, - sort_order: idx, - })); - const { data: created, error } = await supabase.from("tasks").insert(inserts).select("id"); + // Sequential mode: only spawn the FIRST task. Subsequent tasks are + // auto-spawned when the previous one is marked complete. + const first = items[0]; + const { data: created, error } = await supabase + .from("tasks") + .insert({ + title: first.title, + description: first.description, + priority: first.priority, + due_date: format(addDays(startDate, first.days_from_start), "yyyy-MM-dd"), + case_id: caseId, + workflow_template_id: templateId, + template_task_id: first.id, + created_by: user.id, + sort_order: 0, + }) + .select("id") + .single(); if (error || !created) { setSubmitting(false); toast.error(error?.message || "Failed to apply"); return; } - // Insert default assignees + history - const assigneeRows: any[] = []; - const historyRows: any[] = []; - const notifs: any[] = []; - created.forEach((row, idx) => { - const def = items[idx].default_assignee_id; - if (def) { - assigneeRows.push({ task_id: row.id, user_id: def, assigned_by: user.id }); - if (def !== user.id) { - notifs.push({ - user_id: def, + if (first.default_assignee_id) { + await supabase.from("task_assignees").insert({ + task_id: created.id, + user_id: first.default_assignee_id, + assigned_by: user.id, + }); + if (first.default_assignee_id !== user.id) { + await createNotifications([ + { + user_id: first.default_assignee_id, kind: "task_assigned", - title: `Assigned: ${items[idx].title}`, - link: `/tasks?task=${row.id}`, - task_id: row.id, + title: `Assigned: ${first.title}`, + link: `/tasks?task=${created.id}`, + task_id: created.id, case_id: caseId, created_by: user.id, - }); - } + }, + ]); } - historyRows.push({ - task_id: row.id, - actor_id: user.id, - event: "created_from_workflow", - detail: { template_id: templateId }, - }); + } + await supabase.from("task_history").insert({ + task_id: created.id, + actor_id: user.id, + event: "created_from_workflow", + detail: { template_id: templateId, sequential: true, total_steps: items.length }, }); - if (assigneeRows.length) await supabase.from("task_assignees").insert(assigneeRows); - if (historyRows.length) await supabase.from("task_history").insert(historyRows); - if (notifs.length) await createNotifications(notifs); setSubmitting(false); - toast.success(`Created ${created.length} tasks`); + toast.success( + `Workflow started — ${items.length} step${items.length === 1 ? "" : "s"} will run sequentially.`, + ); onApplied?.(); onOpenChange(false); }; From 732aa9f4173a404c8e0503bc8a219986ada0b766 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:44:20 +0000 Subject: [PATCH 09/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/tasks/apply-workflow-dialog.tsx | 2 +- src/routes/settings.workflows.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/tasks/apply-workflow-dialog.tsx b/src/components/tasks/apply-workflow-dialog.tsx index f77f25a..e55754a 100644 --- a/src/components/tasks/apply-workflow-dialog.tsx +++ b/src/components/tasks/apply-workflow-dialog.tsx @@ -149,7 +149,7 @@ export function ApplyWorkflowDialog({
- + + + + d && setStartDate(d)} /> + + +
+

+ Only the first task is created now. Each subsequent task is auto-created when the + previous one is marked complete, with a due date X days after completion. +

+ + + + + + + + ); +} From 518291f51816c01ed0c9f77d16cee015d5584593 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:45:17 +0000 Subject: [PATCH 11/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/collections.$collectionId.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/routes/collections.$collectionId.tsx b/src/routes/collections.$collectionId.tsx index 3b63c37..dddfe24 100644 --- a/src/routes/collections.$collectionId.tsx +++ b/src/routes/collections.$collectionId.tsx @@ -61,6 +61,8 @@ import { Tag, } from "lucide-react"; import { toast } from "sonner"; +import { spawnNextCollectionWorkflowTask } from "@/lib/workflow-chain"; +import { ApplyCollectionWorkflowDialog } from "@/components/collections/apply-collection-workflow-dialog"; export const Route = createFileRoute("/collections/$collectionId")({ component: CollectionDetailRoute, @@ -224,8 +226,15 @@ function CollectionDetailRoute() { done_at: next ? new Date().toISOString() : null, }) .eq("id", t.id); - if (error) toast.error("Could not update", { description: error.message }); - else refreshTasks(); + if (error) { + toast.error("Could not update", { description: error.message }); + return; + } + if (next) { + // Auto-spawn the next workflow task in the chain (if any) + await spawnNextCollectionWorkflowTask(t.id); + } + refreshTasks(); }; const deleteTask = async (id: string) => { From 17a5b5eff5a4c8f583525491edfd70307fd1e41c Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:45:33 +0000 Subject: [PATCH 12/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/collections.$collectionId.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/routes/collections.$collectionId.tsx b/src/routes/collections.$collectionId.tsx index dddfe24..e7537b6 100644 --- a/src/routes/collections.$collectionId.tsx +++ b/src/routes/collections.$collectionId.tsx @@ -104,6 +104,7 @@ function CollectionDetailRoute() { const [loading, setLoading] = useState(true); const [escalating, setEscalating] = useState(false); const [addTaskOpen, setAddTaskOpen] = useState(false); + const [applyWfOpen, setApplyWfOpen] = useState(false); const [reload, setReload] = useState(0); const [invoiceOpen, setInvoiceOpen] = useState(false); @@ -400,9 +401,14 @@ function CollectionDetailRoute() { )} - +
+ + +
{tasks.length === 0 ? (

From 883884aed0c541d80dc86a2be295bd3c108d6357 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:45:45 +0000 Subject: [PATCH 13/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/collections.$collectionId.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/routes/collections.$collectionId.tsx b/src/routes/collections.$collectionId.tsx index e7537b6..bb05def 100644 --- a/src/routes/collections.$collectionId.tsx +++ b/src/routes/collections.$collectionId.tsx @@ -532,6 +532,13 @@ function CollectionDetailRoute() { onSaved={refreshTasks} /> + + {collection.case?.client?.id && (