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
+44 -7
View File
@@ -1,29 +1,54 @@
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Briefcase, Users, FileText, Receipt, ShieldCheck, LogOut, Scale, LayoutDashboard } from "lucide-react";
import {
Briefcase,
Users,
Receipt,
ShieldCheck,
LogOut,
Scale,
LayoutDashboard,
CheckSquare,
MessageSquare,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
import { useBubbleCounts } from "@/lib/use-bubble-counts";
import { NotificationBell } from "@/components/notifications/notification-bell";
interface NavItem {
to: string;
label: string;
icon: typeof Briefcase;
adminOnly?: boolean;
bubbleKey?: "messages" | "tasks";
}
const NAV: NavItem[] = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
{ to: "/clients", label: "Clients", icon: Users },
{ to: "/cases", label: "Cases", icon: Briefcase },
{ to: "/tasks", label: "Tasks", icon: CheckSquare, bubbleKey: "tasks" },
{ to: "/messages", label: "Messages", icon: MessageSquare, bubbleKey: "messages" },
{ to: "/invoices", label: "Invoices", icon: Receipt },
{ to: "/admin/users", label: "Users", icon: ShieldCheck, adminOnly: true },
];
function Bubble({ count }: { count: number }) {
if (!count) return null;
return (
<span className="ml-auto h-5 min-w-5 px-1.5 rounded-full bg-destructive text-destructive-foreground text-[10px] font-bold flex items-center justify-center">
{count > 99 ? "99+" : count}
</span>
);
}
export function AppShell({ children }: { children: ReactNode }) {
const { user, signOut, isAdmin, roles } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const counts = useBubbleCounts();
const handleSignOut = async () => {
await signOut();
@@ -38,16 +63,18 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className="h-9 w-9 rounded-md bg-sidebar-primary flex items-center justify-center">
<Scale className="h-5 w-5 text-sidebar-primary-foreground" />
</div>
<div className="leading-tight">
<div className="font-serif text-lg">Stage Law Firm, PLLC</div>
<div className="leading-tight flex-1 min-w-0">
<div className="font-serif text-lg truncate">Stage Law Firm, PLLC</div>
<div className="text-[10px] uppercase tracking-widest text-sidebar-foreground/60">Law Firm Suite</div>
</div>
<NotificationBell />
</div>
<nav className="flex-1 px-3 py-4 space-y-0.5">
{NAV.filter((n) => !n.adminOnly || isAdmin).map((item) => {
const active = item.to === "/" ? location.pathname === "/" : location.pathname.startsWith(item.to);
const Icon = item.icon;
const bubble = item.bubbleKey ? counts[item.bubbleKey] : 0;
return (
<Link
key={item.to}
@@ -61,6 +88,7 @@ export function AppShell({ children }: { children: ReactNode }) {
>
<Icon className="h-4 w-4" />
{item.label}
<Bubble count={bubble} />
</Link>
);
})}
@@ -89,25 +117,34 @@ export function AppShell({ children }: { children: ReactNode }) {
<Scale className="h-5 w-5" />
<span className="font-serif text-lg">Counsel</span>
</Link>
<Button variant="ghost" size="sm" onClick={handleSignOut} className="text-sidebar-foreground">
<LogOut className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
<NotificationBell />
<Button variant="ghost" size="sm" onClick={handleSignOut} className="text-sidebar-foreground">
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
<main className="flex-1 min-w-0 md:ml-0 mt-14 md:mt-0">
<div className="md:hidden flex overflow-x-auto gap-1 px-3 py-2 border-b bg-card">
{NAV.filter((n) => !n.adminOnly || isAdmin).map((item) => {
const active = item.to === "/" ? location.pathname === "/" : location.pathname.startsWith(item.to);
const bubble = item.bubbleKey ? counts[item.bubbleKey] : 0;
return (
<Link
key={item.to}
to={item.to}
className={cn(
"px-3 py-1.5 rounded-md text-xs font-medium whitespace-nowrap",
"px-3 py-1.5 rounded-md text-xs font-medium whitespace-nowrap relative",
active ? "bg-primary text-primary-foreground" : "text-muted-foreground",
)}
>
{item.label}
{bubble > 0 && (
<span className="ml-1 inline-flex h-4 min-w-4 px-1 rounded-full bg-destructive text-destructive-foreground text-[9px] font-bold items-center justify-center">
{bubble > 99 ? "99+" : bubble}
</span>
)}
</Link>
);
})}
+182
View File
@@ -0,0 +1,182 @@
import { useEffect, useState, useCallback } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Plus, Calendar as CalIcon, Workflow } from "lucide-react";
import { format, isBefore, startOfDay } from "date-fns";
import { TaskDetailPanel } from "@/components/tasks/task-detail-panel";
import { NewTaskDialog } from "@/components/tasks/new-task-dialog";
import { ApplyWorkflowDialog } from "@/components/tasks/apply-workflow-dialog";
import { cn } from "@/lib/utils";
interface TaskRow {
id: string;
title: string;
status: "incomplete" | "complete";
priority: "low" | "normal" | "high" | "urgent";
due_date: string | null;
parent_task_id: string | null;
}
interface AssigneeRow {
task_id: string;
user_id: string;
}
interface Profile {
id: string;
full_name: string;
email: string;
}
function initials(name: string) {
return name.split(" ").map((p) => p[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
}
const PRIORITY_DOT: Record<string, string> = {
urgent: "bg-red-500",
high: "bg-orange-500",
normal: "bg-blue-500",
low: "bg-muted-foreground/40",
};
export function CaseTasksTab({ caseId }: { caseId: string }) {
const { user } = useAuth();
const [tasks, setTasks] = useState<TaskRow[]>([]);
const [assignees, setAssignees] = useState<AssigneeRow[]>([]);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [openTaskId, setOpenTaskId] = useState<string | null>(null);
const [newOpen, setNewOpen] = useState(false);
const [wfOpen, setWfOpen] = useState(false);
const load = useCallback(async () => {
const [t, a, p] = await Promise.all([
supabase
.from("tasks")
.select("id, title, status, priority, due_date, parent_task_id")
.eq("case_id", caseId)
.is("parent_task_id", null)
.order("due_date", { ascending: true, nullsFirst: false }),
supabase
.from("task_assignees")
.select("task_id, user_id, tasks!inner(case_id)")
.eq("tasks.case_id", caseId),
supabase.from("profiles").select("id, full_name, email"),
]);
setTasks((t.data ?? []) as TaskRow[]);
setAssignees(((a.data ?? []) as any[]).map((r) => ({ task_id: r.task_id, user_id: r.user_id })));
setProfiles((p.data ?? []) as Profile[]);
}, [caseId]);
useEffect(() => {
load();
const ch = supabase
.channel(`case-tasks-${caseId}`)
.on("postgres_changes", { event: "*", schema: "public", table: "tasks", filter: `case_id=eq.${caseId}` }, () => load())
.on("postgres_changes", { event: "*", schema: "public", table: "task_assignees" }, () => load())
.subscribe();
return () => {
supabase.removeChannel(ch);
};
}, [caseId, load]);
const profilesById = Object.fromEntries(profiles.map((p) => [p.id, p]));
const assigneesByTask: Record<string, string[]> = {};
for (const a of assignees) (assigneesByTask[a.task_id] = assigneesByTask[a.task_id] || []).push(a.user_id);
const toggle = async (t: TaskRow, checked: boolean) => {
if (!user) return;
await supabase
.from("tasks")
.update({
status: checked ? "complete" : "incomplete",
completed_by: checked ? user.id : null,
completed_at: checked ? (new Date().toISOString() as any) : null,
})
.eq("id", t.id);
};
const today = startOfDay(new Date());
const incomplete = tasks.filter((t) => t.status === "incomplete");
const complete = tasks.filter((t) => t.status === "complete");
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{incomplete.length} open · {complete.length} done
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setWfOpen(true)}>
<Workflow className="h-3.5 w-3.5 mr-1.5" /> Apply workflow
</Button>
<Button size="sm" onClick={() => setNewOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> New task
</Button>
</div>
</div>
<div className="rounded-md border bg-card divide-y">
{tasks.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
No tasks for this case yet.
</div>
)}
{tasks.map((t) => {
const ids = assigneesByTask[t.id] || [];
const overdue = t.due_date && t.status === "incomplete" && isBefore(startOfDay(new Date(t.due_date)), today);
return (
<div
key={t.id}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-accent/40 cursor-pointer"
onClick={() => setOpenTaskId(t.id)}
>
<Checkbox
checked={t.status === "complete"}
onCheckedChange={(v) => toggle(t, !!v)}
onClick={(e) => e.stopPropagation()}
/>
<div className={cn("h-1.5 w-1.5 rounded-full", PRIORITY_DOT[t.priority])} />
<div className="flex-1 min-w-0">
<div className={cn("text-sm", t.status === "complete" && "line-through text-muted-foreground")}>
{t.title}
</div>
</div>
{t.due_date && (
<Badge variant={overdue ? "destructive" : "outline"} className="gap-1">
<CalIcon className="h-3 w-3" />
{format(new Date(t.due_date), "MMM d")}
</Badge>
)}
<div className="flex -space-x-1.5">
{ids.slice(0, 3).map((uid) => {
const p = profilesById[uid];
return (
<Avatar key={uid} className="h-6 w-6 border-2 border-background">
<AvatarFallback className="text-[9px]">{p ? initials(p.full_name || p.email) : "?"}</AvatarFallback>
</Avatar>
);
})}
</div>
</div>
);
})}
</div>
<NewTaskDialog
open={newOpen}
onOpenChange={setNewOpen}
defaultCaseId={caseId}
onCreated={(id) => setOpenTaskId(id)}
/>
<ApplyWorkflowDialog open={wfOpen} onOpenChange={setWfOpen} caseId={caseId} onApplied={load} />
<TaskDetailPanel
taskId={openTaskId}
open={!!openTaskId}
onOpenChange={(v) => !v && setOpenTaskId(null)}
onChanged={load}
/>
</div>
);
}
@@ -0,0 +1,171 @@
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;
}
export function ApplyWorkflowDialog({
open,
onOpenChange,
caseId,
onApplied,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
caseId: string;
onApplied?: () => void;
}) {
const { user } = useAuth();
const [templates, setTemplates] = useState<Template[]>([]);
const [templateId, setTemplateId] = useState<string | null>(null);
const [startDate, setStartDate] = useState<Date>(new Date());
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
supabase
.from("workflow_templates")
.select("id, name")
.order("name")
.then(({ data }) => setTemplates((data ?? []) as Template[]));
setTemplateId(null);
setStartDate(new Date());
}, [open]);
const apply = async () => {
if (!user || !templateId) 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 inserts = items.map((it: any, idx: number) => ({
title: it.title,
description: it.description,
priority: it.priority,
due_date: format(addDays(startDate, it.days_from_start), "yyyy-MM-dd"),
case_id: caseId,
workflow_template_id: templateId,
created_by: user.id,
sort_order: idx,
}));
const { data: created, error } = await supabase.from("tasks").insert(inserts).select("id");
if (error || !created) {
setSubmitting(false);
toast.error(error?.message || "Failed to apply");
return;
}
// Insert default assignees + history
const assigneeRows: any[] = [];
const historyRows: any[] = [];
const notifs: any[] = [];
created.forEach((row, idx) => {
const def = items[idx].default_assignee_id;
if (def) {
assigneeRows.push({ task_id: row.id, user_id: def, assigned_by: user.id });
if (def !== user.id) {
notifs.push({
user_id: def,
kind: "task_assigned",
title: `Assigned: ${items[idx].title}`,
link: `/tasks?task=${row.id}`,
task_id: row.id,
case_id: caseId,
created_by: user.id,
});
}
}
historyRows.push({
task_id: row.id,
actor_id: user.id,
event: "created_from_workflow",
detail: { template_id: templateId },
});
});
if (assigneeRows.length) await supabase.from("task_assignees").insert(assigneeRows);
if (historyRows.length) await supabase.from("task_history").insert(historyRows);
if (notifs.length) await createNotifications(notifs);
setSubmitting(false);
toast.success(`Created ${created.length} tasks`);
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">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">Start date (due dates computed from this)</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 || submitting}>
{submitting && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Apply
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -47,6 +47,7 @@ interface TaskRow {
parent_task_id: string | null;
created_by: string | null;
completed_at: string | null;
completed_by: string | null;
}
interface Profile {
+51
View File
@@ -13,6 +13,7 @@ import { Route as SetupRouteImport } from './routes/setup'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index'
import { Route as TasksIndexRouteImport } from './routes/tasks.index'
import { Route as StatusIndexRouteImport } from './routes/status.index'
import { Route as SettingsIndexRouteImport } from './routes/settings.index'
import { Route as MessagesIndexRouteImport } from './routes/messages.index'
@@ -23,6 +24,7 @@ import { Route as ContactsIndexRouteImport } from './routes/contacts.index'
import { Route as CollectionsIndexRouteImport } from './routes/collections.index'
import { Route as ClientsIndexRouteImport } from './routes/clients.index'
import { Route as CasesIndexRouteImport } from './routes/cases.index'
import { Route as SettingsWorkflowsRouteImport } from './routes/settings.workflows'
import { Route as SettingsWorkflowRouteImport } from './routes/settings.workflow'
import { Route as SettingsFeesRouteImport } from './routes/settings.fees'
import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId'
@@ -57,6 +59,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const TasksIndexRoute = TasksIndexRouteImport.update({
id: '/tasks/',
path: '/tasks/',
getParentRoute: () => rootRouteImport,
} as any)
const StatusIndexRoute = StatusIndexRouteImport.update({
id: '/status/',
path: '/status/',
@@ -107,6 +114,11 @@ const CasesIndexRoute = CasesIndexRouteImport.update({
path: '/cases/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsWorkflowsRoute = SettingsWorkflowsRouteImport.update({
id: '/workflows',
path: '/workflows',
getParentRoute: () => SettingsRoute,
} as any)
const SettingsWorkflowRoute = SettingsWorkflowRouteImport.update({
id: '/workflow',
path: '/workflow',
@@ -188,6 +200,7 @@ export interface FileRoutesByFullPath {
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/workflow': typeof SettingsWorkflowRoute
'/settings/workflows': typeof SettingsWorkflowsRoute
'/cases/': typeof CasesIndexRoute
'/clients/': typeof ClientsIndexRoute
'/collections/': typeof CollectionsIndexRoute
@@ -198,6 +211,7 @@ export interface FileRoutesByFullPath {
'/messages/': typeof MessagesIndexRoute
'/settings/': typeof SettingsIndexRoute
'/status/': typeof StatusIndexRoute
'/tasks/': typeof TasksIndexRoute
'/documents/pleading/new': typeof DocumentsPleadingNewRoute
'/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
@@ -216,6 +230,7 @@ export interface FileRoutesByTo {
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/workflow': typeof SettingsWorkflowRoute
'/settings/workflows': typeof SettingsWorkflowsRoute
'/cases': typeof CasesIndexRoute
'/clients': typeof ClientsIndexRoute
'/collections': typeof CollectionsIndexRoute
@@ -226,6 +241,7 @@ export interface FileRoutesByTo {
'/messages': typeof MessagesIndexRoute
'/settings': typeof SettingsIndexRoute
'/status': typeof StatusIndexRoute
'/tasks': typeof TasksIndexRoute
'/documents/pleading/new': typeof DocumentsPleadingNewRoute
'/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
@@ -246,6 +262,7 @@ export interface FileRoutesById {
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/workflow': typeof SettingsWorkflowRoute
'/settings/workflows': typeof SettingsWorkflowsRoute
'/cases/': typeof CasesIndexRoute
'/clients/': typeof ClientsIndexRoute
'/collections/': typeof CollectionsIndexRoute
@@ -256,6 +273,7 @@ export interface FileRoutesById {
'/messages/': typeof MessagesIndexRoute
'/settings/': typeof SettingsIndexRoute
'/status/': typeof StatusIndexRoute
'/tasks/': typeof TasksIndexRoute
'/documents/pleading/new': typeof DocumentsPleadingNewRoute
'/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
@@ -277,6 +295,7 @@ export interface FileRouteTypes {
| '/invoices/$invoiceId'
| '/settings/fees'
| '/settings/workflow'
| '/settings/workflows'
| '/cases/'
| '/clients/'
| '/collections/'
@@ -287,6 +306,7 @@ export interface FileRouteTypes {
| '/messages/'
| '/settings/'
| '/status/'
| '/tasks/'
| '/documents/pleading/new'
| '/documents/templates/$templateId'
| '/documents/templates/new'
@@ -305,6 +325,7 @@ export interface FileRouteTypes {
| '/invoices/$invoiceId'
| '/settings/fees'
| '/settings/workflow'
| '/settings/workflows'
| '/cases'
| '/clients'
| '/collections'
@@ -315,6 +336,7 @@ export interface FileRouteTypes {
| '/messages'
| '/settings'
| '/status'
| '/tasks'
| '/documents/pleading/new'
| '/documents/templates/$templateId'
| '/documents/templates/new'
@@ -334,6 +356,7 @@ export interface FileRouteTypes {
| '/invoices/$invoiceId'
| '/settings/fees'
| '/settings/workflow'
| '/settings/workflows'
| '/cases/'
| '/clients/'
| '/collections/'
@@ -344,6 +367,7 @@ export interface FileRouteTypes {
| '/messages/'
| '/settings/'
| '/status/'
| '/tasks/'
| '/documents/pleading/new'
| '/documents/templates/$templateId'
| '/documents/templates/new'
@@ -371,6 +395,7 @@ export interface RootRouteChildren {
InvoicesIndexRoute: typeof InvoicesIndexRoute
MessagesIndexRoute: typeof MessagesIndexRoute
StatusIndexRoute: typeof StatusIndexRoute
TasksIndexRoute: typeof TasksIndexRoute
DocumentsPleadingNewRoute: typeof DocumentsPleadingNewRoute
DocumentsTemplatesTemplateIdRoute: typeof DocumentsTemplatesTemplateIdRoute
DocumentsTemplatesNewRoute: typeof DocumentsTemplatesNewRoute
@@ -407,6 +432,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/tasks/': {
id: '/tasks/'
path: '/tasks'
fullPath: '/tasks/'
preLoaderRoute: typeof TasksIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/status/': {
id: '/status/'
path: '/status'
@@ -477,6 +509,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CasesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/workflows': {
id: '/settings/workflows'
path: '/workflows'
fullPath: '/settings/workflows'
preLoaderRoute: typeof SettingsWorkflowsRouteImport
parentRoute: typeof SettingsRoute
}
'/settings/workflow': {
id: '/settings/workflow'
path: '/workflow'
@@ -574,12 +613,14 @@ declare module '@tanstack/react-router' {
interface SettingsRouteChildren {
SettingsFeesRoute: typeof SettingsFeesRoute
SettingsWorkflowRoute: typeof SettingsWorkflowRoute
SettingsWorkflowsRoute: typeof SettingsWorkflowsRoute
SettingsIndexRoute: typeof SettingsIndexRoute
}
const SettingsRouteChildren: SettingsRouteChildren = {
SettingsFeesRoute: SettingsFeesRoute,
SettingsWorkflowRoute: SettingsWorkflowRoute,
SettingsWorkflowsRoute: SettingsWorkflowsRoute,
SettingsIndexRoute: SettingsIndexRoute,
}
@@ -608,6 +649,7 @@ const rootRouteChildren: RootRouteChildren = {
InvoicesIndexRoute: InvoicesIndexRoute,
MessagesIndexRoute: MessagesIndexRoute,
StatusIndexRoute: StatusIndexRoute,
TasksIndexRoute: TasksIndexRoute,
DocumentsPleadingNewRoute: DocumentsPleadingNewRoute,
DocumentsTemplatesTemplateIdRoute: DocumentsTemplatesTemplateIdRoute,
DocumentsTemplatesNewRoute: DocumentsTemplatesNewRoute,
@@ -616,3 +658,12 @@ const rootRouteChildren: RootRouteChildren = {
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+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>
);
}