Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
import { createFileRoute } 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 {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Plus, Calendar as CalIcon, Briefcase, CheckSquare } from "lucide-react";
|
|
import { format, isSameDay, parseISO, startOfDay } from "date-fns";
|
|
import { toast } from "sonner";
|
|
import { cn } from "@/lib/utils";
|
|
import { Link } from "@tanstack/react-router";
|
|
|
|
export const Route = createFileRoute("/calendar/")({
|
|
component: CalendarPage,
|
|
});
|
|
|
|
interface TaskEvt {
|
|
id: string;
|
|
title: string;
|
|
due_date: string;
|
|
case_id: string | null;
|
|
kind: "task";
|
|
}
|
|
interface HearingEvt {
|
|
id: string; // case id
|
|
title: string;
|
|
due_date: string;
|
|
case_id: string;
|
|
kind: "hearing";
|
|
notes: string | null;
|
|
}
|
|
type Evt = TaskEvt | HearingEvt;
|
|
|
|
interface CaseRow {
|
|
id: string;
|
|
title: string;
|
|
case_number: string;
|
|
}
|
|
|
|
function CalendarPage() {
|
|
const { user } = useAuth();
|
|
const [selected, setSelected] = useState<Date>(new Date());
|
|
const [events, setEvents] = useState<Evt[]>([]);
|
|
const [cases, setCases] = useState<CaseRow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// form
|
|
const [fTitle, setFTitle] = useState("");
|
|
const [fDate, setFDate] = useState<Date | undefined>(undefined);
|
|
const [fCase, setFCase] = useState<string>("none");
|
|
const [fNotes, setFNotes] = useState("");
|
|
|
|
const reload = async () => {
|
|
setLoading(true);
|
|
const [tasksRes, casesRes] = await Promise.all([
|
|
supabase
|
|
.from("tasks")
|
|
.select("id,title,due_date,case_id,status")
|
|
.not("due_date", "is", null)
|
|
.eq("status", "incomplete"),
|
|
supabase
|
|
.from("cases")
|
|
.select("id,title,case_number,next_hearing_date,next_hearing_notes")
|
|
.order("title"),
|
|
]);
|
|
|
|
const tEvts: Evt[] = (tasksRes.data ?? []).map((t) => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
due_date: t.due_date as string,
|
|
case_id: t.case_id,
|
|
kind: "task" as const,
|
|
}));
|
|
const hEvts: Evt[] = (casesRes.data ?? [])
|
|
.filter((c) => c.next_hearing_date)
|
|
.map((c) => ({
|
|
id: c.id,
|
|
title: `Hearing: ${c.title}`,
|
|
due_date: c.next_hearing_date as string,
|
|
case_id: c.id,
|
|
kind: "hearing" as const,
|
|
notes: c.next_hearing_notes,
|
|
}));
|
|
|
|
setEvents([...tEvts, ...hEvts]);
|
|
setCases((casesRes.data ?? []).map((c) => ({ id: c.id, title: c.title, case_number: c.case_number })));
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
reload();
|
|
}, []);
|
|
|
|
const eventsByDay = useMemo(() => {
|
|
const m = new Map<string, Evt[]>();
|
|
for (const e of events) {
|
|
const key = e.due_date.slice(0, 10);
|
|
const arr = m.get(key) ?? [];
|
|
arr.push(e);
|
|
m.set(key, arr);
|
|
}
|
|
return m;
|
|
}, [events]);
|
|
|
|
const dayHasEvent = (date: Date) => eventsByDay.has(format(date, "yyyy-MM-dd"));
|
|
|
|
const dayEvents = useMemo(() => {
|
|
return events.filter((e) => isSameDay(parseISO(e.due_date), selected));
|
|
}, [events, selected]);
|
|
|
|
const upcoming = useMemo(() => {
|
|
const today = startOfDay(new Date());
|
|
return [...events]
|
|
.filter((e) => parseISO(e.due_date) >= today)
|
|
.sort((a, b) => a.due_date.localeCompare(b.due_date))
|
|
.slice(0, 8);
|
|
}, [events]);
|
|
|
|
const handleCreate = async () => {
|
|
if (!user || !fTitle.trim() || !fDate) {
|
|
toast.error("Title and date are required");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
const { error } = await supabase.from("tasks").insert({
|
|
title: fTitle.trim(),
|
|
due_date: format(fDate, "yyyy-MM-dd"),
|
|
case_id: fCase === "none" ? null : fCase,
|
|
description: fNotes.trim() || null,
|
|
created_by: user.id,
|
|
status: "incomplete",
|
|
priority: "normal",
|
|
});
|
|
setSaving(false);
|
|
if (error) {
|
|
toast.error(error.message);
|
|
return;
|
|
}
|
|
toast.success("Event scheduled");
|
|
setDialogOpen(false);
|
|
setFTitle("");
|
|
setFDate(undefined);
|
|
setFCase("none");
|
|
setFNotes("");
|
|
reload();
|
|
};
|
|
|
|
return (
|
|
<ProtectedLayout>
|
|
<PageContainer>
|
|
<PageHeader
|
|
title="Calendar"
|
|
description="Schedule and view tasks, hearings, and key dates."
|
|
actions={
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Schedule event
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Schedule event</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label>Title</Label>
|
|
<Input
|
|
value={fTitle}
|
|
onChange={(e) => setFTitle(e.target.value)}
|
|
placeholder="Hearing, deadline, meeting…"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Date</Label>
|
|
<div className="border rounded-md p-2 mt-1">
|
|
<Calendar
|
|
mode="single"
|
|
selected={fDate}
|
|
onSelect={setFDate}
|
|
className={cn("pointer-events-auto")}
|
|
/>
|
|
</div>
|
|
{fDate && (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{format(fDate, "PPP")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<Label>Related case (optional)</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>Notes</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…" : "Schedule"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
}
|
|
/>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-6">
|
|
<Card className="p-3 w-fit">
|
|
<Calendar
|
|
mode="single"
|
|
selected={selected}
|
|
onSelect={(d) => d && setSelected(d)}
|
|
modifiers={{ hasEvent: dayHasEvent }}
|
|
modifiersClassNames={{
|
|
hasEvent: "relative font-semibold text-primary",
|
|
}}
|
|
className={cn("pointer-events-auto")}
|
|
/>
|
|
</Card>
|
|
|
|
<div className="space-y-6">
|
|
<Card className="p-5">
|
|
<h3 className="font-serif text-lg mb-3">
|
|
{format(selected, "EEEE, MMMM d, yyyy")}
|
|
</h3>
|
|
{loading ? (
|
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
|
) : 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.kind}-${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.kind}-${e.id}`} evt={e} showDate />
|
|
))}
|
|
</ul>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</PageContainer>
|
|
</ProtectedLayout>
|
|
);
|
|
}
|
|
|
|
function EventRow({ evt, showDate }: { evt: Evt; showDate?: boolean }) {
|
|
const Icon = evt.kind === "hearing" ? Briefcase : CheckSquare;
|
|
const content = (
|
|
<div className="flex items-start gap-3 px-3 py-2 rounded-md border bg-card hover:bg-accent/40 transition-colors">
|
|
<Icon
|
|
className={cn(
|
|
"h-4 w-4 mt-0.5 shrink-0",
|
|
evt.kind === "hearing" ? "text-amber-600" : "text-primary",
|
|
)}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium truncate">{evt.title}</div>
|
|
{showDate && (
|
|
<div className="text-xs text-muted-foreground">
|
|
{format(parseISO(evt.due_date), "EEE, MMM d")}
|
|
</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>;
|
|
}
|