import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useMemo, useState } from "react"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; import { ProtectedLayout } from "@/components/protected-layout"; import { 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 { Card } from "@/components/ui/card"; import { Calendar } from "@/components/ui/calendar"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Plus, Briefcase, CheckSquare, ChevronLeft, ChevronRight, CalendarDays, MapPin, Trash2, } from "lucide-react"; import { format, isSameDay, isSameMonth, parseISO, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, addMonths, subMonths, startOfDay, } from "date-fns"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { estWallTimeToUTC, formatInEST, toESTDate } from "@/lib/tz"; export const Route = createFileRoute("/calendar/")({ component: CalendarPage, }); // ---- Event type config ---- const EVENT_TYPES = [ { key: "court", label: "Court", color: "var(--chart-1, hsl(0 72% 51%))", bg: "bg-red-500" }, { key: "deposition", label: "Deposition", color: "var(--chart-2)", bg: "bg-purple-500" }, { key: "mediation", label: "Mediation", color: "var(--chart-3)", bg: "bg-amber-500" }, { key: "client_meeting", label: "Client Meeting", color: "var(--chart-4)", bg: "bg-blue-500" }, { key: "phone_call", label: "Phone Call / Consult", color: "var(--chart-5)", bg: "bg-cyan-500" }, { key: "hearing", label: "Hearing", color: "", bg: "bg-rose-500" }, { key: "deadline", label: "Deadline", color: "", bg: "bg-orange-500" }, { key: "task", label: "Task", color: "", bg: "bg-emerald-500" }, { key: "personal", label: "Personal", color: "", bg: "bg-pink-500" }, { key: "general", label: "General", color: "", bg: "bg-indigo-500" }, { key: "other", label: "Other", color: "", bg: "bg-slate-500" }, ] as const; type EventTypeKey = (typeof EVENT_TYPES)[number]["key"]; const EVENT_TYPE_MAP: Record = Object.fromEntries( EVENT_TYPES.map((t) => [t.key, t]), ); function typeMeta(key: string) { return EVENT_TYPE_MAP[key] ?? EVENT_TYPE_MAP.other; } function ownerInitials(name: string | null): string { if (!name) return ""; const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return ""; if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } // ---- Unified event ---- interface Evt { id: string; source: "event" | "task" | "case_hearing"; title: string; start: string; // ISO end: string | null; all_day: boolean; event_type: string; case_id: string | null; case_label: string | null; client_id: string | null; client_label: string | null; owner_id: string | null; owner_name: string | null; location: string | null; description: string | null; } interface CaseRow { id: string; title: string; case_number: string; } interface ClientRow { id: string; name: string; } interface StaffRow { id: string; full_name: string; email: string; } function CalendarPage() { const { user } = useAuth(); const [view, setView] = useState<"month" | "week" | "day" | "list">("month"); const [cursor, setCursor] = useState(new Date()); const [selected, setSelected] = useState(new Date()); const [events, setEvents] = useState([]); const [cases, setCases] = useState([]); const [clients, setClients] = useState([]); const [staff, setStaff] = useState([]); const [loading, setLoading] = useState(true); // filters const [activeTypes, setActiveTypes] = useState>( new Set(EVENT_TYPES.map((t) => t.key)), ); const [activeStaff, setActiveStaff] = useState>(new Set()); // empty = all // dialog state const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [saving, setSaving] = useState(false); const [fTitle, setFTitle] = useState(""); const [fType, setFType] = useState("client_meeting"); const [fStart, setFStart] = useState(undefined); const [fStartTime, setFStartTime] = useState("09:00"); const [fEndTime, setFEndTime] = useState("10:00"); const [fAllDay, setFAllDay] = useState(false); const [fCase, setFCase] = useState("none"); const [fClient, setFClient] = useState("none"); const [fLocation, setFLocation] = useState(""); const [fNotes, setFNotes] = useState(""); const [fAssignee, setFAssignee] = useState("self"); const reload = async () => { setLoading(true); // Pull a wide window around the cursor so month/week/list views are populated. // (Avoids hitting Supabase's default 1000-row cap on large event tables.) const winStart = format(addMonths(startOfMonth(cursor), -6), "yyyy-MM-dd"); const winEnd = format(addMonths(endOfMonth(cursor), 12), "yyyy-MM-dd"); const [eventsRes, tasksRes, casesRes, clientsRes, staffRes] = await Promise.all([ supabase .from("events") .select( "id,title,start_at,end_at,all_day,event_type,case_id,client_id,location,description,created_by,assigned_to", ) .gte("start_at", `${winStart}T00:00:00`) .lte("start_at", `${winEnd}T23:59:59`) .order("start_at", { ascending: true }) .limit(5000), supabase .from("tasks") .select("id,title,due_date,case_id,status,created_by") .not("due_date", "is", null) .eq("status", "incomplete") .gte("due_date", winStart) .lte("due_date", winEnd), supabase .from("cases") .select("id,title,case_number,next_hearing_date,next_hearing_notes,client_id") .order("title"), supabase.from("clients").select("id,name").order("name"), supabase.from("profiles").select("id,full_name,email").order("full_name"), ]); const profileMap = new Map(); (staffRes.data ?? []).forEach((p) => profileMap.set(p.id, { id: p.id, full_name: p.full_name || p.email, email: p.email }), ); const caseMap = new Map(); (casesRes.data ?? []).forEach((c) => caseMap.set(c.id, { id: c.id, title: c.title, case_number: c.case_number, client_id: c.client_id ?? null, }), ); const clientMap = new Map(); (clientsRes.data ?? []).forEach((c) => clientMap.set(c.id, { id: c.id, name: c.name })); const evts: Evt[] = []; for (const e of eventsRes.data ?? []) { const ownerId = (e.assigned_to as string | null) ?? e.created_by ?? null; const owner = ownerId ? profileMap.get(ownerId) : null; const c = e.case_id ? caseMap.get(e.case_id) : null; const clientId = e.client_id ?? c?.client_id ?? null; const cl = clientId ? clientMap.get(clientId) : null; evts.push({ id: e.id, source: "event", title: e.title, start: e.start_at as string, end: (e.end_at as string | null) ?? null, all_day: e.all_day ?? false, event_type: e.event_type ?? "other", case_id: e.case_id, case_label: c ? `${c.case_number} — ${c.title}` : null, client_id: clientId, client_label: cl?.name ?? null, owner_id: ownerId, owner_name: owner?.full_name ?? null, location: e.location ?? null, description: e.description ?? null, }); } for (const t of tasksRes.data ?? []) { const owner = t.created_by ? profileMap.get(t.created_by) : null; const c = t.case_id ? caseMap.get(t.case_id) : null; const cl = c?.client_id ? clientMap.get(c.client_id) : null; evts.push({ id: t.id, source: "task", title: t.title, start: `${t.due_date}T00:00:00`, end: null, all_day: true, event_type: "task", case_id: t.case_id, case_label: c ? `${c.case_number} — ${c.title}` : null, client_id: c?.client_id ?? null, client_label: cl?.name ?? null, owner_id: t.created_by, owner_name: owner?.full_name ?? null, location: null, description: null, }); } for (const c of casesRes.data ?? []) { if (!c.next_hearing_date) continue; const cl = c.client_id ? clientMap.get(c.client_id) : null; evts.push({ id: c.id, source: "case_hearing", title: `Hearing: ${c.title}`, start: `${c.next_hearing_date}T00:00:00`, end: null, all_day: true, event_type: "hearing", case_id: c.id, case_label: `${c.case_number} — ${c.title}`, client_id: c.client_id ?? null, client_label: cl?.name ?? null, owner_id: null, owner_name: null, location: null, description: c.next_hearing_notes, }); } setEvents(evts); setCases(Array.from(caseMap.values()).map(({ id, title, case_number }) => ({ id, title, case_number }))); setClients(Array.from(clientMap.values())); setStaff(Array.from(profileMap.values())); setLoading(false); }; useEffect(() => { reload(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [cursor.getFullYear(), cursor.getMonth()]); const visibleEvents = useMemo(() => { return events.filter((e) => { // Treat unknown event types as "other" so they remain visible const typeKey = EVENT_TYPE_MAP[e.event_type] ? e.event_type : "other"; if (!activeTypes.has(typeKey)) return false; if (activeStaff.size > 0) { if (!e.owner_id || !activeStaff.has(e.owner_id)) return false; } return true; }); }, [events, activeTypes, activeStaff]); const eventsByDay = useMemo(() => { const m = new Map(); for (const e of visibleEvents) { // Bucket events by their EST calendar day, not the raw UTC ISO date. const key = e.all_day ? e.start.slice(0, 10) : formatInEST(e.start, "yyyy-MM-dd"); const arr = m.get(key) ?? []; arr.push(e); m.set(key, arr); } for (const arr of m.values()) { arr.sort((a, b) => a.start.localeCompare(b.start)); } return m; }, [visibleEvents]); const dayEvents = useMemo( () => eventsByDay.get(format(selected, "yyyy-MM-dd")) ?? [], [eventsByDay, selected], ); const upcoming = useMemo(() => { const today = startOfDay(new Date()); return [...visibleEvents] .filter((e) => parseISO(e.start) >= today) .sort((a, b) => a.start.localeCompare(b.start)) .slice(0, 10); }, [visibleEvents]); const toggleType = (key: string) => { setActiveTypes((s) => { const n = new Set(s); if (n.has(key)) n.delete(key); else n.add(key); return n; }); }; const toggleStaff = (id: string) => { setActiveStaff((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; }); }; const openCreate = (presetDate?: Date) => { setEditingId(null); setFTitle(""); setFType("client_meeting"); setFStart(presetDate ?? selected); setFStartTime("09:00"); setFEndTime("10:00"); setFAllDay(false); setFCase("none"); setFClient("none"); setFLocation(""); setFNotes(""); setFAssignee("self"); setDialogOpen(true); }; const openEdit = (evt: Evt) => { if (evt.source !== "event") return; // tasks/hearings edit elsewhere setEditingId(evt.id); setFTitle(evt.title); setFType((EVENT_TYPE_MAP[evt.event_type] ? evt.event_type : "other") as EventTypeKey); // Convert UTC timestamp into the EST wall-clock so the date pickers and // HH:mm inputs reflect what the user originally entered (in EST). const estStart = toESTDate(evt.start); setFStart(estStart); setFAllDay(evt.all_day); if (!evt.all_day) { setFStartTime(formatInEST(evt.start, "HH:mm")); setFEndTime(evt.end ? formatInEST(evt.end, "HH:mm") : formatInEST(evt.start, "HH:mm")); } setFCase(evt.case_id ?? "none"); setFClient(evt.client_id ?? "none"); setFLocation(evt.location ?? ""); setFNotes(evt.description ?? ""); setFAssignee(evt.owner_id ?? "self"); setDialogOpen(true); }; const handleCreate = async () => { if (!user || !fTitle.trim() || !fStart) { toast.error("Title and date are required"); return; } setSaving(true); const dateStr = format(fStart, "yyyy-MM-dd"); // Times are entered as EST wall-clock; convert to a UTC ISO for storage so // Postgres `timestamptz` records the correct instant regardless of browser TZ. const startISO = fAllDay ? estWallTimeToUTC(dateStr, "00:00") : estWallTimeToUTC(dateStr, fStartTime); const endISO = fAllDay ? null : estWallTimeToUTC(dateStr, fEndTime); const ownerId = fAssignee === "self" ? user.id : fAssignee; const payload = { title: fTitle.trim(), event_type: fType, start_at: startISO, end_at: endISO, all_day: fAllDay, case_id: fCase === "none" ? null : fCase, client_id: fClient === "none" ? null : fClient, location: fLocation.trim() || null, description: fNotes.trim() || null, assigned_to: ownerId, }; const { error } = editingId ? await supabase.from("events").update(payload).eq("id", editingId) : await supabase.from("events").insert({ ...payload, created_by: user.id }); setSaving(false); if (error) { toast.error(error.message); return; } toast.success(editingId ? "Event updated" : "Event scheduled"); setDialogOpen(false); reload(); }; const handleDelete = async () => { if (!editingId) return; if (!confirm("Delete this event?")) return; setSaving(true); const { error } = await supabase.from("events").delete().eq("id", editingId); setSaving(false); if (error) { toast.error(error.message); return; } toast.success("Event deleted"); setDialogOpen(false); reload(); }; // ---- Month grid ---- const monthDays = useMemo(() => { const start = startOfWeek(startOfMonth(cursor), { weekStartsOn: 0 }); const end = endOfWeek(endOfMonth(cursor), { weekStartsOn: 0 }); const days: Date[] = []; let d = start; while (d <= end) { days.push(d); d = addDays(d, 1); } return days; }, [cursor]); const weekDays = useMemo(() => { const start = startOfWeek(cursor, { weekStartsOn: 0 }); return Array.from({ length: 7 }, (_, i) => addDays(start, i)); }, [cursor]); return ( openCreate()}> Add event } />
{/* Sidebar filters */}
{ if (d) { setSelected(d); setCursor(d); } }} modifiers={{ hasEvent: (d) => eventsByDay.has(format(d, "yyyy-MM-dd")) }} modifiersClassNames={{ hasEvent: "font-semibold text-primary" }} className={cn("pointer-events-auto")} />

Event Types

{EVENT_TYPES.map((t) => ( ))}

Staff

{activeStaff.size > 0 && ( )}
{staff.length === 0 ? (

No staff members.

) : (
{staff.map((s) => ( ))}
)}

{activeStaff.size === 0 ? "Showing all staff" : `Filtering ${activeStaff.size}`}

{/* Main */}

{view === "week" ? `Week of ${format(startOfWeek(cursor), "MMM d, yyyy")}` : format(cursor, "MMMM yyyy")}

{(["month", "week", "day", "list"] as const).map((v) => ( ))}
{loading ? (

Loading…

) : view === "month" ? ( setSelected(d)} onCreateOnDay={(d) => openCreate(d)} /> ) : view === "week" ? ( setSelected(d)} /> ) : view === "list" ? ( ) : null}

{format(selected, "EEEE, MMMM d, yyyy")}

{dayEvents.length === 0 ? (

No events scheduled.

) : (
    {dayEvents.map((e) => ( ))}
)}

Upcoming

{upcoming.length === 0 ? (

Nothing on the horizon.

) : (
    {upcoming.map((e) => ( ))}
)}
{/* Add event dialog */} {editingId ? "Edit event" : "Add event"}
setFTitle(e.target.value)} placeholder="Hearing, deposition, meeting…" />
setFAllDay(Boolean(c))} />
{!fAllDay && (
setFStartTime(e.target.value)} />
setFEndTime(e.target.value)} />

All times are entered and displayed in Eastern Time.

)}
setFLocation(e.target.value)} placeholder="Courtroom, address, Zoom link…" />