Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 02:56:44 +00:00
co-authored by renee-png
parent ca5b7ea01a
commit 9536ac8e02
6 changed files with 750 additions and 7 deletions
+301
View File
@@ -0,0 +1,301 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState, useCallback } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { AppShell, PageContainer, PageHeader } from "@/components/app-shell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Trash2, ArrowLeft, Loader2 } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/workflows")({
component: WorkflowsPage,
});
interface Template {
id: string;
name: string;
description: string | null;
created_by: string | null;
}
interface TemplateTask {
id: string;
template_id: string;
title: string;
description: string | null;
priority: "low" | "normal" | "high" | "urgent";
days_from_start: number;
default_assignee_id: string | null;
sort_order: number;
}
interface Profile {
id: string;
full_name: string;
email: string;
}
function WorkflowsPage() {
const { user } = useAuth();
const [templates, setTemplates] = useState<Template[]>([]);
const [selected, setSelected] = useState<Template | null>(null);
const [tasks, setTasks] = useState<TemplateTask[]>([]);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [newTask, setNewTask] = useState({ title: "", days: 7, priority: "normal" as const });
const loadTemplates = useCallback(async () => {
const { data } = await supabase
.from("workflow_templates")
.select("*")
.order("name");
setTemplates((data ?? []) as Template[]);
}, []);
const loadTasks = useCallback(async (templateId: string) => {
const { data } = await supabase
.from("workflow_template_tasks")
.select("*")
.eq("template_id", templateId)
.order("sort_order");
setTasks((data ?? []) as TemplateTask[]);
}, []);
useEffect(() => {
loadTemplates();
supabase
.from("profiles")
.select("id, full_name, email")
.order("full_name")
.then(({ data }) => setProfiles((data ?? []) as Profile[]));
}, [loadTemplates]);
useEffect(() => {
if (selected) loadTasks(selected.id);
else setTasks([]);
}, [selected, loadTasks]);
const createTemplate = async () => {
if (!user || !newName.trim()) return;
const { data, error } = await supabase
.from("workflow_templates")
.insert({ name: newName.trim(), description: newDesc.trim() || null, created_by: user.id })
.select()
.single();
if (error) {
toast.error(error.message);
return;
}
setNewName("");
setNewDesc("");
setNewOpen(false);
await loadTemplates();
setSelected(data as Template);
};
const deleteTemplate = async (id: string) => {
if (!confirm("Delete this workflow template?")) return;
await supabase.from("workflow_templates").delete().eq("id", id);
if (selected?.id === id) setSelected(null);
loadTemplates();
};
const addTask = async () => {
if (!selected || !newTask.title.trim()) return;
await supabase.from("workflow_template_tasks").insert({
template_id: selected.id,
title: newTask.title.trim(),
days_from_start: newTask.days,
priority: newTask.priority,
sort_order: tasks.length,
});
setNewTask({ title: "", days: 7, priority: "normal" });
loadTasks(selected.id);
};
const updateTask = async (id: string, patch: Partial<TemplateTask>) => {
await supabase.from("workflow_template_tasks").update(patch).eq("id", id);
if (selected) loadTasks(selected.id);
};
const removeTask = async (id: string) => {
await supabase.from("workflow_template_tasks").delete().eq("id", id);
if (selected) loadTasks(selected.id);
};
return (
<AppShell>
<PageContainer>
<div className="mb-2">
<Link to="/settings" className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
<ArrowLeft className="h-3 w-3" /> Settings
</Link>
</div>
<PageHeader
title="Workflow Templates"
description="Reusable task lists. Apply one to a case to spawn the tasks with computed due dates."
actions={
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" /> New template
</Button>
}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1">
<div className="rounded-md border bg-card divide-y">
{templates.length === 0 && (
<div className="p-4 text-sm text-muted-foreground italic">No templates yet.</div>
)}
{templates.map((t) => (
<button
key={t.id}
onClick={() => setSelected(t)}
className={`w-full text-left px-4 py-3 hover:bg-accent/50 ${
selected?.id === t.id ? "bg-accent" : ""
}`}
>
<div className="font-medium text-sm">{t.name}</div>
{t.description && <div className="text-xs text-muted-foreground truncate">{t.description}</div>}
</button>
))}
</div>
</div>
<div className="lg:col-span-2">
{!selected ? (
<div className="rounded-md border bg-card p-12 text-center text-sm text-muted-foreground">
Select a template to edit its tasks.
</div>
) : (
<div className="rounded-md border bg-card">
<div className="px-4 py-3 border-b flex items-center justify-between">
<div>
<div className="font-semibold">{selected.name}</div>
{selected.description && (
<div className="text-xs text-muted-foreground">{selected.description}</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => deleteTemplate(selected.id)}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
<div className="divide-y">
{tasks.map((t) => (
<div key={t.id} className="px-4 py-2.5 flex items-center gap-2">
<Input
defaultValue={t.title}
onBlur={(e) => e.target.value !== t.title && updateTask(t.id, { title: e.target.value })}
className="flex-1"
/>
<Input
type="number"
defaultValue={t.days_from_start}
onBlur={(e) =>
parseInt(e.target.value) !== t.days_from_start &&
updateTask(t.id, { days_from_start: parseInt(e.target.value) || 0 })
}
className="w-20"
/>
<span className="text-xs text-muted-foreground">days</span>
<Select
value={t.priority}
onValueChange={(v) => updateTask(t.id, { priority: v as any })}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
</SelectContent>
</Select>
<Select
value={t.default_assignee_id ?? "_none"}
onValueChange={(v) =>
updateTask(t.id, { default_assignee_id: v === "_none" ? null : v })
}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Unassigned</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.full_name || p.email}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" onClick={() => removeTask(t.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
<div className="px-4 py-3 border-t flex items-center gap-2">
<Input
placeholder="New task title…"
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
onKeyDown={(e) => e.key === "Enter" && addTask()}
className="flex-1"
/>
<Input
type="number"
value={newTask.days}
onChange={(e) => setNewTask({ ...newTask, days: parseInt(e.target.value) || 0 })}
className="w-20"
/>
<Button size="sm" onClick={addTask} disabled={!newTask.title.trim()}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add
</Button>
</div>
</div>
)}
</div>
</div>
<Dialog open={newOpen} onOpenChange={setNewOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New workflow template</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs">Name</Label>
<Input value={newName} onChange={(e) => setNewName(e.target.value)} autoFocus />
</div>
<div>
<Label className="text-xs">Description</Label>
<Textarea value={newDesc} onChange={(e) => setNewDesc(e.target.value)} rows={2} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNewOpen(false)}>Cancel</Button>
<Button onClick={createTemplate} disabled={!newName.trim()}>Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageContainer>
</AppShell>
);
}