ultisuite-client/components/agenda/agenda-view-week.tsx
R3D347HR4Y 3bbf3691b0
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
bordel c'est beau
2026-06-11 10:10:39 +02:00

320 lines
12 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 { format, isSameDay } 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, roundToStep } from "@/lib/agenda/agenda-date"
import { readableTextColor } from "@/lib/agenda/agenda-colors"
import { layoutDayEvents } from "@/lib/agenda/agenda-event-layout"
import { eventsOnDay, isMultiDay } from "@/lib/agenda/agenda-events"
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
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 {
dayIndex: number
anchorMin: number
startMin: number
endMin: number
moved: boolean
}
export function AgendaViewWeek({
days,
events,
onCreateRange,
onEventClick,
onOpenDay,
}: {
days: Date[]
events: AgendaEvent[]
onCreateRange: (start: Date, end: Date, allDay: boolean, anchor: AnchorRect) => void
onEventClick: (event: AgendaEvent, anchor: AnchorRect) => void
onOpenDay: (day: Date) => void
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const [drag, setDrag] = useState<DragState | null>(null)
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const id = window.setInterval(() => setNow(new Date()), 60_000)
return () => window.clearInterval(id)
}, [])
useEffect(() => {
const el = scrollRef.current
if (!el) return
const target = Math.max(0, (Math.min(now.getHours(), 18) - 1.5) * HOUR_PX)
el.scrollTop = target
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const perDay = useMemo(
() =>
days.map((day) => {
const dayEvents = eventsOnDay(events, day)
const banners = dayEvents
.filter((e) => e.allDay || isMultiDay(e))
.sort((a, b) => a.start.getTime() - b.start.getTime())
const timed = dayEvents.filter((e) => !e.allDay && !isMultiDay(e))
return { day, banners, positioned: layoutDayEvents(timed, day) }
}),
[days, events],
)
const hasBanners = perDay.some((d) => d.banners.length > 0)
const colTemplate = `${GUTTER_PX}px repeat(${days.length}, minmax(0, 1fr))`
const minuteFromPointer = (e: ReactPointerEvent<HTMLElement>): number => {
const rect = e.currentTarget.getBoundingClientRect()
const minutes = ((e.clientY - rect.top) / HOUR_PX) * 60
return Math.min(24 * 60, Math.max(0, roundToStep(minutes, 15)))
}
const handlePointerDown = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || (e.target as HTMLElement).closest("[data-agenda-event]")) return
const min = minuteFromPointer(e)
e.currentTarget.setPointerCapture(e.pointerId)
setDrag({ dayIndex, anchorMin: min, startMin: min, endMin: min + 15, moved: false })
}
const handlePointerMove = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
setDrag((d) => {
if (!d || d.dayIndex !== dayIndex) return d
const min = minuteFromPointer(e)
if (min === d.anchorMin && !d.moved) return d
return {
...d,
moved: true,
startMin: Math.min(d.anchorMin, min),
endMin: Math.max(d.anchorMin, min, Math.min(d.anchorMin, min) + 15),
}
})
}
const handlePointerUp = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
if (!drag || drag.dayIndex !== dayIndex) return
const day = days[dayIndex]
const startMin = drag.startMin
const endMin = drag.moved ? drag.endMin : drag.startMin + 60
const start = new Date(day)
start.setHours(0, startMin, 0, 0)
const end = new Date(day)
end.setHours(0, Math.max(endMin, startMin + 15), 0, 0)
const colRect = e.currentTarget.getBoundingClientRect()
const anchor: AnchorRect = {
left: colRect.left,
top: colRect.top + (startMin / 60) * HOUR_PX,
width: colRect.width,
height: Math.max(((endMin - startMin) / 60) * HOUR_PX, MIN_EVENT_PX),
}
setDrag(null)
onCreateRange(start, end, false, anchor)
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-tl-2xl border-t border-l border-border/60 bg-card">
{/* En-tête : jours + rangée journée entière */}
<div className="shrink-0 border-b border-border/60 pr-[var(--agenda-sbw,0px)]">
<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 }) => (
<div
key={day.getTime()}
className="flex min-h-6 flex-col gap-0.5 border-l border-border/40 px-0.5 pb-1"
>
{banners.map((event) => (
<AgendaEventChip
key={event.key}
event={event}
filled
onClick={(e) => {
e.stopPropagation()
onEventClick(event, anchorFromEvent(e))
}}
/>
))}
</div>
))}
</div>
)}
</div>
{/* Grille horaire */}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div
className="relative grid"
style={{ gridTemplateColumns: colTemplate, height: 24 * HOUR_PX }}
>
{/* Gouttière heures */}
<div className="relative">
{Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (
<span
key={h}
className="absolute right-2 -translate-y-1/2 text-[0.65rem] text-muted-foreground"
style={{ top: h * HOUR_PX }}
>
{String(h).padStart(2, "0")}:00
</span>
))}
</div>
{perDay.map(({ day, positioned }, dayIndex) => {
const isToday = isSameDay(day, now)
const nowTop = (now.getHours() * 60 + now.getMinutes()) * (HOUR_PX / 60)
return (
<div
key={day.getTime()}
className="relative cursor-pointer touch-none border-l border-border/40"
onPointerDown={handlePointerDown(dayIndex)}
onPointerMove={handlePointerMove(dayIndex)}
onPointerUp={handlePointerUp(dayIndex)}
>
{/* Lignes d'heures */}
{Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (
<div
key={h}
aria-hidden
className="absolute right-0 left-0 border-t border-border/40"
style={{ top: h * HOUR_PX }}
/>
))}
{/* Sélection en cours */}
{drag && drag.dayIndex === dayIndex && drag.moved && (
<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 / 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)} {formatMinutes(drag.endMin)}
</span>
</div>
)}
{/* Événements positionnés */}
{positioned.map(({ event, top, duration, leftPct, widthPct }) => {
const compact = (duration / 60) * HOUR_PX < 40
return (
<button
key={event.key}
type="button"
data-agenda-event
className="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] hover:z-30 hover:brightness-95 dark:ring-white/10 dark:hover:brightness-110"
style={{
top: (top / 60) * HOUR_PX,
height: Math.max((duration / 60) * HOUR_PX - 2, MIN_EVENT_PX),
left: `calc(${leftPct}% + 1px)`,
width: `calc(${widthPct}% - 3px)`,
backgroundColor: event.color,
color: readableTextColor(event.color),
}}
onClick={(e) => {
e.stopPropagation()
onEventClick(event, anchorFromEvent(e))
}}
onPointerDown={(e) => e.stopPropagation()}
>
<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)}
</span>
)}
</span>
{!compact && (
<span className="truncate text-[0.7rem] leading-tight opacity-90">
{formatEventTime(event.start)} {formatEventTime(event.end)}
</span>
)}
</button>
)
})}
{/* Indicateur maintenant */}
{isToday && (
<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): string {
const h = Math.floor(min / 60)
const m = min % 60
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`
}