SPB Git

Repository Public

agenda.tsx 18.8 KB 220 lines tsx

1"use client";2import { useEffect, useMemo, useState } from "react";3import { useLocale, useT } from "@/i18n";4import { adminAgenda } from "@/i18n/admin-agenda";5import { Badge, Button, cx, Empty, Icon, Notice } from "@/components/ui";6import { PageHeader, requestsChanged, useAdmin, useLoad } from "@/components/admin/shell";7import { addDays, addMonths, fmtTime, fmtYmd, minutesInTz, startOfMonth, startOfWeek, tzAbbr, ymdInTz } from "@/lib/format";8import type { AgendaData, Block, EventTypeLite } from "@/components/admin/requests/types";9import { BlockSheet, type BlockDraft } from "./block-sheet";10import { BookingSheet, ExternalBlockSheet, MoveConfirmSheet, NewSheet } from "./sheets";11import { eventStyle, TimeGrid, type Ev } from "./time-grid";12import { zonedToIso } from "./zoned";1314type View = "day" | "week" | "month" | "list";15const VIEWS: { key: View; label: "vDay" | "vWeek" | "vMonth" | "vList" }[] = [{ key: "day", label: "vDay" }, { key: "week", label: "vWeek" }, { key: "month", label: "vMonth" }, { key: "list", label: "vList" }];16const LIST_DAYS = 30;1718export function Agenda() {19  const t = useT(adminAgenda);20  const locale = useLocale();21  const { timezone: tz } = useAdmin();22  const [view, setView] = useState<View>("week");23  const [anchor, setAnchor] = useState<string | null>(null);24  const [nowMs, setNowMs] = useState<number | null>(null);25  const [show, setShow] = useState({ confirmed: true, pending: true, cancelled: false, blocks: true });26  const [typeFilter, setTypeFilter] = useState("");27  const [draft, setDraft] = useState<BlockDraft | null>(null);28  const [editBlock, setEditBlock] = useState<BlockDraft | null>(null);29  const [external, setExternal] = useState<Block | null>(null);30  const [bookingId, setBookingId] = useState<string | null>(null);31  const [drop, setDrop] = useState<{ ev: Ev; to: string } | null>(null);3233  // The clock and the viewport are only known in the browser.34  useEffect(() => {35    const now = Date.now();36    setNowMs(now); setAnchor(ymdInTz(new Date(now), tz));37    if (!window.matchMedia("(min-width: 1024px)").matches) setView("day");38    const id = setInterval(() => setNowMs(Date.now()), 60_000);39    return () => clearInterval(id);40  }, [tz]);41  const today = nowMs ? ymdInTz(new Date(nowMs), tz) : null;4243  const range = useMemo(() => {44    if (!anchor) return null;45    const start = view === "day" || view === "list" ? anchor : view === "week" ? startOfWeek(anchor) : startOfWeek(startOfMonth(anchor));46    const n = view === "day" ? 1 : view === "week" ? 7 : view === "month" ? 42 : LIST_DAYS;47    return { start, n, days: Array.from({ length: n }, (_, i) => addDays(start, i)) };48  }, [anchor, view]);49  // The day view loads its whole week, so the week strip above the grid can mark the days that have something.50  const loadStart = range ? (view === "day" ? startOfWeek(range.start) : range.start) : null, loadN = range ? (view === "day" ? 7 : range.n) : 0;51  const path = loadStart ? `/api/v1/admin/agenda?from=${encodeURIComponent(zonedToIso(loadStart, 0, tz))}&to=${encodeURIComponent(zonedToIso(addDays(loadStart, loadN), 0, tz))}` : null;52  const state = useLoad<AgendaData>(path);53  const types = useLoad<{ types: EventTypeLite[] }>("/api/v1/admin/event-types");54  const reload = () => { void state.reload(); requestsChanged(); };5556  const events = useMemo<Ev[]>(() => {57    const d = state.data; if (!d) return [];58    const out: Ev[] = [];59    const typeOk = (id: string | null | undefined) => !typeFilter || id === typeFilter;60    for (const b of d.bookings) {61      if (b.status === "confirmed" ? !show.confirmed : !show.cancelled) continue;62      if (!typeOk(b.type?.id)) continue;63      out.push({ key: `b:${b.id}`, kind: "booking", id: b.id, start: Date.parse(b.startsAt), end: Date.parse(b.endsAt), title: b.contact?.name ?? b.title, sub: b.type?.title ?? "", color: b.type?.color ?? "#596273",64        status: b.status, statusLabel: b.status === "confirmed" ? t("st_confirmed") : t("st_cancelled"), typeId: b.type?.id ?? null });65    }66    if (show.pending) for (const r of d.requests) {67      if (!typeOk(r.type.id)) continue;68      const s = r.proposal?.startsAt ?? r.startsAt, e = r.proposal?.endsAt ?? r.endsAt;69      out.push({ key: `r:${r.id}`, kind: "request", id: r.id, start: Date.parse(s), end: Date.parse(e), title: r.contact?.name ?? r.type.title, sub: r.type.title, color: r.type.color,70        status: r.status === "pending" ? "pending" : "proposal", statusLabel: r.status === "pending" ? t("st_pending") : t("st_reschedule_requested"), typeId: r.type.id });71    }72    if (show.blocks && !typeFilter) for (const b of d.blocks) {73      out.push({ key: `k:${b.id}`, kind: "block", id: b.id, start: Date.parse(b.startsAt), end: Date.parse(b.endsAt), title: b.source === "external" ? (b.calendar ?? t("externalShort")) : (b.title || t("blockUntitled")), sub: "", color: "#596273",74        status: b.source, statusLabel: b.source === "external" ? t("externalShort") : t("blockShort"), typeId: null });75    }76    return out.sort((a, b) => a.start - b.start);77  }, [state.data, show, typeFilter, t]);78  const open = useMemo(() => (state.data?.open ?? []).map((o) => ({ start: Date.parse(o.startsAt), end: Date.parse(o.endsAt) })), [state.data]);7980  const step = (dir: number) => { if (!anchor) return; setAnchor(view === "day" ? addDays(anchor, dir) : view === "week" ? addDays(anchor, 7 * dir) : view === "month" ? addMonths(startOfMonth(anchor), dir) : addDays(anchor, LIST_DAYS * dir)); };81  const title = !range || !anchor ? "" : view === "day" ? fmtYmd(anchor, locale, { weekday: "long", day: "numeric", month: "long", year: "numeric" })82    : view === "month" ? fmtYmd(anchor, locale, { month: "long", year: "numeric" })83    : `${fmtYmd(range.start, locale, { day: "numeric", month: "short" })} – ${fmtYmd(addDays(range.start, range.n - 1), locale, { day: "numeric", month: "short", year: "numeric" })}`;8485  const newAt = (ymd: string, min: number) => setDraft({ startYmd: ymd, startMin: min, endYmd: min + 60 >= 1440 ? addDays(ymd, 1) : ymd, endMin: (min + 60) % 1440, title: "", note: "" });86  const newNow = () => { if (!anchor) return; const base = view === "day" || anchor === today ? anchor : range!.start; newAt(base, base === today && nowMs ? Math.min(23 * 60, (Math.floor(minutesInTz(new Date(nowMs), tz) / 60) + 1) * 60) : 9 * 60); };87  const openEvent = (ev: Ev) => {88    if (ev.kind === "booking") setBookingId(ev.id);89    else if (ev.kind === "request") window.location.assign("/admin/requests");90    else {91      const b = state.data?.blocks.find((x) => x.id === ev.id); if (!b) return;92      if (b.source === "external") setExternal(b);93      else setEditBlock({ id: b.id, startYmd: ymdInTz(b.startsAt, tz), startMin: minutesInTz(b.startsAt, tz), endYmd: ymdInTz(b.endsAt, tz), endMin: minutesInTz(b.endsAt, tz), title: b.title, note: b.note });94    }95  };96  const chip = (key: keyof typeof show, label: string) => (97    <button key={key} type="button" aria-pressed={show[key]} onClick={() => setShow((s) => ({ ...s, [key]: !s[key] }))}98      className={cx("pill !min-h-[36px] !px-3 !text-[13.5px]", !show[key] && "pill-off")}>{show[key] ? <Icon name="check" size={14} /> : null}{label}</button>99  );100  const evLabel = (ev: Ev) => `${ev.title}${ev.sub ? `, ${ev.sub}` : ""} — ${fmtTime(new Date(ev.start), tz, locale)} – ${fmtTime(new Date(ev.end), tz, locale)} — ${ev.statusLabel}`;101  const byDay = useMemo(() => { const m = new Map<string, Ev[]>(); for (const ev of events) { const k = ymdInTz(new Date(ev.start), tz); const l = m.get(k); if (l) l.push(ev); else m.set(k, [ev]); } return m; }, [events, tz]);102103  const strip = view === "day" && anchor ? Array.from({ length: 7 }, (_, i) => addDays(startOfWeek(anchor), i)) : null;104105  return (106    <>107      <PageHeader inline title={t("agTitle")} actions={<Button variant="primary" icon="plus" className="!rounded-full" onClick={newNow} disabled={!anchor}>{t("newTitle")}</Button>} />108      <div className="flex flex-wrap items-center gap-x-3 gap-y-3 mb-3">109        <h2 className="t-headline !text-[20px] first-letter:uppercase min-w-0 flex-1 order-1" aria-live="polite">{view === "day" && anchor ? <><span className="lg:hidden">{fmtYmd(anchor, locale, { month: "long", year: "numeric" })}</span><span className="sr-only lg:not-sr-only">{title}</span></> : title}</h2>110        <div className="flex items-center gap-1.5 order-2" role="group" aria-label={t("navLabel")}>111          <button type="button" className="btn btn-icon !w-10 !min-h-10 bg-elev" aria-label={t("prev")} onClick={() => step(-1)}><Icon name="left" size={17} /></button>112          <Button small className="!rounded-full !px-3.5 !min-h-10" onClick={() => today && setAnchor(today)}>{t("today")}</Button>113          <button type="button" className="btn btn-icon !w-10 !min-h-10 bg-elev" aria-label={t("next")} onClick={() => step(1)}><Icon name="right" size={17} /></button>114        </div>115        <div className="seg w-full lg:w-auto order-3" role="group" aria-label={t("viewLabel")}>116          {VIEWS.map((v) => <button key={v.key} type="button" aria-pressed={view === v.key} onClick={() => setView(v.key)} className="seg-item">{t(v.label)}</button>)}117        </div>118      </div>119      {strip ? (120        <div className="grid grid-cols-7 gap-1 mb-3" role="group" aria-label={t("date")}>121          {strip.map((d) => {122            const n = byDay.get(d)?.length ?? 0;123            return (124              <button key={d} type="button" aria-pressed={d === anchor} aria-current={d === today ? "date" : undefined} onClick={() => setAnchor(d)}125                className={cx("relative flex flex-col items-center justify-center gap-0.5 min-h-[62px] rounded-[18px] cursor-pointer transition-all duration-200 active:scale-95", d === anchor ? "accent-bg shadow-glow" : "hover:bg-elev", d === today && d !== anchor && "text-blue")}>126                <span className="sr-only">{fmtYmd(d, locale, { weekday: "long", day: "numeric", month: "long" })} — {t("nEvents", { n })}</span>127                <span aria-hidden className={cx("text-[11px] font-bold uppercase tracking-wide", d === anchor ? "opacity-90" : d === today ? "" : "t-mute")}>{fmtYmd(d, locale, { weekday: "short" }).replace(".", "").slice(0, 3)}</span>128                <span aria-hidden className="text-[19px] font-[750] leading-none tracking-tight t-num">{fmtYmd(d, locale, { day: "numeric" })}</span>129                <span className={cx("w-[5px] h-[5px] rounded-full", n ? (d === anchor ? "bg-white" : "bg-[var(--a1)]") : "bg-transparent")} aria-hidden />130              </button>131            );132          })}133        </div>134      ) : null}135      <div className="scroll-x items-center -mx-4 px-4 sm:mx-0 sm:px-0 mb-3.5 py-0.5">136        <div className="flex gap-2 shrink-0" role="group" aria-label={t("filterLabel")}>137          {chip("confirmed", t("shConfirmed"))}{chip("pending", t("shPending"))}{chip("blocks", t("shBlocks"))}{chip("cancelled", t("shCancelled"))}138        </div>139        <label className="sr-only" htmlFor="ag-type">{t("type")}</label>140        <select id="ag-type" className="input shrink-0 !min-h-[36px] !py-1 !w-auto !text-[13.5px] !rounded-full !font-semibold max-w-[200px]" value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>141          <option value="">{t("allTypes")}</option>142          {(types.data?.types ?? []).map((x) => <option key={x.id} value={x.id}>{x.title}</option>)}143        </select>144        <span className="t-mute text-[12.5px] ml-auto shrink-0 hidden lg:inline-flex items-center gap-1"><Icon name="globe" size={14} />{tz}{nowMs ? ` (${tzAbbr(new Date(nowMs), tz, locale)})` : ""}</span>145      </div>146147      {state.error && !state.data ? <Notice tone="danger"><p>{t("loadError")} ({state.error.code})</p><Button small className="mt-2" icon="refresh" onClick={() => void state.reload()}>{t("retry")}</Button></Notice>148        : !range || !state.data ? <div className="skeleton h-[460px] !rounded-[24px]" aria-busy="true" aria-label={t("loading")} />149        : (150          <div className={cx(state.loading && "opacity-70 transition-opacity")} aria-busy={state.loading}>151            {state.error ? <div className="mb-3"><Notice tone="warn">{t("staleData")}</Notice></div> : null}152            {view === "day" || view === "week" ? (153              <TimeGrid days={range.days} tz={tz} events={events} open={open} nowMs={nowMs} onCell={newAt} onEvent={openEvent} onDrop={(ev, to) => setDrop({ ev, to })} />154            ) : view === "month" ? (155              <div className="card overflow-hidden">156                <div className="grid grid-cols-7 border-b border-line" aria-hidden>{range.days.slice(0, 7).map((d) => <div key={d} className="px-1 py-2.5 text-center t-eyebrow !text-[11px]">{fmtYmd(d, locale, { weekday: "short" }).replace(".", "")}</div>)}</div>157                <div className="grid grid-cols-7">158                  {range.days.map((d, i) => {159                    const list = byDay.get(d) ?? [];160                    const inMonth = d.slice(0, 7) === anchor!.slice(0, 7);161                    return (162                      <button key={d} type="button" onClick={() => { setAnchor(d); setView("day"); }} aria-current={d === today ? "date" : undefined}163                        className={cx("min-h-[68px] sm:min-h-[112px] p-1 sm:p-1.5 text-left align-top flex flex-col items-center sm:items-stretch gap-1 sm:gap-0.5 cursor-pointer hover:bg-elev active:bg-elev transition-colors border-line", i % 7 !== 0 && "border-l", i >= 7 && "border-t", !inMonth && "bg-elev/50")}>164                        <span className="sr-only">{fmtYmd(d, locale, { weekday: "long", day: "numeric", month: "long" })} — {t("nEvents", { n: list.length })}</span>165                        <span aria-hidden className={cx("text-[14px] font-bold inline-flex items-center justify-center min-w-[28px] h-[28px] px-1 rounded-full sm:self-start t-num", d === today ? "accent-bg shadow-glow" : !inMonth ? "t-mute font-medium" : "")}>{fmtYmd(d, locale, { day: "numeric" })}</span>166                        {/* Phones: one dot per element; wider screens: labelled chips. */}167                        <span className="flex sm:hidden flex-wrap justify-center gap-[3px] max-w-[34px]" aria-hidden>{list.slice(0, 6).map((ev) => <span key={ev.key} className="w-[6px] h-[6px] rounded-full" style={ev.kind === "request" ? { boxShadow: `inset 0 0 0 1.5px ${ev.color}` } : { background: ev.kind === "block" ? "var(--line-strong)" : ev.color, opacity: ev.status === "cancelled" ? 0.35 : 1 }} />)}</span>168                        {list.slice(0, 3).map((ev) => { const st = eventStyle(ev); return <span key={ev.key} aria-hidden className={cx("hidden sm:block w-full rounded-md px-1.5 text-[11.5px] leading-[18px] truncate", st.className)} style={st.style}>{fmtTime(new Date(ev.start), tz, locale)} {ev.title}</span>; })}169                        {list.length > 3 ? <span aria-hidden className="hidden sm:inline text-[11px] t-mute font-semibold px-1">+{list.length - 3}</span> : null}170                      </button>171                    );172                  })}173                </div>174              </div>175            ) : events.length === 0 ? (176              <div className="card"><Empty icon="calendar" title={t("listEmpty")}>{t("listEmptyLead")}</Empty></div>177            ) : (178              <div className="grid gap-4">179                {range.days.filter((d) => byDay.has(d)).map((d) => (180                  <section key={d} aria-label={fmtYmd(d, locale, { weekday: "long", day: "numeric", month: "long" })}>181                    <h3 className={cx("t-eyebrow mb-2 px-1", d === today && "!text-blue")}>{fmtYmd(d, locale, { weekday: "long", day: "numeric", month: "long" })}{d === today ? ` · ${t("today")}` : ""}</h3>182                    <ul className="lgroup">183                      {byDay.get(d)!.map((ev) => (184                        <li key={ev.key}>185                          <button type="button" onClick={() => openEvent(ev)} className="row !min-h-[64px]">186                            <span className="sr-only">{evLabel(ev)}</span>187                            <span className="w-[5px] self-stretch rounded-full shrink-0" style={ev.kind === "request" ? { border: `1.5px dashed ${ev.color}` } : { background: ev.kind === "block" ? "var(--line-strong)" : ev.color }} aria-hidden />188                            <span className="min-w-0 flex-1" aria-hidden>189                              <span className={cx("block font-semibold truncate", ev.status === "cancelled" && "line-through t-mute")}>{ev.title}</span>190                              <span className="block text-sm t-mute truncate t-num">{fmtTime(new Date(ev.start), tz, locale)} – {fmtTime(new Date(ev.end), tz, locale)}{ev.sub ? ` · ${ev.sub}` : ""}</span>191                            </span>192                            <span aria-hidden><Badge tone={ev.status === "confirmed" ? "ok" : ev.status === "pending" ? "pending" : ev.status === "proposal" ? "info" : ev.status === "cancelled" ? "danger" : "mute"}>{ev.statusLabel}</Badge></span>193                          </button>194                        </li>195                      ))}196                    </ul>197                  </section>198                ))}199              </div>200            )}201            <p className="lg:hidden t-mute text-[12.5px] mt-3.5 px-1 inline-flex items-center gap-1"><Icon name="globe" size={14} />{tz}{nowMs ? ` (${tzAbbr(new Date(nowMs), tz, locale)})` : ""}</p>202            <ul className="flex flex-wrap gap-x-4 gap-y-1 mt-2 lg:mt-3.5 px-1 text-[12.5px] t-mute" aria-label={t("legend")}>203              <li className="inline-flex items-center gap-1.5"><span className="w-3 h-3 rounded-[4px]" style={{ background: "color-mix(in srgb, var(--blue) 25%, var(--surface))", borderLeft: "3px solid var(--blue)" }} aria-hidden />{t("st_confirmed")}</li>204              <li className="inline-flex items-center gap-1.5"><span className="w-3 h-3 rounded-[4px]" style={{ border: "1.5px dashed var(--blue)" }} aria-hidden />{t("st_pending")}</li>205              <li className="inline-flex items-center gap-1.5"><span className="w-3 h-3 rounded-[4px] bg-elev border border-line-strong" aria-hidden />{t("blockShort")}</li>206              {view === "day" || view === "week" ? <li className="inline-flex items-center gap-1.5"><span className="w-3 h-3 rounded-[4px] bg-elev/70 border border-line" aria-hidden />{t("legendClosed")}</li> : null}207              {view === "day" || view === "week" ? <li className="hidden lg:inline">{t("legendDrag")}</li> : null}208            </ul>209          </div>210        )}211212      {draft ? <NewSheet draft={draft} types={types.data?.types ?? []} onClose={() => setDraft(null)} onSaved={reload} /> : null}213      {editBlock ? <BlockSheet draft={editBlock} onClose={() => setEditBlock(null)} onSaved={reload} /> : null}214      {external ? <ExternalBlockSheet block={external} onClose={() => setExternal(null)} /> : null}215      {bookingId ? <BookingSheet id={bookingId} onClose={() => setBookingId(null)} onChanged={reload} /> : null}216      {drop ? <MoveConfirmSheet id={drop.ev.id} title={`${drop.ev.title}${drop.ev.sub ? ` — ${drop.ev.sub}` : ""}`} from={new Date(drop.ev.start).toISOString()} to={drop.to} onClose={() => setDrop(null)} onChanged={reload} /> : null}217    </>218  );219}220
Files main
Menu

Theme