diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 6f63b91..f619313 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -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"; @@ -14,6 +14,7 @@ interface NavItem { const NAV: NavItem[] = [ { to: "/", label: "Dashboard", icon: LayoutDashboard }, + { to: "/calendar", label: "Calendar", icon: CalendarIcon }, { to: "/clients", label: "Clients", icon: Users }, { to: "/cases", label: "Cases", icon: Briefcase }, { to: "/invoices", label: "Invoices", icon: Receipt }, diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 9025e4a..b8b1b2f 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as ContactsIndexRouteImport } from './routes/contacts.index' import { Route as CollectionsIndexRouteImport } from './routes/collections.index' import { Route as ClientsIndexRouteImport } from './routes/clients.index' import { Route as CasesIndexRouteImport } from './routes/cases.index' +import { Route as CalendarIndexRouteImport } from './routes/calendar.index' import { Route as SettingsWorkflowsRouteImport } from './routes/settings.workflows' import { Route as SettingsWorkflowRouteImport } from './routes/settings.workflow' import { Route as SettingsProfileRouteImport } from './routes/settings.profile' @@ -115,6 +116,11 @@ const CasesIndexRoute = CasesIndexRouteImport.update({ path: '/cases/', getParentRoute: () => rootRouteImport, } as any) +const CalendarIndexRoute = CalendarIndexRouteImport.update({ + id: '/calendar/', + path: '/calendar/', + getParentRoute: () => rootRouteImport, +} as any) const SettingsWorkflowsRoute = SettingsWorkflowsRouteImport.update({ id: '/workflows', path: '/workflows', @@ -208,6 +214,7 @@ export interface FileRoutesByFullPath { '/settings/profile': typeof SettingsProfileRoute '/settings/workflow': typeof SettingsWorkflowRoute '/settings/workflows': typeof SettingsWorkflowsRoute + '/calendar/': typeof CalendarIndexRoute '/cases/': typeof CasesIndexRoute '/clients/': typeof ClientsIndexRoute '/collections/': typeof CollectionsIndexRoute @@ -239,6 +246,7 @@ export interface FileRoutesByTo { '/settings/profile': typeof SettingsProfileRoute '/settings/workflow': typeof SettingsWorkflowRoute '/settings/workflows': typeof SettingsWorkflowsRoute + '/calendar': typeof CalendarIndexRoute '/cases': typeof CasesIndexRoute '/clients': typeof ClientsIndexRoute '/collections': typeof CollectionsIndexRoute @@ -272,6 +280,7 @@ export interface FileRoutesById { '/settings/profile': typeof SettingsProfileRoute '/settings/workflow': typeof SettingsWorkflowRoute '/settings/workflows': typeof SettingsWorkflowsRoute + '/calendar/': typeof CalendarIndexRoute '/cases/': typeof CasesIndexRoute '/clients/': typeof ClientsIndexRoute '/collections/': typeof CollectionsIndexRoute @@ -306,6 +315,7 @@ export interface FileRouteTypes { | '/settings/profile' | '/settings/workflow' | '/settings/workflows' + | '/calendar/' | '/cases/' | '/clients/' | '/collections/' @@ -337,6 +347,7 @@ export interface FileRouteTypes { | '/settings/profile' | '/settings/workflow' | '/settings/workflows' + | '/calendar' | '/cases' | '/clients' | '/collections' @@ -369,6 +380,7 @@ export interface FileRouteTypes { | '/settings/profile' | '/settings/workflow' | '/settings/workflows' + | '/calendar/' | '/cases/' | '/clients/' | '/collections/' @@ -398,6 +410,7 @@ export interface RootRouteChildren { CollectionsCollectionIdRoute: typeof CollectionsCollectionIdRoute ContactsContactIdRoute: typeof ContactsContactIdRoute InvoicesInvoiceIdRoute: typeof InvoicesInvoiceIdRoute + CalendarIndexRoute: typeof CalendarIndexRoute CasesIndexRoute: typeof CasesIndexRoute ClientsIndexRoute: typeof ClientsIndexRoute CollectionsIndexRoute: typeof CollectionsIndexRoute @@ -521,6 +534,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CasesIndexRouteImport parentRoute: typeof rootRouteImport } + '/calendar/': { + id: '/calendar/' + path: '/calendar' + fullPath: '/calendar/' + preLoaderRoute: typeof CalendarIndexRouteImport + parentRoute: typeof rootRouteImport + } '/settings/workflows': { id: '/settings/workflows' path: '/workflows' @@ -661,6 +681,7 @@ const rootRouteChildren: RootRouteChildren = { CollectionsCollectionIdRoute: CollectionsCollectionIdRoute, ContactsContactIdRoute: ContactsContactIdRoute, InvoicesInvoiceIdRoute: InvoicesInvoiceIdRoute, + CalendarIndexRoute: CalendarIndexRoute, CasesIndexRoute: CasesIndexRoute, ClientsIndexRoute: ClientsIndexRoute, CollectionsIndexRoute: CollectionsIndexRoute, diff --git a/src/routes/calendar.index.tsx b/src/routes/calendar.index.tsx new file mode 100644 index 0000000..19d195f --- /dev/null +++ b/src/routes/calendar.index.tsx @@ -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(new Date()); + const [events, setEvents] = useState([]); + const [cases, setCases] = useState([]); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [saving, setSaving] = useState(false); + + // form + const [fTitle, setFTitle] = useState(""); + const [fDate, setFDate] = useState(undefined); + const [fCase, setFCase] = useState("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(); + 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 ( + + + + + + + + + Schedule event + +
+
+ + setFTitle(e.target.value)} + placeholder="Hearing, deadline, meeting…" + /> +
+
+ +
+ +
+ {fDate && ( +

+ {format(fDate, "PPP")} +

+ )} +
+
+ + +
+
+ +