ultisuite-client/components/agenda/agenda-view-week.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

717 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<HTMLElement>): 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<HTMLDivElement>(null)
const columnRefs = useRef<(HTMLDivElement | null)[]>([])
const dragRef = useRef<DragState | null>(null)
const eventMoveRef = useRef<EventMoveState | null>(null)
const dragSessionCleanupRef = useRef<(() => void) | null>(null)
const suppressClickRef = useRef(false)
const [drag, setDrag] = useState<DragState | null>(null)
const [eventMove, setEventMove] = useState<EventMoveState | null>(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<HTMLElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-mail-surface">
{/* En-tête : jours + rangée journée entière */}
<div className="shrink-0 border-b border-border/60">
<div className="grid" style={{ gridTemplateColumns: colTemplate }}>
<div />
{days.map((day) => {
const isToday = isSameDay(day, now)
return (
<div
key={day.getTime()}
className="flex flex-col items-center gap-0.5 border-l border-border/40 pt-2 pb-1"
>
<span className="text-[0.7rem] font-medium tracking-wide text-muted-foreground uppercase">
{format(day, "EEE", { locale: fr }).replace(".", "")}
</span>
<button
type="button"
onClick={() => onOpenDay(day)}
className={cn(
"flex size-10 items-center justify-center rounded-full text-[1.45rem] font-normal text-foreground/80 hover:bg-mail-nav-hover",
isToday &&
"bg-primary font-medium text-primary-foreground hover:bg-primary",
)}
>
{day.getDate()}
</button>
</div>
)
})}
</div>
{hasBanners && (
<div className="grid" style={{ gridTemplateColumns: colTemplate }}>
<div className="pt-0.5 pr-2 text-right text-[0.65rem] text-muted-foreground" />
{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 (
<div
key={day.getTime()}
className="relative flex min-h-6 flex-col gap-0.5 border-l border-border/40 px-0.5 pb-1"
>
{inDragRange && (
<div
aria-hidden
className={cn(
"absolute inset-x-0.5 top-0.5 bottom-0.5 rounded-md bg-primary/25 ring-1 ring-primary/50",
dayIndex === dragMultiDayRange!.min && "rounded-r-none",
dayIndex === dragMultiDayRange!.max && "rounded-l-none",
dragMultiDayRange!.min !== dragMultiDayRange!.max &&
dayIndex > dragMultiDayRange!.min &&
dayIndex < dragMultiDayRange!.max &&
"rounded-none",
)}
/>
)}
{inEventMoveTarget && (
<div
aria-hidden
className="absolute inset-x-0.5 top-0.5 bottom-0.5 rounded-md bg-primary/25 ring-1 ring-primary/50"
/>
)}
{banners.map((event) => {
const isDragging =
eventMove?.event.key === event.key && eventMove.positionChanged
return (
<div
key={event.key}
data-agenda-event
className={cn(
onEventMove && !isPendingEvent(event) && "touch-none cursor-grab active:cursor-grabbing",
isDragging && "opacity-40",
)}
onPointerDown={startEventMove(event, dayIndex)}
>
<AgendaEventChip
event={event}
filled
pending={isPendingEvent(event)}
onClick={(e) => {
if (shouldSuppressEventClick()) {
e.preventDefault()
return
}
e.stopPropagation()
if (!isPendingEvent(event)) {
onEventClick(event, anchorFromEvent(e))
}
}}
/>
</div>
)
})}
</div>
)
})}
</div>
)}
</div>
{/* Grille horaire */}
<div ref={scrollRef} className={SCROLL_CLASS}>
<div
className="relative grid"
style={{ gridTemplateColumns: colTemplate, height: gridHeightPx }}
>
<div className="relative">
{hourMarks.map((h) => (
<span
key={h}
className="absolute right-2 -translate-y-1/2 text-[0.65rem] text-muted-foreground"
style={{ top: ((h * 60 - visibleHoursStart) / 60) * HOUR_PX }}
>
{formatHourLabel(h % 24, timeFormat)}
</span>
))}
</div>
{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 (
<div
key={day.getTime()}
ref={(el) => {
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) => (
<div
key={h}
aria-hidden
className="absolute right-0 left-0 border-t border-border/40"
style={{ top: ((h * 60 - visibleHoursStart) / 60) * HOUR_PX }}
/>
))}
{showTimedDrag && (
<div
aria-hidden
className="absolute right-1 left-0.5 z-10 rounded-md bg-primary/25 ring-1 ring-primary/50"
style={{
top: ((drag.startMin - visibleHoursStart) / 60) * HOUR_PX,
height: Math.max(
((drag.endMin - drag.startMin) / 60) * HOUR_PX,
8,
),
}}
>
<span className="px-1.5 text-[0.65rem] font-medium text-primary">
{formatMinutes(drag.startMin, timeFormat)} {" "}
{formatMinutes(drag.endMin, timeFormat)}
</span>
</div>
)}
{showEventMove && eventMove && (
<div
aria-hidden
className="absolute right-1 left-0.5 z-10 rounded-md bg-primary/25 ring-1 ring-primary/50"
style={{
top: ((eventMove.currentStartMin - visibleHoursStart) / 60) * HOUR_PX,
height: Math.max(
(eventMove.durationMin / 60) * HOUR_PX,
MIN_EVENT_PX,
),
}}
>
<span className="px-1.5 text-[0.65rem] font-medium text-primary">
{formatMinutes(eventMove.currentStartMin, timeFormat)} {" "}
{formatMinutes(
eventMove.currentStartMin + eventMove.durationMin,
timeFormat,
)}
</span>
</div>
)}
{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 (
<div
key={event.key}
role={pending ? "presentation" : "button"}
tabIndex={pending ? undefined : 0}
data-agenda-event
className={cn(
"absolute z-20 flex flex-col overflow-hidden rounded-md px-1.5 py-0.5 text-left shadow-sm ring-1 ring-black/5 transition-[filter,opacity] hover:z-30 hover:brightness-95 dark:ring-white/10 dark:hover:brightness-110",
pending && "pointer-events-none z-[25] ring-2 ring-dashed ring-primary/60",
!pending && onEventMove && "cursor-grab touch-none active:cursor-grabbing",
!pending && !onEventMove && "cursor-pointer",
isDragging && "opacity-40",
)}
style={{
top: ((top - visibleHoursStart) / 60) * HOUR_PX,
height: Math.max((duration / 60) * HOUR_PX - 2, MIN_EVENT_PX),
left: `calc(${leftPct}% + 1px)`,
width: `calc(${widthPct}% - 3px)`,
backgroundColor: pending ? `${event.color}99` : event.color,
color: readableTextColor(event.color),
}}
onClick={
pending
? undefined
: (e) => {
if (shouldSuppressEventClick()) {
e.preventDefault()
return
}
e.stopPropagation()
onEventClick(event, anchorFromEvent(e as unknown as MouseEvent<HTMLElement>))
}
}
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,
})
}
}
}
>
<span
className={cn(
"truncate text-xs leading-tight font-medium",
compact && "text-[0.7rem]",
)}
>
{event.title}
{compact && (
<span className="font-normal opacity-90">
{" "}
{formatEventTime(event.start, timeFormat)}
</span>
)}
</span>
{!compact && (
<span className="truncate text-[0.7rem] leading-tight opacity-90">
{formatEventTime(event.start, timeFormat)} {" "}
{formatEventTime(event.end, timeFormat)}
</span>
)}
</div>
)
})}
{isToday && showNowLine && (
<div
aria-hidden
className="pointer-events-none absolute right-0 left-0 z-30"
style={{ top: nowTop }}
>
<div className="relative h-0.5 bg-red-500">
<span className="absolute top-1/2 -left-1 size-3 -translate-y-1/2 rounded-full bg-red-500" />
</div>
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)
}
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)
}