Added Calendar sidebar & route
X-Lovable-Edit-ID: edt-51129d12-40f2-4b29-8470-ca6eea904023 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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-destructive" : "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>;
|
||||
}
|
||||
Reference in New Issue
Block a user