Unified workflows to task

X-Lovable-Edit-ID: edt-c60ff743-af14-4367-ab8b-e5df53963c72
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 15:54:51 +00:00
co-authored by renee-png
5 changed files with 64 additions and 59 deletions
+1
View File
@@ -358,6 +358,7 @@ export function CaseCollectionsTab({
open={!!wfCollectionId}
onOpenChange={(v) => !v && setWfCollectionId(null)}
collectionId={wfCollectionId}
caseId={caseId}
onApplied={() => {
setWfCollectionId(null);
load();
@@ -22,21 +22,29 @@ import { Calendar } from "@/components/ui/calendar";
import { CalendarIcon, Loader2 } from "lucide-react";
import { format, addDays } from "date-fns";
import { toast } from "sonner";
import { createNotifications } from "@/lib/notifications";
interface Template {
id: string;
name: string;
}
/**
* Applies a workflow to a collection by creating tasks in the unified `tasks`
* table linked to the collection's parent case. This way the spawned tasks
* appear in the case Tasks tab and the global Tasks list.
*/
export function ApplyCollectionWorkflowDialog({
open,
onOpenChange,
collectionId,
caseId,
onApplied,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
collectionId: string;
caseId?: string | null;
onApplied?: () => void;
}) {
const { user } = useAuth();
@@ -50,7 +58,6 @@ export function ApplyCollectionWorkflowDialog({
supabase
.from("workflow_templates")
.select("id, name")
.eq("kind", "collection")
.order("name")
.then(({ data }) => setTemplates((data ?? []) as Template[]));
setTemplateId(null);
@@ -59,6 +66,10 @@ export function ApplyCollectionWorkflowDialog({
const apply = async () => {
if (!user || !templateId) return;
if (!caseId) {
toast.error("This collection has no linked case — cannot create tasks.");
return;
}
setSubmitting(true);
const { data: tpl } = await supabase
.from("workflow_template_tasks")
@@ -72,21 +83,53 @@ export function ApplyCollectionWorkflowDialog({
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");
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;
}
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: ${first.title}`,
link: `/tasks?task=${created.id}`,
task_id: created.id,
case_id: caseId,
created_by: user.id,
},
]);
}
}
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, collection_id: collectionId },
});
setSubmitting(false);
toast.success(
`Workflow started — ${items.length} step${items.length === 1 ? "" : "s"} will run sequentially.`,
);
@@ -131,15 +174,15 @@ export function ApplyCollectionWorkflowDialog({
</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.
Tasks are created on the linked case. Only the first task is created now — each next
task spawns automatically when the previous one is marked complete.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={apply} disabled={!templateId || submitting}>
<Button onClick={apply} disabled={!templateId || submitting || !caseId}>
{submitting && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Apply
</Button>
@@ -61,7 +61,7 @@ export function ApplyWorkflowPickerDialog({
setCaseId(null);
setStartDate(new Date());
Promise.all([
supabase.from("workflow_templates").select("id, name").eq("kind", "task").order("name"),
supabase.from("workflow_templates").select("id, name").order("name"),
supabase
.from("cases")
.select("id, case_number, title")
+1
View File
@@ -536,6 +536,7 @@ function CollectionDetailRoute() {
open={applyWfOpen}
onOpenChange={setApplyWfOpen}
collectionId={collection.id}
caseId={collection.case?.id ?? null}
onApplied={refreshTasks}
/>
+1 -41
View File
@@ -59,7 +59,6 @@ function WorkflowsPage() {
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [newKind, setNewKind] = useState<"task" | "collection">("task");
const [newTask, setNewTask] = useState({ title: "", days: 7, priority: "normal" as const });
const loadTemplates = useCallback(async () => {
@@ -100,7 +99,7 @@ function WorkflowsPage() {
.insert({
name: newName.trim(),
description: newDesc.trim() || null,
kind: newKind,
kind: "task",
created_by: user.id,
})
.select()
@@ -111,18 +110,11 @@ function WorkflowsPage() {
}
setNewName("");
setNewDesc("");
setNewKind("task");
setNewOpen(false);
await loadTemplates();
setSelected(data as Template);
};
const updateTemplateKind = async (id: string, kind: "task" | "collection") => {
await supabase.from("workflow_templates").update({ kind }).eq("id", id);
await loadTemplates();
if (selected?.id === id) setSelected({ ...selected, kind });
};
const deleteTemplate = async (id: string) => {
if (!confirm("Delete this workflow template?")) return;
await supabase.from("workflow_templates").delete().eq("id", id);
@@ -183,13 +175,6 @@ function WorkflowsPage() {
>
<div className="flex items-center justify-between gap-2">
<div className="font-medium text-sm truncate">{t.name}</div>
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${
t.kind === "collection"
? "bg-amber-500/15 text-amber-700 dark:text-amber-300"
: "bg-blue-500/15 text-blue-700 dark:text-blue-300"
}`}>
{t.kind === "collection" ? "Collection" : "Task"}
</span>
</div>
{t.description && <div className="text-xs text-muted-foreground truncate">{t.description}</div>}
</button>
@@ -212,19 +197,6 @@ function WorkflowsPage() {
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Label className="text-xs text-muted-foreground">Type</Label>
<Select
value={selected.kind}
onValueChange={(v) => updateTemplateKind(selected.id, v as "task" | "collection")}
>
<SelectTrigger className="w-36 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="task">Task workflow</SelectItem>
<SelectItem value="collection">Collection workflow</SelectItem>
</SelectContent>
</Select>
<Button variant="ghost" size="sm" onClick={() => deleteTemplate(selected.id)}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
@@ -321,18 +293,6 @@ function WorkflowsPage() {
<Label className="text-xs">Description</Label>
<Textarea value={newDesc} onChange={(e) => setNewDesc(e.target.value)} rows={2} />
</div>
<div>
<Label className="text-xs">Type</Label>
<Select value={newKind} onValueChange={(v) => setNewKind(v as "task" | "collection")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="task">Task workflow — applied to cases & tasks</SelectItem>
<SelectItem value="collection">Collection workflow — applied to collections</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNewOpen(false)}>Cancel</Button>