Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
919 lines
30 KiB
TypeScript
919 lines
30 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";
|
|
|
|
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 [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 [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 [fLocation, setFLocation] = useState("");
|
|
const [fNotes, setFNotes] = useState("");
|
|
const [fAssignee, setFAssignee] = useState<string>("self");
|
|
|
|
const reload = async () => {
|
|
setLoading(true);
|
|
const [eventsRes, tasksRes, casesRes, staffRes] = await Promise.all([
|
|
supabase
|
|
.from("events")
|
|
.select("id,title,start_at,end_at,all_day,event_type,case_id,location,description,created_by"),
|
|
supabase
|
|
.from("tasks")
|
|
.select("id,title,due_date,case_id,status,created_by")
|
|
.not("due_date", "is", null)
|
|
.eq("status", "incomplete"),
|
|
supabase
|
|
.from("cases")
|
|
.select("id,title,case_number,next_hearing_date,next_hearing_notes")
|
|
.order("title"),
|
|
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>();
|
|
(casesRes.data ?? []).forEach((c) =>
|
|
caseMap.set(c.id, { id: c.id, title: c.title, case_number: c.case_number }),
|
|
);
|
|
|
|
const evts: Evt[] = [];
|
|
|
|
for (const e of eventsRes.data ?? []) {
|
|
const owner = e.created_by ? profileMap.get(e.created_by) : null;
|
|
const c = e.case_id ? caseMap.get(e.case_id) : 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,
|
|
owner_id: e.created_by,
|
|
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;
|
|
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,
|
|
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;
|
|
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}`,
|
|
owner_id: null,
|
|
owner_name: null,
|
|
location: null,
|
|
description: c.next_hearing_notes,
|
|
});
|
|
}
|
|
|
|
setEvents(evts);
|
|
setCases(Array.from(caseMap.values()));
|
|
setStaff(Array.from(profileMap.values()));
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
reload();
|
|
}, []);
|
|
|
|
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) {
|
|
const key = e.start.slice(0, 10);
|
|
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) => {
|
|
setFTitle("");
|
|
setFType("client_meeting");
|
|
setFStart(presetDate ?? selected);
|
|
setFStartTime("09:00");
|
|
setFEndTime("10:00");
|
|
setFAllDay(false);
|
|
setFCase("none");
|
|
setFLocation("");
|
|
setFNotes("");
|
|
setFAssignee("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");
|
|
const startISO = fAllDay ? `${dateStr}T00:00:00` : `${dateStr}T${fStartTime}:00`;
|
|
const endISO = fAllDay ? null : `${dateStr}T${fEndTime}:00`;
|
|
const ownerId = fAssignee === "self" ? user.id : fAssignee;
|
|
|
|
const { error } = await supabase.from("events").insert({
|
|
title: fTitle.trim(),
|
|
event_type: fType,
|
|
start_at: startISO,
|
|
end_at: endISO,
|
|
all_day: fAllDay,
|
|
case_id: fCase === "none" ? null : fCase,
|
|
location: fLocation.trim() || null,
|
|
description: fNotes.trim() || null,
|
|
created_by: ownerId,
|
|
});
|
|
setSaving(false);
|
|
if (error) {
|
|
toast.error(error.message);
|
|
return;
|
|
}
|
|
toast.success("Event scheduled");
|
|
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} />
|
|
) : 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} />
|
|
))}
|
|
</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 />
|
|
))}
|
|
</ul>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add event dialog */}
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>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</Label>
|
|
<Input
|
|
type="time"
|
|
value={fStartTime}
|
|
onChange={(e) => setFStartTime(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>End time</Label>
|
|
<Input
|
|
type="time"
|
|
value={fEndTime}
|
|
onChange={(e) => setFEndTime(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<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>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>
|
|
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleCreate} disabled={saving}>
|
|
{saving ? "Saving…" : "Save event"}
|
|
</Button>
|
|
</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 && format(parseISO(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 && format(parseISO(e.start), "h:mma ")}
|
|
{e.title}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ListView({ events }: { events: Evt[] }) {
|
|
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} />
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EventRow({ evt, showDate }: { evt: Evt; showDate?: boolean }) {
|
|
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>{format(parseISO(evt.start), "EEE, MMM d")}</span>}
|
|
{!evt.all_day && <span>{format(parseISO(evt.start), "h:mm a")}</span>}
|
|
{evt.all_day && <span>All day</span>}
|
|
{evt.case_label && <span className="truncate">· {evt.case_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>
|
|
);
|
|
if (evt.case_id) {
|
|
return (
|
|
<li>
|
|
<Link to="/cases/$caseId" params={{ caseId: evt.case_id }}>
|
|
{content}
|
|
</Link>
|
|
</li>
|
|
);
|
|
}
|
|
return <li>{content}</li>;
|
|
}
|