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) => ({ 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 = { 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([]); const [assignees, setAssignees] = useState([]); const [profiles, setProfiles] = useState([]); const [cases, setCases] = useState([]); const [loading, setLoading] = useState(true); const [filterAssignee, setFilterAssignee] = useState("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(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 = {}; 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 = { 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 ( setNewOpen(true)}> New task } />
setQuery(e.target.value)} placeholder="Filter tasks…" className="pl-8" />
{overdueCount > 0 && (
{overdueCount} overdue task{overdueCount === 1 ? "" : "s"}
)}
{loading ? (
Loading…
) : ( Object.entries(grouped).map(([group, items]) => items.length === 0 ? null : (
{group} {items.length}
{items.map((t) => { const ids = assigneesByTask[t.id] || []; const c = t.case_id ? casesById[t.case_id] : null; return (
setOpenTaskId(t.id)} > toggleComplete(t, !!v)} onClick={(e) => e.stopPropagation()} />
{t.title}
{c && (
{c.case_number} — {c.title}
)}
{t.due_date && (
{format(new Date(t.due_date), "MMM d")}
)}
{ids.slice(0, 3).map((uid) => { const p = profilesById[uid]; return ( {p ? initials(p.full_name || p.email) : "?"} ); })} {ids.length > 3 && (
+{ids.length - 3}
)}
); })}
), ) )} {!loading && filtered.length === 0 && (
No tasks match these filters.
)}
setOpenTaskId(id)} /> !v && setOpenTaskId(null)} onChanged={load} /> ); }