Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
1223 lines
42 KiB
TypeScript
1223 lines
42 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;
|
|
}
|
|
|
|
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<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}
|
|
staff={staff}
|
|
onBulkUpdated={reload}
|
|
/>
|
|
) : 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);
|
|
const initials = ownerInitials(e.owner_name);
|
|
return (
|
|
<div
|
|
key={`${e.source}-${e.id}`}
|
|
className={cn(
|
|
"text-[10px] leading-tight px-1.5 py-0.5 rounded truncate text-white flex items-center gap-1",
|
|
meta.bg,
|
|
)}
|
|
title={`${e.title}${e.owner_name ? ` — ${e.owner_name}` : ""}`}
|
|
>
|
|
{initials && (
|
|
<span className="shrink-0 text-[9px] font-semibold bg-white/25 rounded px-1 leading-tight">
|
|
{initials}
|
|
</span>
|
|
)}
|
|
<span className="truncate">
|
|
{!e.all_day && formatInEST(e.start, "h:mma ")}
|
|
{e.title}
|
|
</span>
|
|
</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);
|
|
const initials = ownerInitials(e.owner_name);
|
|
return (
|
|
<div
|
|
key={`${e.source}-${e.id}`}
|
|
className={cn(
|
|
"text-[11px] px-1.5 py-1 rounded truncate text-white flex items-center gap-1",
|
|
meta.bg,
|
|
)}
|
|
title={`${e.title}${e.owner_name ? ` — ${e.owner_name}` : ""}`}
|
|
>
|
|
{initials && (
|
|
<span className="shrink-0 text-[9px] font-semibold bg-white/25 rounded px-1 leading-tight">
|
|
{initials}
|
|
</span>
|
|
)}
|
|
<span className="truncate">
|
|
{!e.all_day && formatInEST(e.start, "h:mma ")}
|
|
{e.title}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ListView({
|
|
events,
|
|
onEdit,
|
|
staff,
|
|
onBulkUpdated,
|
|
}: {
|
|
events: Evt[];
|
|
onEdit?: (e: Evt) => void;
|
|
staff: StaffRow[];
|
|
onBulkUpdated: () => void | Promise<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);
|
|
});
|
|
|
|
// Bulk-edit state. Only rows with source === "event" can be bulk updated
|
|
// because they're the only ones backed by the editable `events` table.
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [bulkType, setBulkType] = useState<string>("");
|
|
const [bulkAssignee, setBulkAssignee] = useState<string>("");
|
|
const [applying, setApplying] = useState(false);
|
|
|
|
const editableUpcoming = upcoming.filter((e) => e.source === "event");
|
|
const allEditableSelected =
|
|
editableUpcoming.length > 0 && editableUpcoming.every((e) => selected.has(e.id));
|
|
|
|
const toggleOne = (id: string) => {
|
|
setSelected((s) => {
|
|
const n = new Set(s);
|
|
if (n.has(id)) n.delete(id);
|
|
else n.add(id);
|
|
return n;
|
|
});
|
|
};
|
|
const toggleAll = () => {
|
|
if (allEditableSelected) setSelected(new Set());
|
|
else setSelected(new Set(editableUpcoming.map((e) => e.id)));
|
|
};
|
|
|
|
const applyBulk = async () => {
|
|
if (selected.size === 0) {
|
|
toast.error("Select at least one event");
|
|
return;
|
|
}
|
|
if (!bulkType && !bulkAssignee) {
|
|
toast.error("Choose a category or assignee to update");
|
|
return;
|
|
}
|
|
setApplying(true);
|
|
const patch: { event_type?: string; assigned_to?: string } = {};
|
|
if (bulkType) patch.event_type = bulkType;
|
|
if (bulkAssignee) patch.assigned_to = bulkAssignee;
|
|
const { error } = await supabase
|
|
.from("events")
|
|
.update(patch)
|
|
.in("id", Array.from(selected));
|
|
setApplying(false);
|
|
if (error) {
|
|
toast.error(error.message);
|
|
return;
|
|
}
|
|
toast.success(`Updated ${selected.size} event${selected.size === 1 ? "" : "s"}`);
|
|
setSelected(new Set());
|
|
setBulkType("");
|
|
setBulkAssignee("");
|
|
await onBulkUpdated();
|
|
};
|
|
|
|
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">
|
|
{/* Bulk action toolbar */}
|
|
<div className="flex flex-wrap items-center gap-2 border rounded-md p-2 bg-muted/30">
|
|
<label className="flex items-center gap-2 text-xs cursor-pointer pl-1">
|
|
<Checkbox
|
|
checked={allEditableSelected}
|
|
onCheckedChange={toggleAll}
|
|
disabled={editableUpcoming.length === 0}
|
|
/>
|
|
<span className="text-muted-foreground">
|
|
{selected.size > 0 ? `${selected.size} selected` : "Select all"}
|
|
</span>
|
|
</label>
|
|
<div className="h-5 w-px bg-border mx-1" />
|
|
<Select value={bulkType} onValueChange={setBulkType}>
|
|
<SelectTrigger className="h-8 w-[170px] text-xs">
|
|
<SelectValue placeholder="Set category…" />
|
|
</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>
|
|
<Select value={bulkAssignee} onValueChange={setBulkAssignee}>
|
|
<SelectTrigger className="h-8 w-[180px] text-xs">
|
|
<SelectValue placeholder="Assign to…" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{staff.map((s) => (
|
|
<SelectItem key={s.id} value={s.id}>
|
|
{s.full_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
size="sm"
|
|
onClick={applyBulk}
|
|
disabled={applying || selected.size === 0 || (!bulkType && !bulkAssignee)}
|
|
>
|
|
{applying ? "Applying…" : "Apply"}
|
|
</Button>
|
|
{selected.size > 0 && (
|
|
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{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) => (
|
|
<li key={`${e.source}-${e.id}`} className="flex items-start gap-2">
|
|
{e.source === "event" ? (
|
|
<Checkbox
|
|
className="mt-3 ml-1"
|
|
checked={selected.has(e.id)}
|
|
onCheckedChange={() => toggleOne(e.id)}
|
|
/>
|
|
) : (
|
|
<span className="w-4 ml-1" />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<EventRow evt={e} onEdit={onEdit} />
|
|
</div>
|
|
</li>
|
|
))}
|
|
</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>;
|
|
}
|