Added workflow buttons to tasks

X-Lovable-Edit-ID: edt-19a1496c-d939-4fc9-a690-0aff242290ff
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:49:59 +00:00
co-authored by renee-png
3 changed files with 247 additions and 5 deletions
+30 -1
View File
@@ -37,6 +37,7 @@ import {
Calculator,
ChevronRight,
Download,
GitFork,
Loader2,
Plus,
RefreshCw,
@@ -69,6 +70,7 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { SortableLedgerRow } from "./ledger-row";
import { ApplyCollectionWorkflowDialog } from "@/components/collections/apply-collection-workflow-dialog";
const TXN_TYPES = [
{ value: "assessment", label: "Assessment" },
@@ -140,6 +142,7 @@ export function CaseCollectionsTab({
// dialogs
const [addOpen, setAddOpen] = useState(false);
const [newHomeownerOpen, setNewHomeownerOpen] = useState(false);
const [wfCollectionId, setWfCollectionId] = useState<string | null>(null);
const load = async () => {
setLoading(true);
@@ -303,7 +306,21 @@ export function CaseCollectionsTab({
{bal < 0 ? " CR" : ""}
</TableCell>
<TableCell className="text-right">
<ChevronRight className="h-4 w-4 text-muted-foreground inline" />
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={(e) => {
e.stopPropagation();
setWfCollectionId(c.id);
}}
title="Apply workflow"
>
<GitFork className="h-3.5 w-3.5" />
</Button>
<ChevronRight className="h-4 w-4 text-muted-foreground inline" />
</div>
</TableCell>
</TableRow>
);
@@ -331,6 +348,18 @@ export function CaseCollectionsTab({
clientId={clientId}
onCreated={load}
/>
{wfCollectionId && (
<ApplyCollectionWorkflowDialog
open={!!wfCollectionId}
onOpenChange={(v) => !v && setWfCollectionId(null)}
collectionId={wfCollectionId}
onApplied={() => {
setWfCollectionId(null);
load();
}}
/>
)}
</div>
);
}
@@ -0,0 +1,205 @@
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";
import { createNotifications } from "@/lib/notifications";
interface Template {
id: string;
name: string;
}
interface CaseRow {
id: string;
case_number: string;
title: string;
}
/**
* Standalone "apply workflow" dialog for the Tasks page — lets the user
* pick BOTH a case and a template before sequentially spawning the first task.
*/
export function ApplyWorkflowPickerDialog({
open,
onOpenChange,
onApplied,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onApplied?: () => void;
}) {
const { user } = useAuth();
const [templates, setTemplates] = useState<Template[]>([]);
const [cases, setCases] = useState<CaseRow[]>([]);
const [templateId, setTemplateId] = useState<string | null>(null);
const [caseId, setCaseId] = useState<string | null>(null);
const [startDate, setStartDate] = useState<Date>(new Date());
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
setTemplateId(null);
setCaseId(null);
setStartDate(new Date());
Promise.all([
supabase.from("workflow_templates").select("id, name").order("name"),
supabase
.from("cases")
.select("id, case_number, title")
.is("archived_at", null)
.order("case_number"),
]).then(([t, c]) => {
setTemplates((t.data ?? []) as Template[]);
setCases((c.data ?? []) as CaseRow[]);
});
}, [open]);
const apply = async () => {
if (!user || !templateId || !caseId) 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 { 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 },
});
setSubmitting(false);
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 template</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs">Case</Label>
<Select value={caseId ?? ""} onValueChange={setCaseId}>
<SelectTrigger>
<SelectValue placeholder="Select case…" />
</SelectTrigger>
<SelectContent>
{cases.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.case_number} — {c.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={apply} disabled={!templateId || !caseId || submitting}>
{submitting && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Apply
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+12 -4
View File
@@ -17,10 +17,11 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Plus, Search, Calendar as CalIcon, AlertCircle } from "lucide-react";
import { Plus, Search, Calendar as CalIcon, AlertCircle, GitFork } from "lucide-react";
import { format, isBefore, startOfDay, isSameDay, addDays, differenceInCalendarDays } from "date-fns";
import { TaskDetailPanel } from "@/components/tasks/task-detail-panel";
import { NewTaskDialog } from "@/components/tasks/new-task-dialog";
import { ApplyWorkflowPickerDialog } from "@/components/tasks/apply-workflow-picker-dialog";
import { cn } from "@/lib/utils";
import { spawnNextWorkflowTask } from "@/lib/workflow-chain";
@@ -82,6 +83,7 @@ function TasksPage() {
const [query, setQuery] = useState("");
const [newOpen, setNewOpen] = useState(false);
const [wfOpen, setWfOpen] = useState(false);
const [openTaskId, setOpenTaskId] = useState<string | null>(search.task ?? null);
const load = useCallback(async () => {
@@ -204,9 +206,14 @@ function TasksPage() {
title="Tasks"
description="Workflow tasks, due dates, assignments, and comments."
actions={
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" /> New task
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setWfOpen(true)}>
<GitFork className="h-4 w-4 mr-1.5" /> Apply workflow
</Button>
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" /> New task
</Button>
</div>
}
/>
@@ -377,6 +384,7 @@ function TasksPage() {
</PageContainer>
<NewTaskDialog open={newOpen} onOpenChange={setNewOpen} onCreated={(id) => setOpenTaskId(id)} />
<ApplyWorkflowPickerDialog open={wfOpen} onOpenChange={setWfOpen} onApplied={load} />
<TaskDetailPanel
taskId={openTaskId}
open={!!openTaskId}