SPB Git

Repository Public

time-grid.tsx 10.7 KB 160 lines tsx

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
Files main
Menu

Theme