"use client" import { useEffect, useRef, useState, type MouseEvent, type PointerEvent as ReactPointerEvent, } from "react" import { addDays, format, isSameDay, isSameMonth, startOfDay } from "date-fns" import { fr } from "date-fns/locale" import { AgendaEventChip } from "@/components/agenda/agenda-event-chip" import type { AnchorRect } from "@/components/agenda/agenda-floating-card" import { bindAgendaEventDragSession } from "@/lib/agenda/agenda-event-drag-session" import { viewDays, type WeekStartsOn } from "@/lib/agenda/agenda-date" import type { AgendaWeekStart } from "@/lib/agenda/agenda-settings-types" import { eventsOnDay, isMultiDay } from "@/lib/agenda/agenda-events" import { isPendingEvent } from "@/lib/agenda/agenda-pending-event" import type { AgendaEvent } from "@/lib/agenda/agenda-types" import { cn } from "@/lib/utils" const MAX_CHIPS = 4 function anchorFromEvent(e: MouseEvent): AnchorRect { const rect = e.currentTarget.getBoundingClientRect() return { left: rect.left, top: rect.top, width: rect.width, height: rect.height } } function dayIndex(days: Date[], day: Date): number { return days.findIndex((d) => isSameDay(d, day)) } interface MonthDragState { startIndex: number endIndex: number moved: boolean } interface MonthEventMoveState { event: AgendaEvent originIndex: number currentIndex: number positionChanged: boolean } export function AgendaViewMonth({ date, weekStart = "auto", weekStartsOn, events, pendingEvent, onCreateRange, onEventClick, onEventMove, onOpenDay, }: { date: Date weekStart?: AgendaWeekStart weekStartsOn?: WeekStartsOn events: AgendaEvent[] pendingEvent?: AgendaEvent | null onCreateRange: ( start: Date, end: Date, allDay: boolean, anchor: AnchorRect, viaDrag: boolean, ) => void onEventClick: (event: AgendaEvent, anchor: AnchorRect) => void onEventMove?: (event: AgendaEvent, targetStart: Date) => void onOpenDay: (day: Date) => void }) { const days = viewDays("month", date, weekStart, weekStartsOn) const weeks: Date[][] = [] for (let i = 0; i < days.length; i += 7) weeks.push(days.slice(i, i + 7)) const today = new Date() const cellRefs = useRef<(HTMLDivElement | null)[]>([]) const dragRef = useRef(null) const eventMoveRef = useRef(null) const dragSessionCleanupRef = useRef<(() => void) | null>(null) const suppressClickRef = useRef(false) const [drag, setDrag] = useState(null) const [eventMove, setEventMove] = useState(null) const merged = pendingEvent ? [...events, pendingEvent] : events useEffect( () => () => { dragSessionCleanupRef.current?.() dragSessionCleanupRef.current = null }, [], ) const indexFromElement = (el: HTMLElement | null): number | null => { if (!el) return null const idx = cellRefs.current.findIndex((cell) => cell && cell.contains(el)) return idx >= 0 ? idx : null } const indexFromClient = (clientX: number, clientY: number): number | null => { const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null const cell = el?.closest("[data-agenda-month-cell]") as HTMLElement | null return indexFromElement(cell) } const finishDrag = (anchorEl: HTMLElement | null) => { const d = dragRef.current dragRef.current = null setDrag(null) if (!d) return const minIdx = Math.min(d.startIndex, d.endIndex) const maxIdx = Math.max(d.startIndex, d.endIndex) const start = startOfDay(days[minIdx]) const end = addDays(startOfDay(days[maxIdx]), 1) const anchor = anchorEl ? anchorFromEvent({ currentTarget: anchorEl } as MouseEvent) : { left: 0, top: 0, width: 0, height: 0 } if (d.moved) { onCreateRange(start, end, true, anchor, true) } else { onCreateRange(start, end, true, anchor, false) } } const handlePointerDown = (dayIndex: number) => (e: ReactPointerEvent) => { if (e.button !== 0 || eventMoveRef.current) return if ((e.target as HTMLElement).closest("[data-agenda-event]")) return const next: MonthDragState = { startIndex: dayIndex, endIndex: dayIndex, moved: false } dragRef.current = next setDrag(next) e.currentTarget.setPointerCapture(e.pointerId) } const handlePointerMove = (e: ReactPointerEvent) => { const d = dragRef.current if (!d) return const idx = indexFromClient(e.clientX, e.clientY) if (idx === null || idx === d.endIndex) return const next: MonthDragState = { ...d, endIndex: idx, moved: true } dragRef.current = next setDrag(next) } const handlePointerUp = (dayIndex: number) => (e: ReactPointerEvent) => { finishDrag(cellRefs.current[dayIndex]) } const handlePointerCancel = () => { dragRef.current = null setDrag(null) } const finishEventMove = (didDrag: boolean) => { const move = eventMoveRef.current eventMoveRef.current = null setEventMove(null) if (!move || !onEventMove) return const positionChanged = move.currentIndex !== move.originIndex if (didDrag) { suppressClickRef.current = true } if (!didDrag || !positionChanged) return const delta = move.currentIndex - move.originIndex const targetStart = addDays(move.event.start, delta) onEventMove(move.event, targetStart) } const updateEventMove = (clientX: number, clientY: number) => { const move = eventMoveRef.current if (!move) return const idx = indexFromClient(clientX, clientY) if (idx === null) return const positionChanged = idx !== move.originIndex const next: MonthEventMoveState = { ...move, currentIndex: idx, positionChanged, } eventMoveRef.current = next setEventMove(next) } const shouldSuppressEventClick = () => { if (!suppressClickRef.current) return false suppressClickRef.current = false return true } const startEventMove = (event: AgendaEvent, dayIdx: number) => (e: ReactPointerEvent) => { if (e.button !== 0 || isPendingEvent(event) || !onEventMove) return e.stopPropagation() e.preventDefault() const originIndex = dayIndex(days, event.start) const resolvedOrigin = originIndex >= 0 ? originIndex : dayIdx const next: MonthEventMoveState = { event, originIndex: resolvedOrigin, currentIndex: resolvedOrigin, positionChanged: false, } eventMoveRef.current = next setEventMove(next) dragSessionCleanupRef.current?.() dragSessionCleanupRef.current = bindAgendaEventDragSession({ pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, onMove: updateEventMove, onFinish: (clientX, clientY, didDrag) => { dragSessionCleanupRef.current = null if (didDrag) { updateEventMove(clientX, clientY) } finishEventMove(didDrag) }, }) } const dragRange = drag && drag.moved ? { min: Math.min(drag.startIndex, drag.endIndex), max: Math.max(drag.startIndex, drag.endIndex), } : null return (
{days.slice(0, 7).map((d) => (
{format(d, "EEE", { locale: fr }).replace(".", "")}
))}
{weeks.map((week) => week.map((day) => { const idx = dayIndex(days, day) const dayEvents = eventsOnDay(merged, day).sort((a, b) => { const aBanner = a.allDay || isMultiDay(a) const bBanner = b.allDay || isMultiDay(b) if (isPendingEvent(a)) return 1 if (isPendingEvent(b)) return -1 if (aBanner !== bBanner) return aBanner ? -1 : 1 return a.start.getTime() - b.start.getTime() }) const visible = dayEvents.slice(0, MAX_CHIPS) const hidden = dayEvents.length - visible.length const isToday = isSameDay(day, today) const inMonth = isSameMonth(day, date) const inDragRange = dragRange && idx >= dragRange.min && idx <= dragRange.max const inEventMoveTarget = eventMove?.positionChanged && eventMove.currentIndex === idx return (
{ cellRefs.current[idx] = el }} role="gridcell" data-agenda-month-cell className={cn( "relative flex min-h-0 cursor-pointer touch-none flex-col gap-0.5 overflow-hidden border-r border-b border-border/40 px-1 pb-1", !inMonth && "bg-muted/30", inDragRange && "bg-primary/10", inEventMoveTarget && "bg-primary/10 ring-1 ring-inset ring-primary/40", )} onPointerDown={handlePointerDown(idx)} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp(idx)} onPointerCancel={handlePointerCancel} >
{visible.map((event) => { const isDragging = eventMove?.event.key === event.key && eventMove.positionChanged return (
{ if (shouldSuppressEventClick()) { e.preventDefault() return } e.stopPropagation() if (!isPendingEvent(event)) { onEventClick(event, anchorFromEvent(e)) } }} />
) })} {hidden > 0 && ( )}
) }), )}
) }