Added RLS migration & UI
X-Lovable-Edit-ID: edt-0325afe4-29c2-4c24-9dee-83a757e5388c Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,164 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import type { Database } from "@/integrations/supabase/types";
|
||||
|
||||
type EntityType = Database["public"]["Enums"]["comment_entity"];
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface CommentRow {
|
||||
id: string;
|
||||
entity_type: EntityType;
|
||||
entity_id: string;
|
||||
case_id: string | null;
|
||||
author_id: string;
|
||||
body: string;
|
||||
edited_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name.split(" ").map((p) => p[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
|
||||
}
|
||||
|
||||
export function CommentThread({
|
||||
entityType,
|
||||
entityId,
|
||||
caseId,
|
||||
}: {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
caseId?: string | null;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const [comments, setComments] = useState<CommentRow[]>([]);
|
||||
const [profiles, setProfiles] = useState<Record<string, Profile>>({});
|
||||
const [body, setBody] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const { data } = await supabase
|
||||
.from("comments")
|
||||
.select("*")
|
||||
.eq("entity_type", entityType)
|
||||
.eq("entity_id", entityId)
|
||||
.order("created_at", { ascending: true });
|
||||
const rows = (data ?? []) as CommentRow[];
|
||||
setComments(rows);
|
||||
const ids = Array.from(new Set(rows.map((r) => r.author_id)));
|
||||
if (ids.length) {
|
||||
const { data: pr } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, full_name, email")
|
||||
.in("id", ids);
|
||||
const map: Record<string, Profile> = {};
|
||||
(pr ?? []).forEach((p) => (map[p.id] = p as Profile));
|
||||
setProfiles(map);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [entityType, entityId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const ch = supabase
|
||||
.channel(`comments-${entityType}-${entityId}`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "comments", filter: `entity_id=eq.${entityId}` },
|
||||
() => load(),
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabase.removeChannel(ch);
|
||||
};
|
||||
}, [entityType, entityId, load]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!body.trim() || !user) return;
|
||||
setSubmitting(true);
|
||||
const { error } = await supabase.from("comments").insert({
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
case_id: caseId ?? null,
|
||||
author_id: user.id,
|
||||
body: body.trim(),
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
setBody("");
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
const { error } = await supabase.from("comments").delete().eq("id", id);
|
||||
if (error) toast.error(error.message);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{loading && <div className="text-sm text-muted-foreground">Loading…</div>}
|
||||
{!loading && comments.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground italic">No comments yet.</div>
|
||||
)}
|
||||
{comments.map((c) => {
|
||||
const p = profiles[c.author_id];
|
||||
const name = p?.full_name || p?.email || "Unknown";
|
||||
return (
|
||||
<div key={c.id} className="flex gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-[10px]">{initials(name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-sm font-medium">{name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{new Date(c.created_at).toLocaleString()}
|
||||
</div>
|
||||
{c.author_id === user?.id && (
|
||||
<button
|
||||
onClick={() => remove(c.id)}
|
||||
className="ml-auto text-muted-foreground hover:text-destructive"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap mt-0.5">{c.body}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Add a comment…"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={submit} disabled={!body.trim() || submitting}>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Bell, Check, CheckCheck } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NotificationRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
link: string | null;
|
||||
task_id: string | null;
|
||||
case_id: string | null;
|
||||
conversation_id: string | null;
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = useState<NotificationRow[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!user) return;
|
||||
const { data } = await supabase
|
||||
.from("notifications")
|
||||
.select("*")
|
||||
.eq("user_id", user.id)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(50);
|
||||
setNotifications((data ?? []) as NotificationRow[]);
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
load();
|
||||
const ch = supabase
|
||||
.channel(`notif-${user.id}`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "notifications", filter: `user_id=eq.${user.id}` },
|
||||
() => load(),
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabase.removeChannel(ch);
|
||||
};
|
||||
}, [user, load]);
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read_at).length;
|
||||
|
||||
const markRead = async (id: string) => {
|
||||
await supabase.from("notifications").update({ read_at: new Date().toISOString() }).eq("id", id);
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
if (!user) return;
|
||||
await supabase
|
||||
.from("notifications")
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
.eq("user_id", user.id)
|
||||
.is("read_at", null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-4 min-w-4 px-1 rounded-full bg-destructive text-destructive-foreground text-[10px] font-bold flex items-center justify-center">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-96 p-0">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<div className="font-semibold text-sm">Notifications</div>
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={markAllRead}>
|
||||
<CheckCheck className="h-3.5 w-3.5 mr-1" /> Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="max-h-[420px]">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
You're all caught up.
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{notifications.map((n) => {
|
||||
const inner = (
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-2.5 border-b hover:bg-accent/60 cursor-pointer flex gap-2",
|
||||
!n.read_at && "bg-primary/5",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!n.read_at) markRead(n.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className={cn("h-2 w-2 rounded-full mt-1.5 shrink-0", !n.read_at ? "bg-primary" : "bg-transparent")} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{n.title}</div>
|
||||
{n.body && (
|
||||
<div className="text-xs text-muted-foreground truncate">{n.body}</div>
|
||||
)}
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{formatDistanceToNow(new Date(n.created_at), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
{!n.read_at && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markRead(n.id);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Mark read"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return n.link ? (
|
||||
<Link key={n.id} to={n.link as any}>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={n.id}>{inner}</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { CalendarIcon, X, UserPlus, Loader2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { toast } from "sonner";
|
||||
import { createNotifications } from "@/lib/notifications";
|
||||
|
||||
interface CaseOption {
|
||||
id: string;
|
||||
case_number: string;
|
||||
title: 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();
|
||||
}
|
||||
|
||||
export function NewTaskDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultCaseId,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
defaultCaseId?: string | null;
|
||||
onCreated?: (taskId: string) => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [priority, setPriority] = useState<"low" | "normal" | "high" | "urgent">("normal");
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>();
|
||||
const [caseId, setCaseId] = useState<string | null>(defaultCaseId ?? null);
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>([]);
|
||||
const [cases, setCases] = useState<CaseOption[]>([]);
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCaseId(defaultCaseId ?? null);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setPriority("normal");
|
||||
setDueDate(undefined);
|
||||
setAssigneeIds(user ? [user.id] : []);
|
||||
(async () => {
|
||||
const [c, p] = await Promise.all([
|
||||
supabase.from("cases").select("id, case_number, title").order("case_number"),
|
||||
supabase.from("profiles").select("id, full_name, email").order("full_name"),
|
||||
]);
|
||||
setCases((c.data ?? []) as CaseOption[]);
|
||||
setProfiles((p.data ?? []) as Profile[]);
|
||||
})();
|
||||
}, [open, defaultCaseId, user]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!user || !title.trim()) return;
|
||||
setSubmitting(true);
|
||||
const { data, error } = await supabase
|
||||
.from("tasks")
|
||||
.insert({
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
priority,
|
||||
due_date: dueDate ? format(dueDate, "yyyy-MM-dd") : null,
|
||||
case_id: caseId,
|
||||
created_by: user.id,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
if (error || !data) {
|
||||
setSubmitting(false);
|
||||
toast.error(error?.message || "Failed to create task");
|
||||
return;
|
||||
}
|
||||
if (assigneeIds.length) {
|
||||
await supabase.from("task_assignees").insert(
|
||||
assigneeIds.map((uid) => ({ task_id: data.id, user_id: uid, assigned_by: user.id })),
|
||||
);
|
||||
const others = assigneeIds.filter((uid) => uid !== user.id);
|
||||
if (others.length) {
|
||||
await createNotifications(
|
||||
others.map((uid) => ({
|
||||
user_id: uid,
|
||||
kind: "task_assigned" as const,
|
||||
title: `Assigned: ${title.trim()}`,
|
||||
link: `/tasks?task=${data.id}`,
|
||||
task_id: data.id,
|
||||
case_id: caseId,
|
||||
created_by: user.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
await supabase.from("task_history").insert({
|
||||
task_id: data.id,
|
||||
actor_id: user.id,
|
||||
event: "created",
|
||||
detail: { title: title.trim() },
|
||||
});
|
||||
setSubmitting(false);
|
||||
toast.success("Task created");
|
||||
onCreated?.(data.id);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">Title</Label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">Priority</Label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as any)}>
|
||||
<SelectTrigger>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Due date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start font-normal">
|
||||
<CalendarIcon className="h-3.5 w-3.5 mr-2" />
|
||||
{dueDate ? format(dueDate, "MM/dd/yyyy") : "Pick a date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Calendar mode="single" selected={dueDate} onSelect={setDueDate} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Case</Label>
|
||||
<Select value={caseId ?? "_none"} onValueChange={(v) => setCaseId(v === "_none" ? null : v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No case" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">No case</SelectItem>
|
||||
{cases.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.case_number} — {c.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Assignees</Label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{assigneeIds.map((uid) => {
|
||||
const p = profiles.find((x) => x.id === uid);
|
||||
if (!p) return null;
|
||||
return (
|
||||
<Badge key={uid} variant="secondary" className="gap-1.5 pr-1 py-1">
|
||||
<Avatar className="h-4 w-4">
|
||||
<AvatarFallback className="text-[8px]">{initials(p.full_name || p.email)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs">{p.full_name || p.email}</span>
|
||||
<button onClick={() => setAssigneeIds((v) => v.filter((x) => x !== uid))}>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
<Popover open={showPicker} onOpenChange={setShowPicker}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7">
|
||||
<UserPlus className="h-3.5 w-3.5 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-1 w-64">
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{profiles
|
||||
.filter((p) => !assigneeIds.includes(p.id))
|
||||
.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
setAssigneeIds((v) => [...v, p.id]);
|
||||
setShowPicker(false);
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 rounded hover:bg-accent text-sm"
|
||||
>
|
||||
{p.full_name || p.email}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={!title.trim() || submitting}>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
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 { toast } from "sonner";
|
||||
import { Loader2, Plus, Trash2, X, CalendarIcon, UserPlus } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import type { Database } from "@/integrations/supabase/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createNotifications } from "@/lib/notifications";
|
||||
|
||||
type Priority = Database["public"]["Enums"]["task_priority"];
|
||||
|
||||
interface TaskRow {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: "incomplete" | "complete";
|
||||
priority: Priority;
|
||||
due_date: string | null;
|
||||
case_id: string | null;
|
||||
parent_task_id: string | null;
|
||||
created_by: string | null;
|
||||
completed_at: string | null;
|
||||
completed_by: string | null;
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface CommentRow {
|
||||
id: string;
|
||||
task_id: string;
|
||||
author_id: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface HistoryRow {
|
||||
id: string;
|
||||
task_id: string;
|
||||
actor_id: string | null;
|
||||
event: string;
|
||||
detail: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name.split(" ").map((p) => p[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS: Record<Priority, string> = {
|
||||
low: "bg-muted text-muted-foreground",
|
||||
normal: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-200",
|
||||
high: "bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-200",
|
||||
urgent: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-200",
|
||||
};
|
||||
|
||||
export function TaskDetailPanel({
|
||||
taskId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onChanged,
|
||||
}: {
|
||||
taskId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const [task, setTask] = useState<TaskRow | null>(null);
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
const [allProfiles, setAllProfiles] = useState<Profile[]>([]);
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>([]);
|
||||
const [subtasks, setSubtasks] = useState<TaskRow[]>([]);
|
||||
const [comments, setComments] = useState<CommentRow[]>([]);
|
||||
const [history, setHistory] = useState<HistoryRow[]>([]);
|
||||
const [caseTitle, setCaseTitle] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newSubtask, setNewSubtask] = useState("");
|
||||
const [commentBody, setCommentBody] = useState("");
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [showAssignPicker, setShowAssignPicker] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!taskId) return;
|
||||
setLoading(true);
|
||||
const [t, a, s, c, h, pr] = await Promise.all([
|
||||
supabase.from("tasks").select("*").eq("id", taskId).maybeSingle(),
|
||||
supabase.from("task_assignees").select("user_id").eq("task_id", taskId),
|
||||
supabase.from("tasks").select("*").eq("parent_task_id", taskId).order("sort_order"),
|
||||
supabase.from("task_comments").select("*").eq("task_id", taskId).order("created_at"),
|
||||
supabase.from("task_history").select("*").eq("task_id", taskId).order("created_at", { ascending: false }).limit(50),
|
||||
supabase.from("profiles").select("id, full_name, email").order("full_name"),
|
||||
]);
|
||||
if (t.data) {
|
||||
setTask(t.data as TaskRow);
|
||||
setEditTitle(t.data.title);
|
||||
setEditDescription(t.data.description || "");
|
||||
if ((t.data as TaskRow).case_id) {
|
||||
const { data: cd } = await supabase
|
||||
.from("cases")
|
||||
.select("title, case_number")
|
||||
.eq("id", (t.data as TaskRow).case_id!)
|
||||
.maybeSingle();
|
||||
setCaseTitle(cd ? `${cd.case_number} — ${cd.title}` : null);
|
||||
} else {
|
||||
setCaseTitle(null);
|
||||
}
|
||||
}
|
||||
setAssigneeIds((a.data ?? []).map((r) => r.user_id));
|
||||
setSubtasks((s.data ?? []) as TaskRow[]);
|
||||
setComments((c.data ?? []) as CommentRow[]);
|
||||
setHistory((h.data ?? []) as HistoryRow[]);
|
||||
setAllProfiles((pr.data ?? []) as Profile[]);
|
||||
setProfiles((pr.data ?? []) as Profile[]);
|
||||
setLoading(false);
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && taskId) load();
|
||||
}, [open, taskId, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !taskId) return;
|
||||
const ch = supabase
|
||||
.channel(`task-${taskId}`)
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "task_comments", filter: `task_id=eq.${taskId}` }, () => load())
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "task_assignees", filter: `task_id=eq.${taskId}` }, () => load())
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "tasks", filter: `id=eq.${taskId}` }, () => load())
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabase.removeChannel(ch);
|
||||
};
|
||||
}, [open, taskId, load]);
|
||||
|
||||
const profilesById = Object.fromEntries(allProfiles.map((p) => [p.id, p] as const));
|
||||
|
||||
const updateTask = async (patch: Partial<TaskRow>, event?: string, detail?: any) => {
|
||||
if (!task) return;
|
||||
const { error } = await supabase.from("tasks").update(patch).eq("id", task.id);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
if (event && user) {
|
||||
await supabase.from("task_history").insert({
|
||||
task_id: task.id,
|
||||
actor_id: user.id,
|
||||
event,
|
||||
detail: detail ?? {},
|
||||
});
|
||||
}
|
||||
onChanged?.();
|
||||
};
|
||||
|
||||
const toggleComplete = async (checked: boolean) => {
|
||||
if (!task || !user) return;
|
||||
await updateTask(
|
||||
{
|
||||
status: checked ? "complete" : "incomplete",
|
||||
completed_by: checked ? user.id : null,
|
||||
completed_at: checked ? (new Date().toISOString() as any) : null,
|
||||
},
|
||||
checked ? "completed" : "reopened",
|
||||
);
|
||||
};
|
||||
|
||||
const saveTitle = async () => {
|
||||
if (!task || editTitle === task.title) return;
|
||||
await updateTask({ title: editTitle }, "renamed", { from: task.title, to: editTitle });
|
||||
};
|
||||
|
||||
const saveDescription = async () => {
|
||||
if (!task || editDescription === (task.description || "")) return;
|
||||
await updateTask({ description: editDescription || null }, "description_updated");
|
||||
};
|
||||
|
||||
const setPriority = async (p: Priority) => {
|
||||
if (!task) return;
|
||||
await updateTask({ priority: p }, "priority_changed", { to: p });
|
||||
};
|
||||
|
||||
const setDueDate = async (d: Date | undefined) => {
|
||||
if (!task) return;
|
||||
const v = d ? format(d, "yyyy-MM-dd") : null;
|
||||
await updateTask({ due_date: v }, "due_date_changed", { to: v });
|
||||
};
|
||||
|
||||
const addAssignee = async (uid: string) => {
|
||||
if (!task || !user) return;
|
||||
if (assigneeIds.includes(uid)) return;
|
||||
const { error } = await supabase
|
||||
.from("task_assignees")
|
||||
.insert({ task_id: task.id, user_id: uid, assigned_by: user.id });
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
await supabase.from("task_history").insert({
|
||||
task_id: task.id,
|
||||
actor_id: user.id,
|
||||
event: "assignee_added",
|
||||
detail: { user_id: uid },
|
||||
});
|
||||
if (uid !== user.id) {
|
||||
await createNotifications([
|
||||
{
|
||||
user_id: uid,
|
||||
kind: "task_assigned",
|
||||
title: `Assigned: ${task.title}`,
|
||||
body: caseTitle || undefined,
|
||||
link: `/tasks?task=${task.id}`,
|
||||
task_id: task.id,
|
||||
case_id: task.case_id,
|
||||
created_by: user.id,
|
||||
},
|
||||
]);
|
||||
}
|
||||
setShowAssignPicker(false);
|
||||
};
|
||||
|
||||
const removeAssignee = async (uid: string) => {
|
||||
if (!task || !user) return;
|
||||
const { error } = await supabase
|
||||
.from("task_assignees")
|
||||
.delete()
|
||||
.eq("task_id", task.id)
|
||||
.eq("user_id", uid);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
await supabase.from("task_history").insert({
|
||||
task_id: task.id,
|
||||
actor_id: user.id,
|
||||
event: "assignee_removed",
|
||||
detail: { user_id: uid },
|
||||
});
|
||||
};
|
||||
|
||||
const addSubtask = async () => {
|
||||
if (!task || !user || !newSubtask.trim()) return;
|
||||
const { error } = await supabase.from("tasks").insert({
|
||||
title: newSubtask.trim(),
|
||||
parent_task_id: task.id,
|
||||
case_id: task.case_id,
|
||||
created_by: user.id,
|
||||
sort_order: subtasks.length,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
await supabase.from("task_history").insert({
|
||||
task_id: task.id,
|
||||
actor_id: user.id,
|
||||
event: "subtask_added",
|
||||
detail: { title: newSubtask.trim() },
|
||||
});
|
||||
setNewSubtask("");
|
||||
load();
|
||||
};
|
||||
|
||||
const toggleSubtask = async (st: TaskRow) => {
|
||||
const newStatus = st.status === "complete" ? "incomplete" : "complete";
|
||||
await supabase
|
||||
.from("tasks")
|
||||
.update({
|
||||
status: newStatus,
|
||||
completed_by: newStatus === "complete" ? user!.id : null,
|
||||
completed_at: newStatus === "complete" ? (new Date().toISOString() as any) : null,
|
||||
})
|
||||
.eq("id", st.id);
|
||||
load();
|
||||
};
|
||||
|
||||
const removeSubtask = async (id: string) => {
|
||||
await supabase.from("tasks").delete().eq("id", id);
|
||||
load();
|
||||
};
|
||||
|
||||
const addComment = async () => {
|
||||
if (!task || !user || !commentBody.trim()) return;
|
||||
const { error } = await supabase.from("task_comments").insert({
|
||||
task_id: task.id,
|
||||
author_id: user.id,
|
||||
body: commentBody.trim(),
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
// Notify assignees + creator (excluding self)
|
||||
const recipients = new Set(assigneeIds);
|
||||
if (task.created_by) recipients.add(task.created_by);
|
||||
recipients.delete(user.id);
|
||||
if (recipients.size > 0) {
|
||||
await createNotifications(
|
||||
Array.from(recipients).map((uid) => ({
|
||||
user_id: uid,
|
||||
kind: "task_comment" as const,
|
||||
title: `New comment on: ${task.title}`,
|
||||
body: commentBody.slice(0, 120),
|
||||
link: `/tasks?task=${task.id}`,
|
||||
task_id: task.id,
|
||||
case_id: task.case_id,
|
||||
created_by: user.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
setCommentBody("");
|
||||
};
|
||||
|
||||
const deleteComment = async (id: string) => {
|
||||
await supabase.from("task_comments").delete().eq("id", id);
|
||||
};
|
||||
|
||||
if (!task && !loading) return null;
|
||||
|
||||
const completedSubtasks = subtasks.filter((s) => s.status === "complete").length;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto p-0">
|
||||
{loading || !task ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SheetHeader className="px-6 pt-6 pb-3 border-b">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={task.status === "complete"}
|
||||
onCheckedChange={(v) => toggleComplete(!!v)}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onBlur={saveTitle}
|
||||
className="text-lg font-semibold border-0 px-0 shadow-none focus-visible:ring-0 h-auto"
|
||||
/>
|
||||
{caseTitle && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Case: <span className="text-foreground">{caseTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SheetTitle className="sr-only">{task.title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="px-6 py-4 grid grid-cols-3 gap-3 border-b">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
||||
<Badge variant={task.status === "complete" ? "default" : "secondary"}>
|
||||
{task.status === "complete" ? "Complete" : "Incomplete"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Due date</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-sm font-normal">
|
||||
<CalendarIcon className="h-3.5 w-3.5 mr-1.5" />
|
||||
{task.due_date ? format(new Date(task.due_date), "MM/dd/yyyy") : "Set date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={task.due_date ? new Date(task.due_date) : undefined}
|
||||
onSelect={setDueDate}
|
||||
/>
|
||||
{task.due_date && (
|
||||
<div className="p-2 border-t">
|
||||
<Button variant="ghost" size="sm" className="w-full" onClick={() => setDueDate(undefined)}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Priority</div>
|
||||
<Select value={task.priority} onValueChange={(v) => setPriority(v as Priority)}>
|
||||
<SelectTrigger className="h-7 w-full text-sm">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-b">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">Assignees</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{assigneeIds.map((uid) => {
|
||||
const p = profilesById[uid];
|
||||
if (!p) return null;
|
||||
return (
|
||||
<Badge key={uid} variant="secondary" className="gap-1.5 pr-1 py-1">
|
||||
<Avatar className="h-4 w-4">
|
||||
<AvatarFallback className="text-[8px]">{initials(p.full_name || p.email)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs">{p.full_name || p.email}</span>
|
||||
<button onClick={() => removeAssignee(uid)} className="ml-0.5 hover:text-destructive">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
<Popover open={showAssignPicker} onOpenChange={setShowAssignPicker}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7">
|
||||
<UserPlus className="h-3.5 w-3.5 mr-1" />
|
||||
Assign
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-1 w-64">
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{allProfiles
|
||||
.filter((p) => !assigneeIds.includes(p.id))
|
||||
.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => addAssignee(p.id)}
|
||||
className="w-full text-left px-2 py-1.5 rounded hover:bg-accent text-sm flex items-center gap-2"
|
||||
>
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-[10px]">{initials(p.full_name || p.email)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">{p.full_name || p.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-b">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">Description</div>
|
||||
<Textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
onBlur={saveDescription}
|
||||
placeholder="Add a description…"
|
||||
rows={3}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-b">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Subtasks {subtasks.length > 0 && `(${completedSubtasks}/${subtasks.length})`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{subtasks.map((st) => (
|
||||
<div key={st.id} className="flex items-center gap-2 group">
|
||||
<Checkbox
|
||||
checked={st.status === "complete"}
|
||||
onCheckedChange={() => toggleSubtask(st)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm flex-1",
|
||||
st.status === "complete" && "line-through text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{st.title}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeSubtask(st.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Input
|
||||
value={newSubtask}
|
||||
onChange={(e) => setNewSubtask(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addSubtask()}
|
||||
placeholder="Add subtask…"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button size="sm" onClick={addSubtask} disabled={!newSubtask.trim()}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="comments" className="px-6 py-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="comments">Comments ({comments.length})</TabsTrigger>
|
||||
<TabsTrigger value="history">History</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="comments" className="space-y-3 mt-3">
|
||||
{comments.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground italic">No comments yet.</div>
|
||||
)}
|
||||
{comments.map((c) => {
|
||||
const p = profilesById[c.author_id];
|
||||
const name = p?.full_name || p?.email || "Unknown";
|
||||
return (
|
||||
<div key={c.id} className="flex gap-2.5 group">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className="text-[10px]">{initials(name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-sm font-medium">{name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{new Date(c.created_at).toLocaleString()}
|
||||
</div>
|
||||
{c.author_id === user?.id && (
|
||||
<button
|
||||
onClick={() => deleteComment(c.id)}
|
||||
className="ml-auto opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap mt-0.5">{c.body}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<Textarea
|
||||
value={commentBody}
|
||||
onChange={(e) => setCommentBody(e.target.value)}
|
||||
placeholder="Add a comment…"
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={addComment} disabled={!commentBody.trim()}>
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="history" className="mt-3 space-y-2">
|
||||
{history.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground italic">No activity yet.</div>
|
||||
)}
|
||||
{history.map((h) => {
|
||||
const p = h.actor_id ? profilesById[h.actor_id] : null;
|
||||
return (
|
||||
<div key={h.id} className="text-xs flex items-center gap-2 text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{p?.full_name || p?.email || "System"}</span>
|
||||
<span>{h.event.replace(/_/g, " ")}</span>
|
||||
<span className="ml-auto">{new Date(h.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -690,6 +690,47 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
comments: {
|
||||
Row: {
|
||||
author_id: string
|
||||
body: string
|
||||
case_id: string | null
|
||||
created_at: string
|
||||
edited_at: string | null
|
||||
entity_id: string
|
||||
entity_type: Database["public"]["Enums"]["comment_entity"]
|
||||
id: string
|
||||
}
|
||||
Insert: {
|
||||
author_id: string
|
||||
body?: string
|
||||
case_id?: string | null
|
||||
created_at?: string
|
||||
edited_at?: string | null
|
||||
entity_id: string
|
||||
entity_type: Database["public"]["Enums"]["comment_entity"]
|
||||
id?: string
|
||||
}
|
||||
Update: {
|
||||
author_id?: string
|
||||
body?: string
|
||||
case_id?: string | null
|
||||
created_at?: string
|
||||
edited_at?: string | null
|
||||
entity_id?: string
|
||||
entity_type?: Database["public"]["Enums"]["comment_entity"]
|
||||
id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "comments_case_id_fkey"
|
||||
columns: ["case_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "cases"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
contacts: {
|
||||
Row: {
|
||||
address_line1: string | null
|
||||
@@ -1517,6 +1558,73 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
notifications: {
|
||||
Row: {
|
||||
body: string | null
|
||||
case_id: string | null
|
||||
conversation_id: string | null
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
id: string
|
||||
kind: Database["public"]["Enums"]["notification_kind"]
|
||||
link: string | null
|
||||
read_at: string | null
|
||||
task_id: string | null
|
||||
title: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
body?: string | null
|
||||
case_id?: string | null
|
||||
conversation_id?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
id?: string
|
||||
kind: Database["public"]["Enums"]["notification_kind"]
|
||||
link?: string | null
|
||||
read_at?: string | null
|
||||
task_id?: string | null
|
||||
title: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
body?: string | null
|
||||
case_id?: string | null
|
||||
conversation_id?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
id?: string
|
||||
kind?: Database["public"]["Enums"]["notification_kind"]
|
||||
link?: string | null
|
||||
read_at?: string | null
|
||||
task_id?: string | null
|
||||
title?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "notifications_case_id_fkey"
|
||||
columns: ["case_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "cases"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "notifications_conversation_id_fkey"
|
||||
columns: ["conversation_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "conversations"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "notifications_task_id_fkey"
|
||||
columns: ["task_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tasks"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
payment_plan_installments: {
|
||||
Row: {
|
||||
amount: number
|
||||
@@ -1701,6 +1809,184 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
task_assignees: {
|
||||
Row: {
|
||||
assigned_by: string | null
|
||||
created_at: string
|
||||
id: string
|
||||
task_id: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
assigned_by?: string | null
|
||||
created_at?: string
|
||||
id?: string
|
||||
task_id: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
assigned_by?: string | null
|
||||
created_at?: string
|
||||
id?: string
|
||||
task_id?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "task_assignees_task_id_fkey"
|
||||
columns: ["task_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tasks"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
task_comments: {
|
||||
Row: {
|
||||
author_id: string
|
||||
body: string
|
||||
created_at: string
|
||||
edited_at: string | null
|
||||
id: string
|
||||
task_id: string
|
||||
}
|
||||
Insert: {
|
||||
author_id: string
|
||||
body?: string
|
||||
created_at?: string
|
||||
edited_at?: string | null
|
||||
id?: string
|
||||
task_id: string
|
||||
}
|
||||
Update: {
|
||||
author_id?: string
|
||||
body?: string
|
||||
created_at?: string
|
||||
edited_at?: string | null
|
||||
id?: string
|
||||
task_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "task_comments_task_id_fkey"
|
||||
columns: ["task_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tasks"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
task_history: {
|
||||
Row: {
|
||||
actor_id: string | null
|
||||
created_at: string
|
||||
detail: Json
|
||||
event: string
|
||||
id: string
|
||||
task_id: string
|
||||
}
|
||||
Insert: {
|
||||
actor_id?: string | null
|
||||
created_at?: string
|
||||
detail?: Json
|
||||
event: string
|
||||
id?: string
|
||||
task_id: string
|
||||
}
|
||||
Update: {
|
||||
actor_id?: string | null
|
||||
created_at?: string
|
||||
detail?: Json
|
||||
event?: string
|
||||
id?: string
|
||||
task_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "task_history_task_id_fkey"
|
||||
columns: ["task_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tasks"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
tasks: {
|
||||
Row: {
|
||||
case_id: string | null
|
||||
completed_at: string | null
|
||||
completed_by: string | null
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
description: string | null
|
||||
due_date: string | null
|
||||
id: string
|
||||
parent_task_id: string | null
|
||||
priority: Database["public"]["Enums"]["task_priority"]
|
||||
sort_order: number
|
||||
status: Database["public"]["Enums"]["task_status"]
|
||||
title: string
|
||||
updated_at: string
|
||||
workflow_template_id: string | null
|
||||
}
|
||||
Insert: {
|
||||
case_id?: string | null
|
||||
completed_at?: string | null
|
||||
completed_by?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
parent_task_id?: string | null
|
||||
priority?: Database["public"]["Enums"]["task_priority"]
|
||||
sort_order?: number
|
||||
status?: Database["public"]["Enums"]["task_status"]
|
||||
title: string
|
||||
updated_at?: string
|
||||
workflow_template_id?: string | null
|
||||
}
|
||||
Update: {
|
||||
case_id?: string | null
|
||||
completed_at?: string | null
|
||||
completed_by?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
parent_task_id?: string | null
|
||||
priority?: Database["public"]["Enums"]["task_priority"]
|
||||
sort_order?: number
|
||||
status?: Database["public"]["Enums"]["task_status"]
|
||||
title?: string
|
||||
updated_at?: string
|
||||
workflow_template_id?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "tasks_case_id_fkey"
|
||||
columns: ["case_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "cases"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "tasks_parent_task_id_fkey"
|
||||
columns: ["parent_task_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tasks"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "tasks_workflow_template_id_fkey"
|
||||
columns: ["workflow_template_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "workflow_templates"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
time_entries: {
|
||||
Row: {
|
||||
billable: boolean
|
||||
@@ -1779,6 +2065,80 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
workflow_template_tasks: {
|
||||
Row: {
|
||||
created_at: string
|
||||
days_from_start: number
|
||||
default_assignee_id: string | null
|
||||
description: string | null
|
||||
id: string
|
||||
priority: Database["public"]["Enums"]["task_priority"]
|
||||
sort_order: number
|
||||
template_id: string
|
||||
title: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
days_from_start?: number
|
||||
default_assignee_id?: string | null
|
||||
description?: string | null
|
||||
id?: string
|
||||
priority?: Database["public"]["Enums"]["task_priority"]
|
||||
sort_order?: number
|
||||
template_id: string
|
||||
title: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
days_from_start?: number
|
||||
default_assignee_id?: string | null
|
||||
description?: string | null
|
||||
id?: string
|
||||
priority?: Database["public"]["Enums"]["task_priority"]
|
||||
sort_order?: number
|
||||
template_id?: string
|
||||
title?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "workflow_template_tasks_template_id_fkey"
|
||||
columns: ["template_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "workflow_templates"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
workflow_templates: {
|
||||
Row: {
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
description: string | null
|
||||
id: string
|
||||
name: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
description?: string | null
|
||||
id?: string
|
||||
name: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
description?: string | null
|
||||
id?: string
|
||||
name?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
@@ -1788,6 +2148,10 @@ export type Database = {
|
||||
Args: { _case_id: string; _user_id: string }
|
||||
Returns: boolean
|
||||
}
|
||||
can_access_task: {
|
||||
Args: { _task_id: string; _user_id: string }
|
||||
Returns: boolean
|
||||
}
|
||||
has_role: {
|
||||
Args: {
|
||||
_role: Database["public"]["Enums"]["app_role"]
|
||||
@@ -1811,7 +2175,20 @@ export type Database = {
|
||||
| "closed_lost"
|
||||
| "closed"
|
||||
client_type: "hoa" | "individual" | "business" | "condo"
|
||||
comment_entity: "case" | "document"
|
||||
invoice_status: "draft" | "sent" | "paid" | "overdue" | "void"
|
||||
notification_kind:
|
||||
| "task_assigned"
|
||||
| "task_mention"
|
||||
| "task_comment"
|
||||
| "task_due"
|
||||
| "task_completed"
|
||||
| "case_mention"
|
||||
| "document_mention"
|
||||
| "message_mention"
|
||||
| "message"
|
||||
task_priority: "low" | "normal" | "high" | "urgent"
|
||||
task_status: "incomplete" | "complete"
|
||||
}
|
||||
CompositeTypes: {
|
||||
[_ in never]: never
|
||||
@@ -1949,7 +2326,21 @@ export const Constants = {
|
||||
"closed",
|
||||
],
|
||||
client_type: ["hoa", "individual", "business", "condo"],
|
||||
comment_entity: ["case", "document"],
|
||||
invoice_status: ["draft", "sent", "paid", "overdue", "void"],
|
||||
notification_kind: [
|
||||
"task_assigned",
|
||||
"task_mention",
|
||||
"task_comment",
|
||||
"task_due",
|
||||
"task_completed",
|
||||
"case_mention",
|
||||
"document_mention",
|
||||
"message_mention",
|
||||
"message",
|
||||
],
|
||||
task_priority: ["low", "normal", "high", "urgent"],
|
||||
task_status: ["incomplete", "complete"],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import type { Database } from "@/integrations/supabase/types";
|
||||
|
||||
export type NotificationKind = Database["public"]["Enums"]["notification_kind"];
|
||||
|
||||
export interface CreateNotificationInput {
|
||||
user_id: string;
|
||||
kind: NotificationKind;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
link?: string | null;
|
||||
task_id?: string | null;
|
||||
case_id?: string | null;
|
||||
conversation_id?: string | null;
|
||||
created_by?: string | null;
|
||||
}
|
||||
|
||||
export async function createNotifications(rows: CreateNotificationInput[]) {
|
||||
if (!rows.length) return;
|
||||
await supabase.from("notifications").insert(rows);
|
||||
}
|
||||
|
||||
const MENTION_RE = /@\[([^\]]+)\]\(user:([0-9a-f-]{36})\)/g;
|
||||
|
||||
export function extractMentions(body: string): string[] {
|
||||
const ids = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = MENTION_RE.exec(body)) !== null) {
|
||||
ids.add(m[2]);
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
export function renderMentionsToText(body: string): string {
|
||||
return body.replace(MENTION_RE, "@$1");
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface Counts {
|
||||
messages: number;
|
||||
tasks: number;
|
||||
notifications: number;
|
||||
}
|
||||
|
||||
export function useBubbleCounts(): Counts {
|
||||
const { user } = useAuth();
|
||||
const [counts, setCounts] = useState<Counts>({ messages: 0, tasks: 0, notifications: 0 });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!user) {
|
||||
setCounts({ messages: 0, tasks: 0, notifications: 0 });
|
||||
return;
|
||||
}
|
||||
const [memberRes, msgRes, taskRes, notifRes] = await Promise.all([
|
||||
supabase.from("conversation_members").select("conversation_id, last_read_at").eq("user_id", user.id),
|
||||
// unread messages: query per-convo handled below
|
||||
Promise.resolve(null),
|
||||
supabase
|
||||
.from("task_assignees")
|
||||
.select("task_id, tasks!inner(status, due_date)")
|
||||
.eq("user_id", user.id)
|
||||
.eq("tasks.status", "incomplete"),
|
||||
supabase
|
||||
.from("notifications")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("user_id", user.id)
|
||||
.is("read_at", null),
|
||||
]);
|
||||
|
||||
let unreadMessages = 0;
|
||||
const memberRows = memberRes.data ?? [];
|
||||
if (memberRows.length) {
|
||||
// For each conversation, count messages newer than last_read_at and not from self
|
||||
const results = await Promise.all(
|
||||
memberRows.map(async (m) => {
|
||||
const { count } = await supabase
|
||||
.from("messages")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("conversation_id", m.conversation_id)
|
||||
.gt("created_at", m.last_read_at)
|
||||
.neq("sender_id", user.id);
|
||||
return count ?? 0;
|
||||
}),
|
||||
);
|
||||
unreadMessages = results.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
const taskCount = (taskRes.data ?? []).length;
|
||||
setCounts({
|
||||
messages: unreadMessages,
|
||||
tasks: taskCount,
|
||||
notifications: notifRes.count ?? 0,
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
load();
|
||||
const ch = supabase
|
||||
.channel(`bubbles-${user.id}`)
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "messages" }, () => load())
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "task_assignees" }, () => load())
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "tasks" }, () => load())
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "notifications", filter: `user_id=eq.${user.id}` }, () => load())
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "conversation_members", filter: `user_id=eq.${user.id}` }, () => load())
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabase.removeChannel(ch);
|
||||
};
|
||||
}, [user, load]);
|
||||
|
||||
return counts;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { createFileRoute, useSearch } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo, 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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 { format, isBefore, startOfDay, isSameDay, addDays } from "date-fns";
|
||||
import { TaskDetailPanel } from "@/components/tasks/task-detail-panel";
|
||||
import { NewTaskDialog } from "@/components/tasks/new-task-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Route = createFileRoute("/tasks/")({
|
||||
component: TasksPage,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
task: typeof search.task === "string" ? search.task : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
interface TaskRow {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "incomplete" | "complete";
|
||||
priority: "low" | "normal" | "high" | "urgent";
|
||||
due_date: string | null;
|
||||
case_id: string | null;
|
||||
parent_task_id: string | null;
|
||||
created_by: string | null;
|
||||
}
|
||||
interface AssigneeRow {
|
||||
task_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
interface Profile {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
}
|
||||
interface CaseRow {
|
||||
id: string;
|
||||
case_number: string;
|
||||
title: 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",
|
||||
};
|
||||
|
||||
function TasksPage() {
|
||||
const { user } = useAuth();
|
||||
const search = useSearch({ from: "/tasks/" });
|
||||
const [tasks, setTasks] = useState<TaskRow[]>([]);
|
||||
const [assignees, setAssignees] = useState<AssigneeRow[]>([]);
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
const [cases, setCases] = useState<CaseRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [filterAssignee, setFilterAssignee] = useState<string>("me");
|
||||
const [filterStatus, setFilterStatus] = useState<"incomplete" | "complete" | "all">("incomplete");
|
||||
const [showOnlyMine, setShowOnlyMine] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [openTaskId, setOpenTaskId] = useState<string | null>(search.task ?? null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [t, a, p, c] = await Promise.all([
|
||||
supabase
|
||||
.from("tasks")
|
||||
.select("id, title, status, priority, due_date, case_id, parent_task_id, created_by")
|
||||
.is("parent_task_id", null)
|
||||
.order("due_date", { ascending: true, nullsFirst: false }),
|
||||
supabase.from("task_assignees").select("task_id, user_id"),
|
||||
supabase.from("profiles").select("id, full_name, email"),
|
||||
supabase.from("cases").select("id, case_number, title"),
|
||||
]);
|
||||
setTasks((t.data ?? []) as TaskRow[]);
|
||||
setAssignees((a.data ?? []) as AssigneeRow[]);
|
||||
setProfiles((p.data ?? []) as Profile[]);
|
||||
setCases((c.data ?? []) as CaseRow[]);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const ch = supabase
|
||||
.channel("tasks-list")
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "tasks" }, () => load())
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "task_assignees" }, () => load())
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabase.removeChannel(ch);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (search.task) setOpenTaskId(search.task);
|
||||
}, [search.task]);
|
||||
|
||||
const profilesById = useMemo(() => Object.fromEntries(profiles.map((p) => [p.id, p])), [profiles]);
|
||||
const casesById = useMemo(() => Object.fromEntries(cases.map((c) => [c.id, c])), [cases]);
|
||||
const assigneesByTask = useMemo(() => {
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const a of assignees) {
|
||||
(map[a.task_id] = map[a.task_id] || []).push(a.user_id);
|
||||
}
|
||||
return map;
|
||||
}, [assignees]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return tasks.filter((t) => {
|
||||
if (filterStatus !== "all" && t.status !== filterStatus) return false;
|
||||
if (filterAssignee === "me" && user) {
|
||||
if (!(assigneesByTask[t.id] || []).includes(user.id)) return false;
|
||||
} else if (filterAssignee !== "me" && filterAssignee !== "all") {
|
||||
if (!(assigneesByTask[t.id] || []).includes(filterAssignee)) return false;
|
||||
}
|
||||
if (showOnlyMine && user && t.created_by !== user.id) return false;
|
||||
if (query && !t.title.toLowerCase().includes(query.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
}, [tasks, filterStatus, filterAssignee, showOnlyMine, query, user, assigneesByTask]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const today = startOfDay(new Date());
|
||||
const tomorrow = addDays(today, 1);
|
||||
const groups: Record<string, TaskRow[]> = {
|
||||
Overdue: [],
|
||||
Today: [],
|
||||
Tomorrow: [],
|
||||
"This week": [],
|
||||
Later: [],
|
||||
"No due date": [],
|
||||
Completed: [],
|
||||
};
|
||||
for (const t of filtered) {
|
||||
if (t.status === "complete") {
|
||||
groups.Completed.push(t);
|
||||
continue;
|
||||
}
|
||||
if (!t.due_date) {
|
||||
groups["No due date"].push(t);
|
||||
continue;
|
||||
}
|
||||
const d = startOfDay(new Date(t.due_date));
|
||||
if (isBefore(d, today)) groups.Overdue.push(t);
|
||||
else if (isSameDay(d, today)) groups.Today.push(t);
|
||||
else if (isSameDay(d, tomorrow)) groups.Tomorrow.push(t);
|
||||
else if (isBefore(d, addDays(today, 7))) groups["This week"].push(t);
|
||||
else groups.Later.push(t);
|
||||
}
|
||||
return groups;
|
||||
}, [filtered]);
|
||||
|
||||
const toggleComplete = 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);
|
||||
await supabase.from("task_history").insert({
|
||||
task_id: t.id,
|
||||
actor_id: user.id,
|
||||
event: checked ? "completed" : "reopened",
|
||||
detail: {},
|
||||
});
|
||||
};
|
||||
|
||||
const overdueCount = grouped.Overdue.length;
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
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 flex-wrap items-end gap-3 mb-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Label className="text-xs">Search</Label>
|
||||
<div className="relative">
|
||||
<Search className="h-3.5 w-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filter tasks…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Assigned To</Label>
|
||||
<Select value={filterAssignee} onValueChange={setFilterAssignee}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="me">Me</SelectItem>
|
||||
<SelectItem value="all">Anyone</SelectItem>
|
||||
{profiles.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.full_name || p.email}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Status</Label>
|
||||
<Select value={filterStatus} onValueChange={(v) => setFilterStatus(v as any)}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="incomplete">Incomplete</SelectItem>
|
||||
<SelectItem value="complete">Complete</SelectItem>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-1.5">
|
||||
<Switch id="mine" checked={showOnlyMine} onCheckedChange={setShowOnlyMine} />
|
||||
<Label htmlFor="mine" className="text-sm">Only created by me</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overdueCount > 0 && (
|
||||
<div className="mb-4 flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{overdueCount} overdue task{overdueCount === 1 ? "" : "s"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border bg-card">
|
||||
{loading ? (
|
||||
<div className="p-6 text-sm text-muted-foreground">Loading…</div>
|
||||
) : (
|
||||
Object.entries(grouped).map(([group, items]) =>
|
||||
items.length === 0 ? null : (
|
||||
<div key={group}>
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-2 text-xs font-semibold uppercase tracking-wider",
|
||||
group === "Overdue"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/40 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{group} <span className="text-muted-foreground ml-1">{items.length}</span>
|
||||
</div>
|
||||
{items.map((t) => {
|
||||
const ids = assigneesByTask[t.id] || [];
|
||||
const c = t.case_id ? casesById[t.case_id] : null;
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-t hover:bg-accent/40 cursor-pointer"
|
||||
onClick={() => setOpenTaskId(t.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={t.status === "complete"}
|
||||
onCheckedChange={(v) => toggleComplete(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>
|
||||
{c && (
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{c.case_number} — {c.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{t.due_date && (
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<CalIcon className="h-3 w-3" />
|
||||
{format(new Date(t.due_date), "MMM d")}
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{ids.length > 3 && (
|
||||
<div className="h-6 w-6 rounded-full bg-muted border-2 border-background text-[9px] flex items-center justify-center">
|
||||
+{ids.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="p-12 text-center text-sm text-muted-foreground">
|
||||
No tasks match these filters.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
||||
<NewTaskDialog open={newOpen} onOpenChange={setNewOpen} onCreated={(id) => setOpenTaskId(id)} />
|
||||
<TaskDetailPanel
|
||||
taskId={openTaskId}
|
||||
open={!!openTaskId}
|
||||
onOpenChange={(v) => !v && setOpenTaskId(null)}
|
||||
onChanged={load}
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
-- Enums
|
||||
CREATE TYPE public.task_priority AS ENUM ('low', 'normal', 'high', 'urgent');
|
||||
CREATE TYPE public.task_status AS ENUM ('incomplete', 'complete');
|
||||
CREATE TYPE public.comment_entity AS ENUM ('case', 'document');
|
||||
CREATE TYPE public.notification_kind AS ENUM ('task_assigned', 'task_mention', 'task_comment', 'task_due', 'task_completed', 'case_mention', 'document_mention', 'message_mention', 'message');
|
||||
|
||||
-- Workflow templates
|
||||
CREATE TABLE public.workflow_templates (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
created_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE public.workflow_template_tasks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
template_id uuid NOT NULL REFERENCES public.workflow_templates(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
description text,
|
||||
priority public.task_priority NOT NULL DEFAULT 'normal',
|
||||
days_from_start integer NOT NULL DEFAULT 7,
|
||||
default_assignee_id uuid,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Tasks
|
||||
CREATE TABLE public.tasks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title text NOT NULL,
|
||||
description text,
|
||||
status public.task_status NOT NULL DEFAULT 'incomplete',
|
||||
priority public.task_priority NOT NULL DEFAULT 'normal',
|
||||
due_date date,
|
||||
case_id uuid REFERENCES public.cases(id) ON DELETE CASCADE,
|
||||
parent_task_id uuid REFERENCES public.tasks(id) ON DELETE CASCADE,
|
||||
workflow_template_id uuid REFERENCES public.workflow_templates(id) ON DELETE SET NULL,
|
||||
created_by uuid,
|
||||
completed_by uuid,
|
||||
completed_at timestamptz,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tasks_case_id ON public.tasks(case_id);
|
||||
CREATE INDEX idx_tasks_parent ON public.tasks(parent_task_id);
|
||||
CREATE INDEX idx_tasks_due_date ON public.tasks(due_date);
|
||||
CREATE INDEX idx_tasks_status ON public.tasks(status);
|
||||
|
||||
-- Task assignees
|
||||
CREATE TABLE public.task_assignees (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
task_id uuid NOT NULL REFERENCES public.tasks(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL,
|
||||
assigned_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (task_id, user_id)
|
||||
);
|
||||
CREATE INDEX idx_task_assignees_user ON public.task_assignees(user_id);
|
||||
|
||||
-- Task comments
|
||||
CREATE TABLE public.task_comments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
task_id uuid NOT NULL REFERENCES public.tasks(id) ON DELETE CASCADE,
|
||||
author_id uuid NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
edited_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_task_comments_task ON public.task_comments(task_id);
|
||||
|
||||
-- Task history (activity log)
|
||||
CREATE TABLE public.task_history (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
task_id uuid NOT NULL REFERENCES public.tasks(id) ON DELETE CASCADE,
|
||||
actor_id uuid,
|
||||
event text NOT NULL,
|
||||
detail jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_task_history_task ON public.task_history(task_id);
|
||||
|
||||
-- Generic comments (cases / documents)
|
||||
CREATE TABLE public.comments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
entity_type public.comment_entity NOT NULL,
|
||||
entity_id uuid NOT NULL,
|
||||
case_id uuid REFERENCES public.cases(id) ON DELETE CASCADE,
|
||||
author_id uuid NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
edited_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_comments_entity ON public.comments(entity_type, entity_id);
|
||||
CREATE INDEX idx_comments_case ON public.comments(case_id);
|
||||
|
||||
-- Notifications (global inbox)
|
||||
CREATE TABLE public.notifications (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL,
|
||||
kind public.notification_kind NOT NULL,
|
||||
title text NOT NULL,
|
||||
body text,
|
||||
link text,
|
||||
task_id uuid REFERENCES public.tasks(id) ON DELETE CASCADE,
|
||||
case_id uuid REFERENCES public.cases(id) ON DELETE CASCADE,
|
||||
conversation_id uuid REFERENCES public.conversations(id) ON DELETE CASCADE,
|
||||
read_at timestamptz,
|
||||
created_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_notifications_user_unread ON public.notifications(user_id, read_at);
|
||||
|
||||
-- Triggers for updated_at
|
||||
CREATE TRIGGER trg_tasks_updated_at BEFORE UPDATE ON public.tasks FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
CREATE TRIGGER trg_workflow_templates_updated_at BEFORE UPDATE ON public.workflow_templates FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
CREATE TRIGGER trg_workflow_template_tasks_updated_at BEFORE UPDATE ON public.workflow_template_tasks FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- Helper: can_access_task
|
||||
CREATE OR REPLACE FUNCTION public.can_access_task(_task_id uuid, _user_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT
|
||||
public.is_admin(_user_id)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.tasks t
|
||||
WHERE t.id = _task_id
|
||||
AND (
|
||||
t.created_by = _user_id
|
||||
OR (t.case_id IS NOT NULL AND public.can_access_case(t.case_id, _user_id))
|
||||
OR EXISTS (SELECT 1 FROM public.task_assignees ta WHERE ta.task_id = t.id AND ta.user_id = _user_id)
|
||||
)
|
||||
)
|
||||
$$;
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.task_assignees ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.task_comments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.task_history ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.workflow_templates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.workflow_template_tasks ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Tasks policies
|
||||
CREATE POLICY tasks_select ON public.tasks FOR SELECT TO authenticated
|
||||
USING (public.can_access_task(id, auth.uid()));
|
||||
CREATE POLICY tasks_insert ON public.tasks FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
auth.uid() IS NOT NULL
|
||||
AND (case_id IS NULL OR public.can_access_case(case_id, auth.uid()))
|
||||
);
|
||||
CREATE POLICY tasks_update ON public.tasks FOR UPDATE TO authenticated
|
||||
USING (public.can_access_task(id, auth.uid()));
|
||||
CREATE POLICY tasks_delete ON public.tasks FOR DELETE TO authenticated
|
||||
USING (public.is_admin(auth.uid()) OR created_by = auth.uid());
|
||||
|
||||
-- Task assignees policies
|
||||
CREATE POLICY task_assignees_select ON public.task_assignees FOR SELECT TO authenticated
|
||||
USING (public.can_access_task(task_id, auth.uid()));
|
||||
CREATE POLICY task_assignees_insert ON public.task_assignees FOR INSERT TO authenticated
|
||||
WITH CHECK (public.can_access_task(task_id, auth.uid()));
|
||||
CREATE POLICY task_assignees_delete ON public.task_assignees FOR DELETE TO authenticated
|
||||
USING (public.can_access_task(task_id, auth.uid()));
|
||||
|
||||
-- Task comments policies
|
||||
CREATE POLICY task_comments_select ON public.task_comments FOR SELECT TO authenticated
|
||||
USING (public.can_access_task(task_id, auth.uid()));
|
||||
CREATE POLICY task_comments_insert ON public.task_comments FOR INSERT TO authenticated
|
||||
WITH CHECK (author_id = auth.uid() AND public.can_access_task(task_id, auth.uid()));
|
||||
CREATE POLICY task_comments_update ON public.task_comments FOR UPDATE TO authenticated
|
||||
USING (author_id = auth.uid());
|
||||
CREATE POLICY task_comments_delete ON public.task_comments FOR DELETE TO authenticated
|
||||
USING (author_id = auth.uid() OR public.is_admin(auth.uid()));
|
||||
|
||||
-- Task history policies
|
||||
CREATE POLICY task_history_select ON public.task_history FOR SELECT TO authenticated
|
||||
USING (public.can_access_task(task_id, auth.uid()));
|
||||
CREATE POLICY task_history_insert ON public.task_history FOR INSERT TO authenticated
|
||||
WITH CHECK (public.can_access_task(task_id, auth.uid()));
|
||||
|
||||
-- Generic comments policies
|
||||
CREATE POLICY comments_select ON public.comments FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_admin(auth.uid())
|
||||
OR (case_id IS NOT NULL AND public.can_access_case(case_id, auth.uid()))
|
||||
OR author_id = auth.uid()
|
||||
);
|
||||
CREATE POLICY comments_insert ON public.comments FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
author_id = auth.uid()
|
||||
AND (case_id IS NULL OR public.can_access_case(case_id, auth.uid()))
|
||||
);
|
||||
CREATE POLICY comments_update ON public.comments FOR UPDATE TO authenticated
|
||||
USING (author_id = auth.uid());
|
||||
CREATE POLICY comments_delete ON public.comments FOR DELETE TO authenticated
|
||||
USING (author_id = auth.uid() OR public.is_admin(auth.uid()));
|
||||
|
||||
-- Notifications policies (private to user)
|
||||
CREATE POLICY notifications_select ON public.notifications FOR SELECT TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
CREATE POLICY notifications_insert ON public.notifications FOR INSERT TO authenticated
|
||||
WITH CHECK (auth.uid() IS NOT NULL);
|
||||
CREATE POLICY notifications_update ON public.notifications FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
CREATE POLICY notifications_delete ON public.notifications FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- Workflow templates policies
|
||||
CREATE POLICY wft_select ON public.workflow_templates FOR SELECT TO authenticated USING (true);
|
||||
CREATE POLICY wft_insert ON public.workflow_templates FOR INSERT TO authenticated WITH CHECK (auth.uid() IS NOT NULL);
|
||||
CREATE POLICY wft_update ON public.workflow_templates FOR UPDATE TO authenticated
|
||||
USING (created_by = auth.uid() OR public.is_admin(auth.uid()));
|
||||
CREATE POLICY wft_delete ON public.workflow_templates FOR DELETE TO authenticated
|
||||
USING (created_by = auth.uid() OR public.is_admin(auth.uid()));
|
||||
|
||||
CREATE POLICY wftt_select ON public.workflow_template_tasks FOR SELECT TO authenticated USING (true);
|
||||
CREATE POLICY wftt_insert ON public.workflow_template_tasks FOR INSERT TO authenticated
|
||||
WITH CHECK (EXISTS (SELECT 1 FROM public.workflow_templates wt WHERE wt.id = template_id AND (wt.created_by = auth.uid() OR public.is_admin(auth.uid()))));
|
||||
CREATE POLICY wftt_update ON public.workflow_template_tasks FOR UPDATE TO authenticated
|
||||
USING (EXISTS (SELECT 1 FROM public.workflow_templates wt WHERE wt.id = template_id AND (wt.created_by = auth.uid() OR public.is_admin(auth.uid()))));
|
||||
CREATE POLICY wftt_delete ON public.workflow_template_tasks FOR DELETE TO authenticated
|
||||
USING (EXISTS (SELECT 1 FROM public.workflow_templates wt WHERE wt.id = template_id AND (wt.created_by = auth.uid() OR public.is_admin(auth.uid()))));
|
||||
|
||||
-- Realtime
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.tasks;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.task_comments;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.task_assignees;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.task_history;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comments;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.notifications;
|
||||
Reference in New Issue
Block a user