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:
@@ -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")
|
||||
|
||||
@@ -536,6 +536,7 @@ function CollectionDetailRoute() {
|
||||
open={applyWfOpen}
|
||||
onOpenChange={setApplyWfOpen}
|
||||
collectionId={collection.id}
|
||||
caseId={collection.case?.id ?? null}
|
||||
onApplied={refreshTasks}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user