Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 03:16:39 +00:00
co-authored by renee-png
parent a741c340cb
commit 54617504b4
2 changed files with 334 additions and 1 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Briefcase, Users, FileText, Receipt, ShieldCheck, LogOut, Scale, LayoutDashboard } from "lucide-react";
import { Briefcase, Users, FileText, Receipt, ShieldCheck, LogOut, Scale, LayoutDashboard, Calendar as CalendarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
+333
View File
@@ -0,0 +1,333 @@
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>;
}