Repository Public
1"use client";2import { useEffect, useMemo, useRef, useState } from "react";3import { useLocale, useT } from "@/i18n";4import { adminAgenda } from "@/i18n/admin-agenda";5import { cx } from "@/components/ui";6import { addDays, fmtDateTime, fmtTime, fmtYmd, minutesInTz, minutesLabel, ymdInTz } from "@/lib/format";7import { zonedToIso, zonedToMs } from "./zoned";89export const HOUR = 56; // px per hour10const SNAP_MIN = 15;1112export interface Ev {13 key: string; kind: "booking" | "request" | "block"; id: string; start: number; end: number; title: string; sub: string; color: string;14 /** confirmed | cancelled | pending | proposal | manual | external */15 status: string; statusLabel: string; typeId: string | null;16}17interface Seg { ev: Ev; top: number; height: number; col: number; cols: number; continues: boolean }1819/** Side-by-side layout of overlapping segments within one day. */20function layout(segs: Omit<Seg, "col" | "cols">[]): Seg[] {21 const sorted = [...segs].sort((a, b) => a.top - b.top || b.height - a.height);22 const out: Seg[] = [];23 let cluster: Seg[] = [], colEnds: number[] = [], clusterEnd = -1;24 const flush = () => { for (const s of cluster) s.cols = colEnds.length; out.push(...cluster); cluster = []; colEnds = []; };25 for (const s of sorted) {26 if (cluster.length && s.top >= clusterEnd) flush();27 let col = colEnds.findIndex((e) => e <= s.top);28 if (col < 0) { col = colEnds.length; colEnds.push(0); }29 colEnds[col] = s.top + s.height;30 clusterEnd = Math.max(clusterEnd, s.top + s.height);31 cluster.push({ ...s, col, cols: 1 });32 }33 flush();34 return out;35}3637export function eventStyle(ev: Ev): { className: string; style: React.CSSProperties } {38 if (ev.kind === "booking" && ev.status === "confirmed") return { className: "border border-transparent text-ink", style: { background: `color-mix(in srgb, ${ev.color} 18%, var(--surface))`, boxShadow: `inset 4px 0 0 ${ev.color}` } };39 if (ev.kind === "booking") return { className: "border border-line bg-surface t-mute line-through", style: {} };40 if (ev.kind === "request") return { className: "text-ink", style: { border: `1.5px dashed ${ev.color}`, background: `repeating-linear-gradient(135deg, color-mix(in srgb, ${ev.color} 12%, var(--surface)) 0 6px, var(--surface) 6px 12px)` } };41 if (ev.status === "external") return { className: "border border-dashed border-line-strong bg-elev text-ink italic", style: {} };42 return { className: "border border-line-strong bg-elev text-ink", style: {} };43}4445export function TimeGrid({ days, tz, events, open, nowMs, onCell, onEvent, onDrop }: {46 days: string[]; tz: string; events: Ev[]; open: { start: number; end: number }[]; nowMs: number | null;47 onCell: (ymd: string, minutes: number) => void; onEvent: (ev: Ev) => void; onDrop: (ev: Ev, newStartIso: string) => void;48}) {49 const t = useT(adminAgenda);50 const locale = useLocale();51 const scroller = useRef<HTMLDivElement>(null);52 const columns = useRef<HTMLDivElement>(null);53 const drag = useRef<{ ev: Ev; x: number; y: number; colW: number; dayIndex: number; moved: boolean } | null>(null);54 const suppressClick = useRef(false);55 const [ghost, setGhost] = useState<{ key: string; dDays: number; dy: number } | null>(null);56 const todayYmd = nowMs ? ymdInTz(new Date(nowMs), tz) : null;5758 useEffect(() => { if (scroller.current) scroller.current.scrollTop = 7.5 * HOUR; }, [days.length]);5960 const perDay = useMemo(() => days.map((ymd) => {61 const dayStart = zonedToMs(ymd, 0, tz), dayEnd = zonedToMs(addDays(ymd, 1), 0, tz);62 const clip = (s: number, e: number) => {63 const a = Math.max(s, dayStart), b = Math.min(e, dayEnd);64 if (b <= a) return null;65 const top = a === dayStart ? 0 : (minutesInTz(new Date(a), tz) / 60) * HOUR;66 return { top, height: Math.min(((b - a) / 3_600_000) * HOUR, 24 * HOUR - top) };67 };68 const segs: Omit<Seg, "col" | "cols">[] = [];69 for (const ev of events) { const c = clip(ev.start, ev.end); if (c) segs.push({ ev, top: c.top, height: Math.max(c.height, 20), continues: ev.start < dayStart }); }70 const opens = open.map((o) => clip(o.start, o.end)).filter((x): x is { top: number; height: number } => !!x);71 return { ymd, segs: layout(segs), opens };72 }), [days, events, open, tz]);7374 const down = (e: React.PointerEvent<HTMLButtonElement>, ev: Ev, dayIndex: number) => {75 if (ev.kind !== "booking" || ev.status !== "confirmed" || e.pointerType === "touch" || e.button !== 0) return;76 const colW = (columns.current?.getBoundingClientRect().width ?? 0) / days.length;77 drag.current = { ev, x: e.clientX, y: e.clientY, colW, dayIndex, moved: false };78 e.currentTarget.setPointerCapture(e.pointerId);79 };80 const move = (e: React.PointerEvent) => {81 const d = drag.current; if (!d) return;82 const dx = e.clientX - d.x, dy = e.clientY - d.y;83 if (!d.moved && Math.hypot(dx, dy) < 6) return;84 d.moved = true;85 const stepPx = (HOUR * SNAP_MIN) / 60;86 const dDays = d.colW > 0 ? Math.max(-d.dayIndex, Math.min(days.length - 1 - d.dayIndex, Math.round(dx / d.colW))) : 0;87 setGhost({ key: d.ev.key, dDays, dy: Math.round(dy / stepPx) * stepPx });88 };89 const up = () => {90 const d = drag.current; drag.current = null;91 if (!d?.moved) return;92 suppressClick.current = true;93 setTimeout(() => { suppressClick.current = false; }, 80);94 const g = ghost; setGhost(null);95 if (!g || (g.dDays === 0 && g.dy === 0)) return;96 const startDate = new Date(d.ev.start);97 const iso = zonedToIso(addDays(ymdInTz(startDate, tz), g.dDays), minutesInTz(startDate, tz) + Math.round((g.dy / HOUR) * 60), tz);98 onDrop(d.ev, iso);99 };100 const cancelDrag = () => { drag.current = null; setGhost(null); };101102 const label = (ev: Ev) => `${ev.title}${ev.sub ? `, ${ev.sub}` : ""} — ${fmtDateTime(new Date(ev.start), tz, locale)} – ${fmtTime(new Date(ev.end), tz, locale)} — ${ev.statusLabel}`;103 const multi = days.length > 1;104105 return (106 <div ref={scroller} className="card overflow-auto overscroll-contain max-h-[calc(100dvh-420px)] lg:max-h-[calc(100dvh-270px)] min-h-[380px] relative" role="region" tabIndex={0} aria-label={t("gridLabel")}>107 <div className={cx(multi && "min-w-[760px]")}>108 <div className={cx("sticky top-0 z-20 glass border-b border-line grid", !multi && "max-lg:hidden")} style={{ gridTemplateColumns: `52px repeat(${days.length}, minmax(0, 1fr))` }}>109 <div className="sticky left-0" />110 {days.map((ymd) => (111 <div key={ymd} className={cx("px-2 py-2.5 text-center text-[13px] font-semibold border-l border-line", ymd === todayYmd ? "text-blue" : "")} aria-current={ymd === todayYmd ? "date" : undefined}>112 <span className="first-letter:uppercase">{fmtYmd(ymd, locale, { weekday: multi ? "short" : "long" })}</span>{" "}113 <span className={cx("inline-flex items-center justify-center min-w-[28px] h-[28px] px-1 rounded-full t-num", ymd === todayYmd && "accent-bg shadow-glow")}>{fmtYmd(ymd, locale, { day: "numeric" })}</span>114 </div>115 ))}116 </div>117 <div className="grid" style={{ gridTemplateColumns: `52px 1fr` }}>118 <div className="sticky left-0 z-10 bg-surface" aria-hidden>119 <div className="relative" style={{ height: 24 * HOUR }}>120 {Array.from({ length: 23 }, (_, h) => <span key={h} className="absolute right-2 -translate-y-1/2 text-[11.5px] font-medium t-mute whitespace-nowrap t-num" style={{ top: (h + 1) * HOUR }}>{minutesLabel((h + 1) * 60, locale)}</span>)}121 </div>122 </div>123 <div ref={columns} className="grid" style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }}>124 {perDay.map((day, dayIndex) => (125 <div key={day.ymd} className="relative border-l border-line bg-elev/60 cursor-cell" style={{ height: 24 * HOUR }}126 onClick={(e) => {127 if (suppressClick.current) { suppressClick.current = false; return; }128 const y = e.clientY - e.currentTarget.getBoundingClientRect().top;129 onCell(day.ymd, Math.max(0, Math.min(1410, Math.floor(((y / HOUR) * 60) / 30) * 30)));130 }}>131 {day.opens.map((o, i) => <div key={i} className="absolute inset-x-0 bg-surface pointer-events-none" style={{ top: o.top, height: o.height }} />)}132 {Array.from({ length: 23 }, (_, h) => <div key={h} className="absolute inset-x-0 border-t border-line pointer-events-none" style={{ top: (h + 1) * HOUR }} />)}133 {day.ymd === todayYmd && nowMs ? <div className="absolute inset-x-0 z-[5] pointer-events-none" style={{ top: (minutesInTz(new Date(nowMs), tz) / 60) * HOUR }}><div className="h-[2px] bg-danger rounded-full shadow-[0_0_10px_var(--danger)]" /><div className="absolute -left-[5px] -top-[4px] w-[10px] h-[10px] rounded-full bg-danger text-danger live-dot" /></div> : null}134 {day.segs.map((s) => {135 const st = eventStyle(s.ev);136 const g = ghost && ghost.key === s.ev.key ? ghost : null;137 const draggable = s.ev.kind === "booking" && s.ev.status === "confirmed";138 return (139 <button key={s.ev.key + day.ymd} type="button" title={label(s.ev)}140 onClick={(e) => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return; } onEvent(s.ev); }}141 onPointerDown={(e) => down(e, s.ev, dayIndex)} onPointerMove={move} onPointerUp={up} onPointerCancel={cancelDrag}142 className={cx("absolute pl-2.5 pr-1.5 text-left text-[12.5px] leading-tight overflow-hidden cursor-pointer z-[2] transition-shadow hover:shadow-e2 active:brightness-95", s.height < 32 ? "rounded-[9px] py-0 flex items-center" : "rounded-[12px] py-1", st.className, draggable && "touch-pan-y", g && "z-[6] shadow-e2 opacity-90")}143 style={{ ...st.style, top: s.top + 1, height: s.height - 2, left: `calc(${(s.col / s.cols) * 100}% + 2px)`, width: `calc(${100 / s.cols}% - 4px)`,144 transform: g ? `translate(calc(${g.dDays} * (100% + 4px) * ${s.cols}), ${g.dy}px)` : undefined }}>145 {/* The full description is the accessible name; the clipped visible text is decorative. */}146 <span className="sr-only">{label(s.ev)}</span>147 <span aria-hidden className="block font-semibold truncate">{s.continues ? "↳ " : ""}{s.ev.title}</span>148 {s.height >= 34 ? <span aria-hidden className="block truncate opacity-80">{fmtTime(new Date(s.ev.start), tz, locale)}{s.ev.kind === "request" ? ` · ${s.ev.statusLabel}` : s.ev.sub ? ` · ${s.ev.sub}` : ""}</span> : null}149 </button>150 );151 })}152 </div>153 ))}154 </div>155 </div>156 </div>157 </div>158 );159}160