Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 02:54:58 +00:00
co-authored by renee-png
parent f524dc8fd1
commit ca5b7ea01a
7 changed files with 1659 additions and 0 deletions
+164
View File
@@ -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>
);
}
+260
View File
@@ -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>
);
}
+608
View File
@@ -0,0 +1,608 @@
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;
}
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>
);
}
+36
View File
@@ -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");
}
+79
View File
@@ -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;
}
+352
View File
@@ -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>
);
}