Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
ca5b7ea01a
commit
9536ac8e02
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user