Added sequential workflow chaining

X-Lovable-Edit-ID: edt-c73c4b18-614b-463f-bfde-d768d7ffd96e
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 23:46:00 +00:00
co-authored by renee-png
10 changed files with 428 additions and 44 deletions
+5
View File
@@ -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;
@@ -95,6 +96,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());
@@ -0,0 +1,149 @@
import { useState, useEffect } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { CalendarIcon, Loader2 } from "lucide-react";
import { format, addDays } from "date-fns";
import { toast } from "sonner";
interface Template {
id: string;
name: string;
}
export function ApplyCollectionWorkflowDialog({
open,
onOpenChange,
collectionId,
onApplied,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
collectionId: string;
onApplied?: () => void;
}) {
const { user } = useAuth();
const [templates, setTemplates] = useState<Template[]>([]);
const [templateId, setTemplateId] = useState<string | null>(null);
const [startDate, setStartDate] = useState<Date>(new Date());
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
supabase
.from("workflow_templates")
.select("id, name")
.order("name")
.then(({ data }) => setTemplates((data ?? []) as Template[]));
setTemplateId(null);
setStartDate(new Date());
}, [open]);
const apply = async () => {
if (!user || !templateId) return;
setSubmitting(true);
const { data: tpl } = await supabase
.from("workflow_template_tasks")
.select("*")
.eq("template_id", templateId)
.order("sort_order");
const items = tpl ?? [];
if (!items.length) {
setSubmitting(false);
toast.error("Template has no tasks.");
return;
}
const first = items[0];
const { error } = await supabase.from("collection_tasks").insert({
collection_id: collectionId,
title: first.title,
due_date: format(addDays(startDate, first.days_from_start), "yyyy-MM-dd"),
sort_order: 0,
assignee_id: first.default_assignee_id ?? null,
workflow_template_id: templateId,
template_task_id: first.id,
created_by: user.id,
});
setSubmitting(false);
if (error) {
toast.error(error.message || "Failed to apply");
return;
}
toast.success(
`Workflow started — ${items.length} step${items.length === 1 ? "" : "s"} will run sequentially.`,
);
onApplied?.();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Apply workflow to this collection</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs">Template</Label>
<Select value={templateId ?? ""} onValueChange={setTemplateId}>
<SelectTrigger>
<SelectValue placeholder="Select template…" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">First task due date (next steps chain off completion)</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start font-normal">
<CalendarIcon className="h-3.5 w-3.5 mr-2" />
{format(startDate, "MM/dd/yyyy")}
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Calendar mode="single" selected={startDate} onSelect={(d) => d && setStartDate(d)} />
</PopoverContent>
</Popover>
</div>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={apply} disabled={!templateId || submitting}>
{submitting && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Apply
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+43 -38
View File
@@ -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);
};
@@ -144,7 +149,7 @@ export function ApplyWorkflowDialog({
</Select>
</div>
<div>
<Label className="text-xs">Start date (due dates computed from this)</Label>
<Label className="text-xs">First task due date (next steps chain off completion)</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start font-normal">
@@ -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";
@@ -193,6 +194,9 @@ export function TaskDetailPanel({
},
checked ? "completed" : "reopened",
);
if (checked) {
await spawnNextWorkflowTask(task.id);
}
};
const saveTitle = async () => {
+30
View File
@@ -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"]
+151
View File
@@ -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<string | null> {
// 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<string | null> {
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;
}
+27 -5
View File
@@ -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,
@@ -102,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);
@@ -224,8 +227,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) => {
@@ -391,9 +401,14 @@ function CollectionDetailRoute() {
</Badge>
)}
</h3>
<Button size="sm" variant="outline" onClick={() => setAddTaskOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add task
</Button>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setApplyWfOpen(true)}>
<Workflow className="h-3.5 w-3.5 mr-1" /> Apply workflow
</Button>
<Button size="sm" variant="outline" onClick={() => setAddTaskOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add task
</Button>
</div>
</div>
{tasks.length === 0 ? (
<p className="text-sm text-muted-foreground italic py-4">
@@ -517,6 +532,13 @@ function CollectionDetailRoute() {
onSaved={refreshTasks}
/>
<ApplyCollectionWorkflowDialog
open={applyWfOpen}
onOpenChange={setApplyWfOpen}
collectionId={collection.id}
onApplied={refreshTasks}
/>
{collection.case?.client?.id && (
<GenerateInvoiceDialog
open={invoiceOpen}
+1 -1
View File
@@ -145,7 +145,7 @@ function WorkflowsPage() {
<div>
<h2 className="font-serif text-lg">Workflow Templates</h2>
<p className="text-sm text-muted-foreground">
Reusable task lists. Apply one to a case to spawn the tasks with computed due dates.
Sequential checklists. The first task is created when the workflow is applied; each next task spawns automatically when the previous one is marked complete, with its due date set X days after completion.
</p>
</div>
<Button onClick={() => setNewOpen(true)}>
+4
View File
@@ -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;
@@ -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);