320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
"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")}`
|
||
}
|