ultisuite-client/components/agenda/agenda-page.tsx
R3D347HR4Y ad1370ea7e
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: enhance configuration and add new demo layouts
- Introduced turbopack alias for canvas in next.config.mjs.
- Updated package.json scripts for development and branding tasks.
- Added new dependencies for Tiptap extensions.
- Implemented new demo layouts for agenda, contacts, drive, and mail applications.
- Enhanced globals.css for improved theming and splash screen animations.
- Added OAuth callback handling for drive mounts.
- Updated layout components to integrate new demo shells and improve structure.
2026-06-12 19:10:24 +02:00

397 lines
13 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useParams, useRouter, useSearchParams } from "next/navigation"
import { addDays, addHours, addMinutes, addMonths, startOfHour } from "date-fns"
import { AgendaEventDialog, type AgendaEventDialogState } from "@/components/agenda/agenda-event-dialog"
import { AgendaEventPopover, type AgendaEventPopoverState } from "@/components/agenda/agenda-event-popover"
import type { AnchorRect } from "@/components/agenda/agenda-floating-card"
import { AgendaHeader } from "@/components/agenda/agenda-header"
import { AgendaQuickCreate, type AgendaQuickCreateState } from "@/components/agenda/agenda-quick-create"
import { AgendaSidebar } from "@/components/agenda/agenda-sidebar"
import { AgendaViewMonth } from "@/components/agenda/agenda-view-month"
import { AgendaViewWeek } from "@/components/agenda/agenda-view-week"
import { Skeleton } from "@/components/ui/skeleton"
import { useAgendaEvents } from "@/lib/api/hooks/use-calendar-queries"
import { isExternalCalendarId } from "@/lib/agenda/agenda-calendar-visibility"
import { parseICSDate, viewDays, viewRange } from "@/lib/agenda/agenda-date"
import { useExternalAgendaEvents } from "@/lib/agenda/use-external-agenda-events"
import { useResolvedWeekStartsOn } from "@/lib/agenda/use-resolved-week-start"
import { draftToPendingEvent } from "@/lib/agenda/agenda-pending-event"
import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
import { useAgendaEventMove } from "@/lib/agenda/use-agenda-event-move"
import { useVisibleAgendaCalendars } from "@/lib/agenda/use-visible-agenda-calendars"
import type { AgendaEvent, AgendaEventDraft } from "@/lib/agenda/agenda-types"
import { useAgendaRouteRoot } from "@/lib/agenda/agenda-route-context"
import {
buildAgendaPath,
parseAgendaSegments,
type AgendaView,
} from "@/lib/agenda/agenda-url"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { useIsMobile } from "@/hooks/use-mobile"
import { AGENDA_CALENDAR_CARD_CLASS, AGENDA_MAIN_INSET_X } from "@/lib/agenda/agenda-chrome-classes"
import { cn } from "@/lib/utils"
export function AgendaPage() {
const router = useRouter()
const params = useParams()
const searchParams = useSearchParams()
const agendaRouteRoot = useAgendaRouteRoot()
const isMobile = useIsMobile()
const identity = useChromeIdentity()
const route = useMemo(
() => parseAgendaSegments(params.segments as string[] | undefined),
[params.segments],
)
const lastView = useAgendaSettingsStore((s) => s.lastView)
const setLastView = useAgendaSettingsStore((s) => s.setLastView)
const externalCalendars = useAgendaSettingsStore((s) => s.externalCalendars)
const weekStart = useAgendaSettingsStore((s) => s.weekStart)
const weekStartsOn = useResolvedWeekStartsOn(weekStart)
const defaultQuickDurationMinutes = useAgendaSettingsStore(
(s) => s.defaultQuickDurationMinutes,
)
const view: AgendaView = route.view ?? (isMobile ? "day" : lastView)
const date = route.date
useEffect(() => {
if (!route.view) {
router.replace(
buildAgendaPath(view, date, agendaRouteRoot) + window.location.search
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [route.view])
useEffect(() => {
if (route.view) setLastView(route.view)
}, [route.view, setLastView])
const navigate = useCallback(
(nextView: AgendaView, nextDate: Date) => {
router.push(buildAgendaPath(nextView, nextDate, agendaRouteRoot))
},
[router, agendaRouteRoot],
)
const step = useCallback(
(delta: 1 | -1) => {
const next =
view === "month"
? addMonths(date, delta)
: addDays(date, view === "week" ? delta * 7 : delta)
navigate(view, next)
},
[view, date, navigate],
)
const {
calendars,
visibleCalendars,
isLoading: calendarsLoading,
} = useVisibleAgendaCalendars()
const visibleApiCalendars = useMemo(
() => visibleCalendars.filter((calendar) => !isExternalCalendarId(calendar.id)),
[visibleCalendars],
)
const visibleExternalCalendars = useMemo(
() => externalCalendars.filter((calendar) =>
visibleCalendars.some((visible) => visible.id === calendar.id),
),
[externalCalendars, visibleCalendars],
)
const writableCalendars = useMemo(
() => calendars.filter((calendar) => !isExternalCalendarId(calendar.id)),
[calendars],
)
const writableVisibleCalendars = useMemo(
() => visibleCalendars.filter((calendar) => !isExternalCalendarId(calendar.id)),
[visibleCalendars],
)
const fetchRange = useMemo(
() => viewRange("month", date, weekStart, weekStartsOn),
[date, weekStart, weekStartsOn],
)
const { events: apiEvents } = useAgendaEvents(
visibleApiCalendars,
fetchRange.start,
fetchRange.end,
)
const { events: externalEvents } = useExternalAgendaEvents(
visibleExternalCalendars,
fetchRange.start,
fetchRange.end,
)
const events = useMemo(
() =>
[...apiEvents, ...externalEvents].sort(
(a, b) => a.start.getTime() - b.start.getTime(),
),
[apiEvents, externalEvents],
)
const [quickCreate, setQuickCreate] = useState<AgendaQuickCreateState | null>(null)
const [dialogState, setDialogState] = useState<AgendaEventDialogState | null>(null)
const [popover, setPopover] = useState<AgendaEventPopoverState | null>(null)
/** Brouillon affiché sur la grille tant que la modale est ouverte. */
const [pendingDraft, setPendingDraft] = useState<AgendaEventDraft | null>(null)
const defaultCalendarId =
writableVisibleCalendars[0]?.id ?? writableCalendars[0]?.id ?? ""
const userEmail = identity?.email
const clearPending = useCallback(() => setPendingDraft(null), [])
const closeOverlays = useCallback(() => {
setQuickCreate(null)
setPopover(null)
clearPending()
}, [clearPending])
const showPendingDraft = useCallback((draft: AgendaEventDraft) => {
setPendingDraft(draft)
}, [])
const openCreateDialog = useCallback(
(base?: Partial<AgendaEventDraft>, opts?: { keepPending?: boolean }) => {
setPopover(null)
setQuickCreate(null)
if (!opts?.keepPending) clearPending()
const start = base?.start ?? addHours(startOfHour(new Date()), 1)
const end = base?.end ?? addMinutes(start, defaultQuickDurationMinutes)
const draft: AgendaEventDraft = {
title: "",
start,
end,
allDay: false,
calendarId: defaultCalendarId,
...base,
}
showPendingDraft(draft)
setDialogState({ mode: "create", draft })
},
[clearPending, defaultCalendarId, defaultQuickDurationMinutes, showPendingDraft],
)
const openEditDialog = useCallback(
(event: AgendaEvent) => {
closeOverlays()
const masterStart = parseICSDate(event.master.start) ?? event.start
const masterEnd = parseICSDate(event.master.end) ?? event.end
setDialogState({
mode: "edit",
event,
draft: {
title: event.title === "(Sans titre)" ? "" : event.title,
start: masterStart,
end: masterEnd,
allDay: event.allDay,
calendarId: event.calendarId,
description: event.description,
location: event.location,
attendees: event.attendees,
rrule: event.rrule,
color: event.master.color,
},
})
},
[closeOverlays],
)
const handledNewParam = useRef(false)
useEffect(() => {
if (handledNewParam.current || !route.view) return
if (searchParams.get("new") !== "1") return
handledNewParam.current = true
const guest = searchParams.get("guest")?.trim()
const guestName = searchParams.get("guest_name")?.trim()
openCreateDialog({
title: searchParams.get("title")?.trim() ?? "",
attendees: guest
? [{ email: guest, name: guestName || guest, status: "NEEDS-ACTION" }]
: [],
})
router.replace(buildAgendaPath(view, date))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, route.view])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
if (
e.metaKey ||
e.ctrlKey ||
e.altKey ||
target.closest("input, textarea, select, [contenteditable], [role=dialog]")
)
return
switch (e.key.toLowerCase()) {
case "j":
case "d":
navigate("day", date)
break
case "s":
case "w":
navigate("week", date)
break
case "m":
navigate("month", date)
break
case "t":
navigate(view, new Date())
break
case "c":
openCreateDialog()
break
default:
return
}
e.preventDefault()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [navigate, view, date, openCreateDialog])
const handleEventClick = useCallback((event: AgendaEvent, anchor: AnchorRect) => {
setQuickCreate(null)
clearPending()
setPopover({ event, anchor })
}, [clearPending])
const handleCreateRange = useCallback(
(start: Date, end: Date, allDay: boolean, anchor: AnchorRect, viaDrag: boolean) => {
setPopover(null)
const draft: AgendaEventDraft = {
title: "",
start,
end,
allDay,
calendarId: defaultCalendarId,
}
showPendingDraft(draft)
if (viaDrag) {
openCreateDialog(draft, { keepPending: true })
} else {
setDialogState(null)
setQuickCreate({ start, end, allDay, anchor })
}
},
[defaultCalendarId, openCreateDialog, showPendingDraft],
)
const { moveEvent } = useAgendaEventMove()
const handleEventMove = useCallback(
(event: AgendaEvent, targetStart: Date) => {
void moveEvent(event, targetStart)
},
[moveEvent],
)
const pendingEvent = useMemo(() => {
if (!pendingDraft) return null
return draftToPendingEvent(pendingDraft, visibleCalendars.length > 0 ? visibleCalendars : calendars)
}, [pendingDraft, visibleCalendars, calendars])
const days = useMemo(
() => viewDays(view, date, weekStart, weekStartsOn),
[view, date, weekStart, weekStartsOn],
)
return (
<>
<AgendaHeader
view={view}
date={date}
weekStart={weekStart}
weekStartsOn={weekStartsOn}
onToday={() => navigate(view, new Date())}
onStep={step}
onViewChange={(v) => navigate(v, date)}
/>
<div className="flex min-h-0 flex-1">
<AgendaSidebar
selectedDate={date}
onSelectDate={(d) => navigate(view, d)}
onCreateEvent={() => openCreateDialog()}
/>
<main className={cn("flex min-h-0 min-w-0 flex-1 flex-col pb-1 max-sm:pb-0", AGENDA_MAIN_INSET_X)}>
<div className={AGENDA_CALENDAR_CARD_CLASS} data-agenda-calendar-card>
{calendarsLoading ? (
<div className="flex h-full flex-col gap-3 p-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="min-h-0 flex-1" />
</div>
) : view === "month" ? (
<AgendaViewMonth
date={date}
weekStart={weekStart}
weekStartsOn={weekStartsOn}
events={events}
pendingEvent={pendingEvent}
onCreateRange={handleCreateRange}
onEventClick={handleEventClick}
onEventMove={handleEventMove}
onOpenDay={(day) => navigate("day", day)}
/>
) : (
<AgendaViewWeek
days={days}
events={events}
pendingEvent={pendingEvent}
onCreateRange={handleCreateRange}
onEventClick={handleEventClick}
onEventMove={handleEventMove}
onOpenDay={(day) => navigate("day", day)}
/>
)}
</div>
</main>
</div>
<AgendaQuickCreate
state={quickCreate}
calendars={writableVisibleCalendars.length > 0 ? writableVisibleCalendars : writableCalendars}
defaultCalendarId={defaultCalendarId}
userEmail={userEmail}
onClose={() => {
setQuickCreate(null)
clearPending()
}}
onMoreOptions={(draft) => {
setQuickCreate(null)
showPendingDraft(draft)
setDialogState({ mode: "create", draft })
}}
/>
<AgendaEventPopover
state={popover}
calendars={calendars}
userEmail={userEmail}
onClose={() => setPopover(null)}
onEdit={openEditDialog}
/>
<AgendaEventDialog
state={dialogState}
onClose={() => {
setDialogState(null)
clearPending()
}}
calendars={writableCalendars}
userEmail={userEmail}
onDraftChange={showPendingDraft}
/>
</>
)
}