"use client" import { useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent as ReactPointerEvent, } from "react" import { addDays, format, isSameDay, 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 { formatEventTime, formatHourLabel, roundToStep } from "@/lib/agenda/agenda-date" import { readableTextColor } from "@/lib/agenda/agenda-colors" import { layoutDayEvents } from "@/lib/agenda/agenda-event-layout" import { bindAgendaEventDragSession } from "@/lib/agenda/agenda-event-drag-session" import { eventsOnDay, isMultiDay } from "@/lib/agenda/agenda-events" import { isPendingEvent } from "@/lib/agenda/agenda-pending-event" import { useEffectiveAgendaSettings } from "@/lib/agenda/use-effective-agenda-settings" import type { AgendaEvent } from "@/lib/agenda/agenda-types" import { cn } from "@/lib/utils" const HOUR_PX = 48 const GUTTER_PX = 56 const MIN_EVENT_PX = 22 /** Scroll sans barre visible — évite le décalage header / colonnes. */ const SCROLL_CLASS = "min-h-0 flex-1 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden" function anchorFromEvent(e: MouseEvent): AnchorRect { const rect = e.currentTarget.getBoundingClientRect() return { left: rect.left, top: rect.top, width: rect.width, height: rect.height } } interface DragState { startDayIndex: number endDayIndex: number anchorMin: number startMin: number endMin: number moved: boolean /** Glissé horizontalement sur plusieurs colonnes → journée entière. */ multiDay: boolean } interface EventMoveState { event: AgendaEvent durationMin: number originDayIndex: number originStartMin: number currentDayIndex: number currentStartMin: number positionChanged: boolean /** Offset pointeur vs haut événement — évite snap au clic/début drag. */ grabOffsetY: number } export function AgendaViewWeek({ days, events, pendingEvent, onCreateRange, onEventClick, onEventMove, onOpenDay, }: { days: Date[] 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 scrollRef = useRef(null) const columnRefs = 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 [now, setNow] = useState(() => new Date()) const settings = useEffectiveAgendaSettings() const { visibleHoursStart, visibleHoursEnd, dragSnapMinutes, defaultQuickDurationMinutes, timeFormat, } = settings const gridMinutes = Math.max(60, visibleHoursEnd - visibleHoursStart + 1) const gridHeightPx = (gridMinutes / 60) * HOUR_PX const hourMarks = useMemo(() => { const startHour = Math.floor(visibleHoursStart / 60) const endHour = Math.ceil(visibleHoursEnd / 60) const marks: number[] = [] for (let h = startHour + 1; h <= endHour; h++) marks.push(h) return marks }, [visibleHoursStart, visibleHoursEnd]) useEffect(() => { const id = window.setInterval(() => setNow(new Date()), 60_000) return () => window.clearInterval(id) }, []) useEffect( () => () => { dragSessionCleanupRef.current?.() dragSessionCleanupRef.current = null }, [], ) useEffect(() => { const el = scrollRef.current if (!el) return const nowMinutes = now.getHours() * 60 + now.getMinutes() const target = Math.max( 0, (Math.min(nowMinutes, visibleHoursEnd) - visibleHoursStart - 90) * (HOUR_PX / 60), ) el.scrollTop = target // eslint-disable-next-line react-hooks/exhaustive-deps }, [visibleHoursStart, visibleHoursEnd]) const dayIndexFromClientX = (clientX: number): number | null => { for (let i = 0; i < columnRefs.current.length; i++) { const el = columnRefs.current[i] if (!el) continue const rect = el.getBoundingClientRect() if (clientX >= rect.left && clientX <= rect.right) return i } return null } const perDay = useMemo(() => { const merged = pendingEvent ? [...events, pendingEvent] : events return days.map((day) => { const dayEvents = eventsOnDay(merged, day) const banners = dayEvents .filter((e) => e.allDay || isMultiDay(e)) .sort((a, b) => { if (isPendingEvent(a)) return 1 if (isPendingEvent(b)) return -1 return a.start.getTime() - b.start.getTime() }) const timed = dayEvents.filter((e) => !e.allDay && !isMultiDay(e)) return { day, banners, positioned: layoutDayEvents(timed, day) } }) }, [days, events, pendingEvent]) const hasBanners = perDay.some((d) => d.banners.length > 0) || (drag?.multiDay ?? false) const colTemplate = `${GUTTER_PX}px repeat(${days.length}, minmax(0, 1fr))` const minuteFromClientY = (clientY: number, dayIndex: number): number | null => { const el = columnRefs.current[dayIndex] if (!el) return null const rect = el.getBoundingClientRect() const relativeMinutes = ((clientY - rect.top) / HOUR_PX) * 60 const absolute = visibleHoursStart + relativeMinutes return Math.min( visibleHoursEnd, Math.max(visibleHoursStart, roundToStep(absolute, dragSnapMinutes)), ) } const finishDrag = () => { const d = dragRef.current dragRef.current = null setDrag(null) if (!d) return const minDay = Math.min(d.startDayIndex, d.endDayIndex) const maxDay = Math.max(d.startDayIndex, d.endDayIndex) const viaDrag = d.moved if (d.multiDay) { const start = startOfDay(days[minDay]) const end = addDays(startOfDay(days[maxDay]), 1) const el = columnRefs.current[minDay] const anchor: AnchorRect = el ? { left: el.getBoundingClientRect().left, top: el.getBoundingClientRect().top, width: (columnRefs.current[maxDay]?.getBoundingClientRect().right ?? el.getBoundingClientRect().right) - el.getBoundingClientRect().left, height: 24, } : { left: 0, top: 0, width: 0, height: 0 } onCreateRange(start, end, true, anchor, true) return } const day = days[d.startDayIndex] const el = columnRefs.current[d.startDayIndex] if (!el) return const startMin = d.startMin const endMin = viaDrag ? d.endMin : Math.min(visibleHoursEnd, d.startMin + defaultQuickDurationMinutes) const start = new Date(day) start.setHours(0, startMin, 0, 0) const end = new Date(day) end.setHours(0, Math.max(endMin, startMin + dragSnapMinutes), 0, 0) const colRect = el.getBoundingClientRect() onCreateRange(start, end, false, { left: colRect.left, top: colRect.top + ((startMin - visibleHoursStart) / 60) * HOUR_PX, width: colRect.width, height: Math.max(((endMin - startMin) / 60) * HOUR_PX, MIN_EVENT_PX), }, viaDrag) } const finishEventMove = (didDrag: boolean) => { const move = eventMoveRef.current eventMoveRef.current = null setEventMove(null) if (!move || !onEventMove) return const positionChanged = move.event.allDay ? move.currentDayIndex !== move.originDayIndex : move.currentDayIndex !== move.originDayIndex || move.currentStartMin !== move.originStartMin if (didDrag) { suppressClickRef.current = true } if (!didDrag || !positionChanged) return const day = days[move.currentDayIndex] if (!day) return if (move.event.allDay) { onEventMove(move.event, startOfDay(day)) return } const start = new Date(day) start.setHours(0, move.currentStartMin, 0, 0) onEventMove(move.event, start) } const updateEventMove = (clientX: number, clientY: number) => { const move = eventMoveRef.current if (!move) return const hoverDay = dayIndexFromClientX(clientX) if (hoverDay === null) return let currentStartMin = move.originStartMin if (!move.event.allDay) { const min = minuteFromClientY(clientY - move.grabOffsetY, hoverDay) if (min === null) return currentStartMin = min } const positionChanged = move.event.allDay ? hoverDay !== move.originDayIndex : hoverDay !== move.originDayIndex || currentStartMin !== move.originStartMin const next: EventMoveState = { ...move, currentDayIndex: hoverDay, currentStartMin, positionChanged, } eventMoveRef.current = next setEventMove(next) } const shouldSuppressEventClick = () => { if (!suppressClickRef.current) return false suppressClickRef.current = false return true } const startEventMove = (event: AgendaEvent, displayDayIndex: number) => (e: ReactPointerEvent) => { if (e.button !== 0 || isPendingEvent(event) || !onEventMove) return e.stopPropagation() e.preventDefault() const originDayIndex = days.findIndex((d) => isSameDay(d, event.start)) const resolvedOriginDay = originDayIndex >= 0 ? originDayIndex : displayDayIndex const originStartMin = event.allDay ? 0 : event.start.getHours() * 60 + event.start.getMinutes() const durationMin = Math.max( dragSnapMinutes, Math.round((event.end.getTime() - event.start.getTime()) / 60_000), ) const grabOffsetY = e.clientY - e.currentTarget.getBoundingClientRect().top const next: EventMoveState = { event, durationMin, originDayIndex: resolvedOriginDay, originStartMin, currentDayIndex: resolvedOriginDay, currentStartMin: originStartMin, positionChanged: false, grabOffsetY, } 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 handlePointerDown = (dayIndex: number) => (e: ReactPointerEvent) => { if (e.button !== 0 || eventMoveRef.current) return if ((e.target as HTMLElement).closest("[data-agenda-event]")) return const min = minuteFromClientY(e.clientY, dayIndex) if (min === null) return const next: DragState = { startDayIndex: dayIndex, endDayIndex: dayIndex, anchorMin: min, startMin: min, endMin: min + dragSnapMinutes, moved: false, multiDay: false, } dragRef.current = next setDrag(next) e.currentTarget.setPointerCapture(e.pointerId) } const handlePointerMove = (e: ReactPointerEvent) => { const d = dragRef.current if (!d) return const hoverDay = dayIndexFromClientX(e.clientX) if (hoverDay === null) return if (hoverDay !== d.startDayIndex) { const next: DragState = { ...d, endDayIndex: hoverDay, moved: true, multiDay: true, } dragRef.current = next setDrag(next) return } if (d.multiDay) return const min = minuteFromClientY(e.clientY, d.startDayIndex) if (min === null) return if (min === d.anchorMin && !d.moved) return const next: DragState = { ...d, endDayIndex: d.startDayIndex, moved: true, multiDay: false, startMin: Math.min(d.anchorMin, min), endMin: Math.max( d.anchorMin, min, Math.min(d.anchorMin, min) + dragSnapMinutes, ), } dragRef.current = next setDrag(next) } const handlePointerUp = () => { finishDrag() } const handlePointerCancel = () => { dragRef.current = null setDrag(null) } const dragMultiDayRange = drag?.multiDay && drag.moved ? { min: Math.min(drag.startDayIndex, drag.endDayIndex), max: Math.max(drag.startDayIndex, drag.endDayIndex), } : null return (
{/* En-tête : jours + rangée journée entière */}
{days.map((day) => { const isToday = isSameDay(day, now) return (
{format(day, "EEE", { locale: fr }).replace(".", "")}
) })}
{hasBanners && (
{perDay.map(({ day, banners }, dayIndex) => { const inDragRange = dragMultiDayRange && dayIndex >= dragMultiDayRange.min && dayIndex <= dragMultiDayRange.max const inEventMoveTarget = eventMove?.positionChanged && eventMove.event.allDay && eventMove.currentDayIndex === dayIndex return (
{inDragRange && (
dragMultiDayRange!.min && dayIndex < dragMultiDayRange!.max && "rounded-none", )} /> )} {inEventMoveTarget && (
)} {banners.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)) } }} />
) })}
) })}
)}
{/* Grille horaire */}
{hourMarks.map((h) => ( {formatHourLabel(h % 24, timeFormat)} ))}
{perDay.map(({ day, positioned }, dayIndex) => { const isToday = isSameDay(day, now) const nowMinutes = now.getHours() * 60 + now.getMinutes() const nowTop = (nowMinutes - visibleHoursStart) * (HOUR_PX / 60) const showNowLine = nowMinutes >= visibleHoursStart && nowMinutes <= visibleHoursEnd const showTimedDrag = drag && !drag.multiDay && drag.moved && drag.startDayIndex === dayIndex const showEventMove = eventMove?.positionChanged && eventMove.currentDayIndex === dayIndex && !eventMove.event.allDay return (
{ columnRefs.current[dayIndex] = el }} className="relative cursor-pointer touch-none border-l border-border/40" onPointerDown={handlePointerDown(dayIndex)} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerCancel} > {hourMarks.map((h) => (
))} {showTimedDrag && (
{formatMinutes(drag.startMin, timeFormat)} –{" "} {formatMinutes(drag.endMin, timeFormat)}
)} {showEventMove && eventMove && (
{formatMinutes(eventMove.currentStartMin, timeFormat)} –{" "} {formatMinutes( eventMove.currentStartMin + eventMove.durationMin, timeFormat, )}
)} {positioned.map(({ event, top, duration, leftPct, widthPct }) => { const isDragging = eventMove?.event.key === event.key && eventMove.positionChanged const compact = (duration / 60) * HOUR_PX < 40 const pending = isPendingEvent(event) return (
{ if (shouldSuppressEventClick()) { e.preventDefault() return } e.stopPropagation() onEventClick(event, anchorFromEvent(e as unknown as MouseEvent)) } } onPointerDown={ pending ? undefined : startEventMove(event, dayIndex) } onKeyDown={ pending ? undefined : (e) => { if (e.key === "Enter" || e.key === " ") { e.stopPropagation() onEventClick(event, { left: 0, top: 0, width: 0, height: 0, }) } } } > {event.title} {compact && ( {" "} ⋅ {formatEventTime(event.start, timeFormat)} )} {!compact && ( {formatEventTime(event.start, timeFormat)} –{" "} {formatEventTime(event.end, timeFormat)} )}
) })} {isToday && showNowLine && (
)}
) })}
) } function formatMinutes(min: number, timeFormat: "24h" | "12h" = "24h"): string { const d = new Date(2000, 0, 1, Math.floor(min / 60), min % 60, 0) return formatEventTime(d, timeFormat) }