Files
mylegal-stage-law/src/routes/calendar.index.tsx
T
2026-04-18 22:54:24 +00:00

1059 lines
36 KiB
TypeScript

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<string, (typeof EVENT_TYPES)[number]> = Object.fromEntries(
EVENT_TYPES.map((t) => [t.key, t]),
);
function typeMeta(key: string) {
return EVENT_TYPE_MAP[key] ?? EVENT_TYPE_MAP.other;
}
// ---- 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<Date>(new Date());
const [selected, setSelected] = useState<Date>(new Date());
const [events, setEvents] = useState<Evt[]>([]);
const [cases, setCases] = useState<CaseRow[]>([]);
const [clients, setClients] = useState<ClientRow[]>([]);
const [staff, setStaff] = useState<StaffRow[]>([]);
const [loading, setLoading] = useState(true);
// filters
const [activeTypes, setActiveTypes] = useState<Set<string>>(
new Set(EVENT_TYPES.map((t) => t.key)),
);
const [activeStaff, setActiveStaff] = useState<Set<string>>(new Set()); // empty = all
// dialog state
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [fTitle, setFTitle] = useState("");
const [fType, setFType] = useState<EventTypeKey>("client_meeting");
const [fStart, setFStart] = useState<Date | undefined>(undefined);
const [fStartTime, setFStartTime] = useState("09:00");
const [fEndTime, setFEndTime] = useState("10:00");
const [fAllDay, setFAllDay] = useState(false);
const [fCase, setFCase] = useState<string>("none");
const [fClient, setFClient] = useState<string>("none");
const [fLocation, setFLocation] = useState("");
const [fNotes, setFNotes] = useState("");
const [fAssignee, setFAssignee] = useState<string>("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<string, StaffRow>();
(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<string, CaseRow & { client_id: string | null }>();
(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<string, ClientRow>();
(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<string, Evt[]>();
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 (
<ProtectedLayout>
<PageContainer>
<PageHeader
title="Calendar"
description="Schedule and view tasks, hearings, and events across staff."
actions={
<Button onClick={() => openCreate()}>
<Plus className="h-4 w-4 mr-2" />
Add event
</Button>
}
/>
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-6">
{/* Sidebar filters */}
<div className="space-y-4">
<Card className="p-3">
<Calendar
mode="single"
selected={selected}
month={cursor}
onMonthChange={setCursor}
onSelect={(d) => {
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")}
/>
</Card>
<Card className="p-4">
<h4 className="font-serif text-sm font-semibold mb-3">Event Types</h4>
<div className="space-y-2">
{EVENT_TYPES.map((t) => (
<label key={t.key} className="flex items-center gap-2 cursor-pointer text-sm">
<Checkbox
checked={activeTypes.has(t.key)}
onCheckedChange={() => toggleType(t.key)}
/>
<span className={cn("h-2.5 w-2.5 rounded-full", t.bg)} />
<span>{t.label}</span>
</label>
))}
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-serif text-sm font-semibold">Staff</h4>
{activeStaff.size > 0 && (
<button
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => setActiveStaff(new Set())}
>
Clear
</button>
)}
</div>
{staff.length === 0 ? (
<p className="text-xs text-muted-foreground">No staff members.</p>
) : (
<div className="space-y-2 max-h-64 overflow-auto">
{staff.map((s) => (
<label key={s.id} className="flex items-center gap-2 cursor-pointer text-sm">
<Checkbox
checked={activeStaff.has(s.id)}
onCheckedChange={() => toggleStaff(s.id)}
/>
<span className="truncate">{s.full_name}</span>
</label>
))}
</div>
)}
<p className="text-xs text-muted-foreground mt-2">
{activeStaff.size === 0 ? "Showing all staff" : `Filtering ${activeStaff.size}`}
</p>
</Card>
</div>
{/* Main */}
<div className="space-y-4">
<Card className="p-4">
<div className="flex items-center justify-between flex-wrap gap-3 mb-4">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setCursor(new Date())}>
Today
</Button>
<Button
variant="ghost"
size="icon"
onClick={() =>
setCursor(view === "week" ? addDays(cursor, -7) : subMonths(cursor, 1))
}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() =>
setCursor(view === "week" ? addDays(cursor, 7) : addMonths(cursor, 1))
}
>
<ChevronRight className="h-4 w-4" />
</Button>
<h2 className="font-serif text-xl ml-2">
{view === "week"
? `Week of ${format(startOfWeek(cursor), "MMM d, yyyy")}`
: format(cursor, "MMMM yyyy")}
</h2>
</div>
<div className="flex items-center gap-1 border rounded-md p-1">
{(["month", "week", "day", "list"] as const).map((v) => (
<Button
key={v}
size="sm"
variant={view === v ? "secondary" : "ghost"}
onClick={() => setView(v)}
className="h-7 px-3 capitalize"
>
{v}
</Button>
))}
</div>
</div>
{loading ? (
<p className="text-sm text-muted-foreground py-8 text-center">Loading…</p>
) : view === "month" ? (
<MonthGrid
days={monthDays}
cursor={cursor}
selected={selected}
eventsByDay={eventsByDay}
onSelectDay={(d) => setSelected(d)}
onCreateOnDay={(d) => openCreate(d)}
/>
) : view === "week" ? (
<WeekStrip
days={weekDays}
selected={selected}
eventsByDay={eventsByDay}
onSelectDay={(d) => setSelected(d)}
/>
) : view === "list" ? (
<ListView events={visibleEvents} onEdit={openEdit} />
) : null}
</Card>
<Card className="p-5">
<h3 className="font-serif text-lg mb-3 flex items-center gap-2">
<CalendarDays className="h-4 w-4" />
{format(selected, "EEEE, MMMM d, yyyy")}
</h3>
{dayEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">No events scheduled.</p>
) : (
<ul className="space-y-2">
{dayEvents.map((e) => (
<EventRow key={`${e.source}-${e.id}`} evt={e} onEdit={openEdit} />
))}
</ul>
)}
</Card>
<Card className="p-5">
<h3 className="font-serif text-lg mb-3">Upcoming</h3>
{upcoming.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing on the horizon.</p>
) : (
<ul className="space-y-2">
{upcoming.map((e) => (
<EventRow key={`up-${e.source}-${e.id}`} evt={e} showDate onEdit={openEdit} />
))}
</ul>
)}
</Card>
</div>
</div>
{/* Add event dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingId ? "Edit event" : "Add event"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 max-h-[70vh] overflow-auto pr-1">
<div>
<Label>Title</Label>
<Input
value={fTitle}
onChange={(e) => setFTitle(e.target.value)}
placeholder="Hearing, deposition, meeting…"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Event type</Label>
<Select value={fType} onValueChange={(v) => setFType(v as EventTypeKey)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EVENT_TYPES.map((t) => (
<SelectItem key={t.key} value={t.key}>
<span className="flex items-center gap-2">
<span className={cn("h-2.5 w-2.5 rounded-full", t.bg)} />
{t.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Assign to</Label>
<Select value={fAssignee} onValueChange={setFAssignee}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="self">Me</SelectItem>
{staff.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.full_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>Date</Label>
<div className="border rounded-md p-2 mt-1">
<Calendar
mode="single"
selected={fStart}
onSelect={setFStart}
className={cn("pointer-events-auto")}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="allday"
checked={fAllDay}
onCheckedChange={(c) => setFAllDay(Boolean(c))}
/>
<Label htmlFor="allday" className="cursor-pointer">
All day
</Label>
</div>
{!fAllDay && (
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Start time (EST)</Label>
<Input
type="time"
value={fStartTime}
onChange={(e) => setFStartTime(e.target.value)}
/>
</div>
<div>
<Label>End time (EST)</Label>
<Input
type="time"
value={fEndTime}
onChange={(e) => setFEndTime(e.target.value)}
/>
</div>
<p className="col-span-2 text-xs text-muted-foreground -mt-1">
All times are entered and displayed in Eastern Time.
</p>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Related case</Label>
<Select value={fCase} onValueChange={setFCase}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{cases.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.case_number} — {c.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Related client</Label>
<Select value={fClient} onValueChange={setFClient}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>Location</Label>
<Input
value={fLocation}
onChange={(e) => setFLocation(e.target.value)}
placeholder="Courtroom, address, Zoom link…"
/>
</div>
<div>
<Label>Description</Label>
<Textarea value={fNotes} onChange={(e) => setFNotes(e.target.value)} rows={3} />
</div>
</div>
<DialogFooter className="gap-2 sm:justify-between">
{editingId ? (
<Button variant="ghost" onClick={handleDelete} disabled={saving} className="text-destructive">
<Trash2 className="h-4 w-4 mr-1" /> Delete
</Button>
) : <span />}
<div className="flex gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={saving}>
{saving ? "Saving…" : editingId ? "Save changes" : "Save event"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</PageContainer>
</ProtectedLayout>
);
}
// ---- Subcomponents ----
function MonthGrid({
days,
cursor,
selected,
eventsByDay,
onSelectDay,
onCreateOnDay,
}: {
days: Date[];
cursor: Date;
selected: Date;
eventsByDay: Map<string, Evt[]>;
onSelectDay: (d: Date) => void;
onCreateOnDay: (d: Date) => void;
}) {
const dayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const today = new Date();
return (
<div>
<div
className="text-xs font-medium text-muted-foreground border-b"
style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))" }}
>
{dayLabels.map((d) => (
<div key={d} className="px-2 py-1.5 text-center">
{d}
</div>
))}
</div>
<div
style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))", gridAutoRows: "1fr" }}
>
{days.map((d) => {
const key = format(d, "yyyy-MM-dd");
const dayEvts = eventsByDay.get(key) ?? [];
const inMonth = isSameMonth(d, cursor);
const isToday = isSameDay(d, today);
const isSel = isSameDay(d, selected);
return (
<div
key={key}
onClick={() => onSelectDay(d)}
onDoubleClick={() => onCreateOnDay(d)}
style={{ minHeight: 100 }}
className={cn(
"border-b border-r p-1.5 cursor-pointer transition-colors",
"hover:bg-accent/40",
!inMonth && "bg-muted/30 text-muted-foreground",
isSel && "ring-1 ring-primary ring-inset",
)}
>
<div className="flex items-center justify-between mb-1">
<span
className={cn(
"text-xs font-medium h-5 w-5 flex items-center justify-center rounded-full",
isToday && "bg-primary text-primary-foreground",
)}
>
{format(d, "d")}
</span>
</div>
<div className="space-y-0.5">
{dayEvts.slice(0, 3).map((e) => {
const meta = typeMeta(e.event_type);
return (
<div
key={`${e.source}-${e.id}`}
className={cn(
"text-[10px] leading-tight px-1.5 py-0.5 rounded truncate text-white",
meta.bg,
)}
title={e.title}
>
{!e.all_day && formatInEST(e.start, "h:mma ")}
{e.title}
</div>
);
})}
{dayEvts.length > 3 && (
<div className="text-[10px] text-muted-foreground px-1.5">
+{dayEvts.length - 3} more
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
function WeekStrip({
days,
selected,
eventsByDay,
onSelectDay,
}: {
days: Date[];
selected: Date;
eventsByDay: Map<string, Evt[]>;
onSelectDay: (d: Date) => void;
}) {
const today = new Date();
return (
<div
style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))", gap: "0.5rem" }}
>
{days.map((d) => {
const key = format(d, "yyyy-MM-dd");
const dayEvts = eventsByDay.get(key) ?? [];
const isToday = isSameDay(d, today);
const isSel = isSameDay(d, selected);
return (
<div
key={key}
onClick={() => onSelectDay(d)}
className={cn(
"border rounded-md p-2 min-h-[200px] cursor-pointer hover:bg-accent/40 transition-colors",
isSel && "ring-1 ring-primary",
)}
>
<div className="text-xs text-muted-foreground">{format(d, "EEE")}</div>
<div
className={cn(
"text-lg font-semibold mb-2",
isToday && "text-primary",
)}
>
{format(d, "d")}
</div>
<div className="space-y-1">
{dayEvts.map((e) => {
const meta = typeMeta(e.event_type);
return (
<div
key={`${e.source}-${e.id}`}
className={cn("text-[11px] px-1.5 py-1 rounded truncate text-white", meta.bg)}
title={e.title}
>
{!e.all_day && formatInEST(e.start, "h:mma ")}
{e.title}
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
function ListView({ events, onEdit }: { events: Evt[]; onEdit?: (e: Evt) => void }) {
const today = startOfDay(new Date());
const upcoming = [...events]
.filter((e) => parseISO(e.start) >= today)
.sort((a, b) => a.start.localeCompare(b.start));
const grouped = new Map<string, Evt[]>();
upcoming.forEach((e) => {
const k = e.start.slice(0, 10);
const arr = grouped.get(k) ?? [];
arr.push(e);
grouped.set(k, arr);
});
if (upcoming.length === 0)
return <p className="text-sm text-muted-foreground py-8 text-center">No upcoming events.</p>;
return (
<div className="space-y-4">
{Array.from(grouped.entries()).map(([day, evts]) => (
<div key={day}>
<h4 className="font-serif text-sm font-semibold mb-2">
{format(parseISO(`${day}T00:00:00`), "EEEE, MMMM d, yyyy")}
</h4>
<ul className="space-y-2">
{evts.map((e) => (
<EventRow key={`${e.source}-${e.id}`} evt={e} onEdit={onEdit} />
))}
</ul>
</div>
))}
</div>
);
}
function EventRow({
evt,
showDate,
onEdit,
}: {
evt: Evt;
showDate?: boolean;
onEdit?: (e: Evt) => void;
}) {
const meta = typeMeta(evt.event_type);
const Icon = evt.source === "task" ? CheckSquare : Briefcase;
const content = (
<div className="flex items-start gap-3 px-3 py-2 rounded-md border bg-card hover:bg-accent/40 transition-colors">
<span className={cn("h-2.5 w-2.5 rounded-full mt-1.5 shrink-0", meta.bg)} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm font-medium truncate">{evt.title}</span>
<Badge variant="outline" className="text-[10px] py-0">
{meta.label}
</Badge>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-3 flex-wrap mt-0.5">
{showDate && <span>{formatInEST(evt.start, "EEE, MMM d")}</span>}
{!evt.all_day && <span>{formatInEST(evt.start, "h:mm a")} EST</span>}
{evt.all_day && <span>All day</span>}
{evt.case_label && <span className="truncate">· {evt.case_label}</span>}
{evt.client_label && !evt.case_label && (
<span className="truncate">· {evt.client_label}</span>
)}
{evt.owner_name && <span>· {evt.owner_name}</span>}
{evt.location && (
<span className="flex items-center gap-1">
· <MapPin className="h-3 w-3" /> {evt.location}
</span>
)}
</div>
</div>
</div>
);
// For real "event" rows, clicking opens the edit dialog (preferred over case nav).
if (evt.source === "event" && onEdit) {
return (
<li>
<button
type="button"
onClick={() => onEdit(evt)}
className="w-full text-left"
>
{content}
</button>
</li>
);
}
if (evt.case_id) {
return (
<li>
<Link to="/cases/$caseId" params={{ caseId: evt.case_id }}>
{content}
</Link>
</li>
);
}
return <li>{content}</li>;
}