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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user