SPB Git

Repository Public

availability.tsx 19.8 KB 310 lines tsx

1"use client";2import { useEffect, useMemo, useState } from "react";3import { useLocale, useT } from "@/i18n";4import { adminConfig } from "@/i18n/admin-config";5import { api } from "@/lib/client-api";6import { addDays, fmtYmd, hhmm, minutesInTz, minutesLabel, parseHhmm, ymdInTz, type Loc } from "@/lib/format";7import { Badge, Button, cx, Empty, errorText, Field, Notice, Sheet, toast } from "@/components/ui";8import { Loadable, PageHeader, useLoad } from "@/components/admin/shell";9import { Confirm, Section, Toggle } from "./common";1011interface Rule { eventTypeId: string | null; weekday: number; startMinute: number; endMinute: number }12interface Exc {13  id: string; eventTypeId: string | null; kind: "open" | "closed"; mode: "range" | "weekly"; startsLocal: string | null; endsLocal: string | null; allDay: boolean;14  weekdays: number[] | null; startMinute: number | null; durationMin: number | null; validFrom: string | null; validUntil: string | null; note: string;15}16interface AvailabilityData { timezone: string; rules: Rule[]; exceptions: Exc[] }17interface TypeLite { id: string; title: string; custom_availability: boolean }18interface Range { start: string; end: string }19type Draft = Record<number, Range[]>;2021const DAYS = [1, 2, 3, 4, 5, 6, 7];22const dayName = (d: number, l: Loc, style: "long" | "short" = "long") => fmtYmd(addDays("2024-01-01", d - 1), l, { weekday: style });23const endMin = (s: string) => { const m = parseHhmm(s); return m === 0 ? 1440 : m; };2425function toDraft(rules: Rule[], scheduleId: string | null): Draft {26  const d: Draft = { 1: [], 2: [], 3: [], 4: [], 5: [], 6: [], 7: [] };27  for (const r of rules.filter((x) => x.eventTypeId === scheduleId).sort((a, b) => a.startMinute - b.startMinute)) d[r.weekday].push({ start: hhmm(r.startMinute), end: hhmm(r.endMinute) });28  return d;29}3031function dayError(ranges: Range[]): "order" | "overlap" | "empty" | null {32  const mins: [number, number][] = [];33  for (const r of ranges) {34    if (!r.start || !r.end) return "empty";35    const s = parseHhmm(r.start), e = endMin(r.end);36    if (e <= s) return "order";37    mins.push([s, e]);38  }39  mins.sort((a, b) => a[0] - b[0]);40  for (let k = 1; k < mins.length; k++) if (mins[k][0] < mins[k - 1][1]) return "overlap";41  return null;42}4344export function AvailabilityScreen() {45  const t = useT(adminConfig);46  const locale = useLocale();47  const state = useLoad<AvailabilityData>("/api/v1/admin/availability");48  const types = useLoad<{ types: TypeLite[] }>("/api/v1/admin/event-types");49  const [scheduleId, setScheduleId] = useState<string | null>(null);50  const [draft, setDraft] = useState<Draft | null>(null);51  const [dirty, setDirty] = useState(false);52  const [saving, setSaving] = useState(false);53  const [adding, setAdding] = useState(false);54  const [toDelete, setToDelete] = useState<Exc | null>(null);55  const [deleting, setDeleting] = useState(false);5657  const rules = state.data?.rules;58  useEffect(() => { if (rules) { setDraft(toDraft(rules, scheduleId)); setDirty(false); } }, [rules, scheduleId]);5960  const customTypes = (types.data?.types ?? []).filter((x) => x.custom_availability);61  const typeTitle = (id: string | null) => (id ? types.data?.types.find((x) => x.id === id)?.title ?? "—" : null);62  const tz = state.data?.timezone ?? "";6364  const change = (day: number, ranges: Range[]) => { setDraft((d) => ({ ...(d as Draft), [day]: ranges })); setDirty(true); };65  const errors = useMemo(() => Object.fromEntries(DAYS.map((d) => [d, draft ? dayError(draft[d]) : null])) as Record<number, ReturnType<typeof dayError>>, [draft]);66  const hasError = DAYS.some((d) => errors[d]);6768  const save = async () => {69    if (!draft || hasError) return;70    setSaving(true);71    try {72      const out = DAYS.flatMap((d) => draft[d].map((r) => ({ weekday: d, startMinute: parseHhmm(r.start), endMinute: endMin(r.end) })));73      await api("/api/v1/admin/availability/rules", { method: "PUT", body: { eventTypeId: scheduleId, rules: out } });74      toast.ok(t("avSaved"));75      await state.reload();76    } catch (e) { toast.error(errorText(e, locale)); } finally { setSaving(false); }77  };78  const selectSchedule = (v: string) => {79    if (dirty && !window.confirm(t("avDiscardConfirm"))) return;80    setScheduleId(v || null);81  };82  const remove = async () => {83    if (!toDelete) return;84    setDeleting(true);85    try { await api(`/api/v1/admin/availability/exceptions/${toDelete.id}`, { method: "DELETE" }); toast.ok(t("avExcDeleted")); setToDelete(null); await state.reload(); }86    catch (e) { toast.error(errorText(e, locale)); } finally { setDeleting(false); }87  };8889  const nowLocal = tz ? `${ymdInTz(new Date(), tz)}T${hhmm(minutesInTz(new Date(), tz))}` : "";90  const isPast = (e: Exc) => (e.mode === "range" ? (e.endsLocal ?? "") <= nowLocal : !!e.validUntil && e.validUntil < nowLocal.slice(0, 10));91  const upcoming = (state.data?.exceptions ?? []).filter((e) => !isPast(e));92  const past = (state.data?.exceptions ?? []).filter(isPast);9394  const describe = (e: Exc): string => {95    const date = (ymd: string) => fmtYmd(ymd, locale, { weekday: "short", day: "numeric", month: "short", year: "numeric" });96    if (e.mode === "range" && e.startsLocal && e.endsLocal) {97      const d0 = e.startsLocal.slice(0, 10), d1 = e.endsLocal.slice(0, 10);98      if (e.allDay) {99        const last = e.endsLocal.slice(11, 16) === "00:00" ? addDays(d1, -1) : d1;100        return last <= d0 ? t("avDescDay", { day: date(d0) }) : t("avDescDays", { from: date(d0), to: date(last) });101      }102      const t0 = minutesLabel(parseHhmm(e.startsLocal.slice(11, 16)), locale), t1 = minutesLabel(parseHhmm(e.endsLocal.slice(11, 16)), locale);103      return d0 === d1 ? t("avDescHours", { day: date(d0), from: t0, to: t1 }) : t("avDescSpan", { from: `${date(d0)}, ${t0}`, to: `${date(d1)}, ${t1}` });104    }105    const days = (e.weekdays ?? []).slice().sort().map((d) => dayName(d, locale, "short")).join(", ");106    const base = t("avDescWeekly", { days, time: minutesLabel(e.startMinute ?? 0, locale), dur: e.durationMin ?? 0 });107    const from = e.validFrom ? t("avDescFrom", { date: date(e.validFrom) }) : "";108    const until = e.validUntil ? t("avDescUntil", { date: date(e.validUntil) }) : "";109    return [base, from, until].filter(Boolean).join(" · ");110  };111  const row = (e: Exc) => (112    <li key={e.id} className="flex items-start justify-between gap-3 py-3 border-t border-line first:border-t-0">113      <div className="min-w-0">114        <div className="flex flex-wrap items-center gap-2 mb-1">115          <Badge tone={e.kind === "closed" ? "danger" : "ok"}>{e.kind === "closed" ? t("avClosed") : t("avOpenExc")}</Badge>116          <Badge>{e.eventTypeId ? typeTitle(e.eventTypeId) : t("avAllTypes")}</Badge>117          {e.mode === "weekly" ? <Badge tone="info">{t("avRecurring")}</Badge> : null}118        </div>119        <p className="font-medium">{describe(e)}</p>120        {e.note ? <p className="t-mute text-sm mt-0.5 break-words">{e.note}</p> : null}121      </div>122      <Button small variant="ghost" icon="trash" aria-label={`${t("cDelete")} — ${describe(e)}`} onClick={() => setToDelete(e)} />123    </li>124  );125126  return (127    <>128      <PageHeader title={t("avTitle")} lead={t("avLead")} />129      <Loadable state={state}>130        <div className="grid gap-5">131          <Section title={t("avWeeklyTitle")} lead={t("avWeeklyLead", { tz })}132            actions={<>{dirty ? <Badge tone="pending">{t("cUnsaved")}</Badge> : null}<Button variant="primary" small busy={saving} disabled={!dirty || hasError} onClick={save}>{t("cSave")}</Button></>}>133            <div className="max-w-sm mb-4">134              <Field label={t("avSchedule")} hint={customTypes.length ? undefined : t("avScheduleHint")}>135                {(p) => (136                  <select {...p} className="input" value={scheduleId ?? ""} onChange={(e) => selectSchedule(e.target.value)}>137                    <option value="">{t("avDefaultSchedule")}</option>138                    {customTypes.map((x) => <option key={x.id} value={x.id}>{x.title}</option>)}139                  </select>140                )}141              </Field>142            </div>143            {draft ? (144              <ul>145                {DAYS.map((d) => {146                  const ranges = draft[d], name = dayName(d, locale), err = errors[d];147                  return (148                    <li key={d} className="py-3 border-t border-line first:border-t-0 grid gap-2 sm:grid-cols-[190px_1fr] sm:gap-4">149                      <Toggle checked={ranges.length > 0} label={name.charAt(0).toUpperCase() + name.slice(1)} hint={ranges.length ? undefined : t("avDayClosed")}150                        onChange={(v) => change(d, v ? [{ start: "09:00", end: "17:00" }] : [])} />151                      {ranges.length ? (152                        <div className="grid gap-2">153                          {ranges.map((r, k) => (154                            <div key={k} className="flex items-center gap-2">155                              <input type="time" className="input !px-3 flex-1 min-w-0 sm:flex-none sm:!w-[140px]" value={r.start} aria-label={`${name} — ${t("avStart")} ${k + 1}`} aria-invalid={err ? true : undefined}156                                onChange={(e) => change(d, ranges.map((x, i) => (i === k ? { ...x, start: e.target.value } : x)))} />157                              <span className="t-mute" aria-hidden>–</span>158                              <input type="time" className="input !px-3 flex-1 min-w-0 sm:flex-none sm:!w-[140px]" value={r.end} aria-label={`${name} — ${t("avEnd")} ${k + 1}`} aria-invalid={err ? true : undefined}159                                onChange={(e) => change(d, ranges.map((x, i) => (i === k ? { ...x, end: e.target.value } : x)))} />160                              <Button small variant="ghost" icon="x" aria-label={`${t("avRemoveRange")} — ${name} ${k + 1}`} onClick={() => change(d, ranges.filter((_, i) => i !== k))} />161                            </div>162                          ))}163                          {err ? <p className="error-text !mt-0" role="alert">{err === "order" ? t("avErrOrder") : err === "overlap" ? t("avErrOverlap") : t("avErrEmpty")}</p> : null}164                          <div className="flex flex-wrap gap-2">165                            <Button small variant="ghost" icon="plus" onClick={() => { const last = ranges[ranges.length - 1]; const s = Math.min(endMin(last.end) + 60, 1380); change(d, [...ranges, { start: hhmm(s), end: hhmm(Math.min(s + 60, 1439)) }]); }}>{t("avAddRange")}</Button>166                            {d <= 5 ? <Button small variant="ghost" onClick={() => { setDraft((cur) => { const n = { ...(cur as Draft) }; for (const o of [1, 2, 3, 4, 5]) if (o !== d) n[o] = ranges.map((x) => ({ ...x })); return n; }); setDirty(true); }}>{t("avCopyWeekdays")}</Button> : null}167                          </div>168                        </div>169                      ) : <span className="hidden sm:block" />}170                    </li>171                  );172                })}173              </ul>174            ) : null}175            <p className="hint">{t("avMidnightHint")}</p>176          </Section>177178          <Section title={t("avExcTitle")} lead={t("avExcLead", { tz })} actions={<Button small variant="primary" icon="plus" onClick={() => setAdding(true)}>{t("cAdd")}</Button>}>179            {upcoming.length ? <ul>{upcoming.map(row)}</ul> : <Empty icon="block" title={t("avExcEmpty")}>{t("avExcEmptyLead")}</Empty>}180            {past.length ? (181              <details className="mt-3 border-t border-line pt-3">182                <summary className="cursor-pointer t-mute font-medium min-h-[44px] flex items-center">{t("avPast", { n: past.length })}</summary>183                <ul className={cx("opacity-80")}>{past.map(row)}</ul>184              </details>185            ) : null}186          </Section>187        </div>188      </Loadable>189      {adding && tz ? <ExceptionSheet tz={tz} types={types.data?.types ?? []} onClose={() => setAdding(false)} onSaved={async () => { setAdding(false); await state.reload(); }} /> : null}190      <Confirm open={!!toDelete} onClose={() => setToDelete(null)} title={t("avExcDeleteTitle")} confirmLabel={t("cDelete")} cancelLabel={t("cCancel")} onConfirm={remove} busy={deleting}>191        <p>{toDelete ? describe(toDelete) : ""}</p>192        <p className="t-mute text-sm">{t("avExcDeleteLead")}</p>193      </Confirm>194    </>195  );196}197198type Preset = "hours" | "days" | "weekly" | "open";199200function ExceptionSheet({ tz, types, onClose, onSaved }: { tz: string; types: TypeLite[]; onClose: () => void; onSaved: () => void }) {201  const t = useT(adminConfig);202  const locale = useLocale();203  const today = ymdInTz(new Date(), tz);204  const [preset, setPreset] = useState<Preset>("hours");205  const [startDate, setStartDate] = useState(today);206  const [startTime, setStartTime] = useState("10:00");207  const [endDate, setEndDate] = useState(today);208  const [endTime, setEndTime] = useState("11:00");209  const [lastDay, setLastDay] = useState(today);210  const [weekdays, setWeekdays] = useState<number[]>([]);211  const [duration, setDuration] = useState<number | "">(60);212  const [validUntil, setValidUntil] = useState("");213  const [scope, setScope] = useState("");214  const [note, setNote] = useState("");215  const [error, setError] = useState<string | null>(null);216  const [busy, setBusy] = useState(false);217218  const submit = async (ev: React.FormEvent) => {219    ev.preventDefault(); setError(null);220    const common = { eventTypeId: scope || null, note };221    let payload: Record<string, unknown>;222    if (preset === "hours" || preset === "open") {223      const s = `${startDate}T${startTime}`, e = `${endDate}T${endTime}`;224      if (!startDate || !endDate || !startTime || !endTime) return setError(t("avErrEmpty"));225      if (e <= s) return setError(t("avErrRange"));226      payload = { mode: "range", kind: preset === "open" ? "open" : "closed", startsLocal: s, endsLocal: e, allDay: false, ...common };227    } else if (preset === "days") {228      if (!startDate || !lastDay) return setError(t("avErrEmpty"));229      if (lastDay < startDate) return setError(t("avErrDays"));230      payload = { mode: "range", kind: "closed", startsLocal: `${startDate}T00:00`, endsLocal: `${addDays(lastDay, 1)}T00:00`, allDay: true, ...common };231    } else {232      if (!weekdays.length) return setError(t("avErrWeekdays"));233      if (duration === "" || duration < 1 || duration > 1440) return setError(t("avErrDuration"));234      if (!startDate || !startTime) return setError(t("avErrEmpty"));235      if (validUntil && validUntil < startDate) return setError(t("avErrDays"));236      payload = { mode: "weekly", kind: "closed", weekdays: weekdays.slice().sort(), startMinute: parseHhmm(startTime), durationMin: duration, validFrom: startDate, validUntil: validUntil || null, ...common };237    }238    setBusy(true);239    try { await api("/api/v1/admin/availability/exceptions", { body: payload }); toast.ok(t("avExcSaved")); onSaved(); }240    catch (e) { setError(errorText(e, locale)); setBusy(false); }241  };242  const presets: { id: Preset; label: string; hint: string }[] = [243    { id: "hours", label: t("avPresetHours"), hint: t("avPresetHoursHint") },244    { id: "days", label: t("avPresetDays"), hint: t("avPresetDaysHint") },245    { id: "weekly", label: t("avPresetWeekly"), hint: t("avPresetWeeklyHint") },246    { id: "open", label: t("avPresetOpen"), hint: t("avPresetOpenHint") },247  ];248  return (249    <Sheet open onClose={onClose} title={t("avExcAddTitle")} wide250      footer={<><Button onClick={onClose}>{t("cCancel")}</Button><Button variant="primary" busy={busy} type="submit" form="exc-form">{t("cAdd")}</Button></>}>251      <form id="exc-form" onSubmit={submit} className="grid gap-4">252        <fieldset>253          <legend className="label">{t("avPresetLegend")}</legend>254          <div className="grid sm:grid-cols-2 gap-2">255            {presets.map((p) => (256              <label key={p.id} className={cx("flex gap-3 items-start p-3 rounded-2xl border cursor-pointer min-h-[44px]", preset === p.id ? "border-blue bg-blue-wash" : "border-line-strong")}>257                <input type="radio" name="preset" className="mt-1" checked={preset === p.id} onChange={() => { setPreset(p.id); setError(null); }} />258                <span><span className="block font-medium">{p.label}</span><span className="block text-[13px] t-mute">{p.hint}</span></span>259              </label>260            ))}261          </div>262        </fieldset>263        {preset === "hours" || preset === "open" ? (264          <div className="grid grid-cols-2 gap-3">265            <Field label={t("avStartDate")}>{(p) => <input {...p} type="date" className="input" value={startDate} onChange={(e) => { setStartDate(e.target.value); if (endDate < e.target.value) setEndDate(e.target.value); }} required />}</Field>266            <Field label={t("avStartTime")}>{(p) => <input {...p} type="time" className="input" value={startTime} onChange={(e) => setStartTime(e.target.value)} required />}</Field>267            <Field label={t("avEndDate")}>{(p) => <input {...p} type="date" className="input" value={endDate} min={startDate} onChange={(e) => setEndDate(e.target.value)} required />}</Field>268            <Field label={t("avEndTime")}>{(p) => <input {...p} type="time" className="input" value={endTime} onChange={(e) => setEndTime(e.target.value)} required />}</Field>269          </div>270        ) : preset === "days" ? (271          <div className="grid grid-cols-2 gap-3">272            <Field label={t("avFirstDay")}>{(p) => <input {...p} type="date" className="input" value={startDate} onChange={(e) => { setStartDate(e.target.value); if (lastDay < e.target.value) setLastDay(e.target.value); }} required />}</Field>273            <Field label={t("avLastDay")} hint={t("avLastDayHint")}>{(p) => <input {...p} type="date" className="input" value={lastDay} min={startDate} onChange={(e) => setLastDay(e.target.value)} required />}</Field>274          </div>275        ) : (276          <>277            <fieldset>278              <legend className="label">{t("avWeekdays")}</legend>279              <div className="flex flex-wrap gap-2">280                {DAYS.map((d) => {281                  const on = weekdays.includes(d);282                  return <button key={d} type="button" aria-pressed={on} onClick={() => setWeekdays(on ? weekdays.filter((x) => x !== d) : [...weekdays, d])}283                    className={cx("pill !min-h-[44px] min-w-[52px] justify-center")}><span aria-hidden>{dayName(d, locale, "short")}</span><span className="sr-only">{dayName(d, locale)}</span></button>;284                })}285              </div>286            </fieldset>287            <div className="grid grid-cols-2 gap-3">288              <Field label={t("avStartTime")}>{(p) => <input {...p} type="time" className="input" value={startTime} onChange={(e) => setStartTime(e.target.value)} required />}</Field>289              <Field label={t("avDuration")} hint={t("avDurationHint")}>{(p) => <input {...p} type="number" inputMode="numeric" className="input" min={1} max={1440} value={duration} onChange={(e) => setDuration(e.target.value === "" ? "" : Number(e.target.value))} required />}</Field>290              <Field label={t("avValidFrom")}>{(p) => <input {...p} type="date" className="input" value={startDate} onChange={(e) => setStartDate(e.target.value)} required />}</Field>291              <Field label={t("avValidUntil")} optional={t("cOptional")}>{(p) => <input {...p} type="date" className="input" value={validUntil} min={startDate} onChange={(e) => setValidUntil(e.target.value)} />}</Field>292            </div>293          </>294        )}295        <Field label={t("avScope")}>296          {(p) => (297            <select {...p} className="input" value={scope} onChange={(e) => setScope(e.target.value)}>298              <option value="">{t("avAllTypes")}</option>299              {types.map((x) => <option key={x.id} value={x.id}>{x.title}</option>)}300            </select>301          )}302        </Field>303        <Field label={t("avNote")} optional={t("cOptional")} hint={t("avNoteHint")}>{(p) => <input {...p} className="input" value={note} maxLength={200} onChange={(e) => setNote(e.target.value)} />}</Field>304        {error ? <Notice tone="danger">{error}</Notice> : null}305        <p className="hint !mt-0">{t("avExcLead", { tz })}</p>306      </form>307    </Sheet>308  );309}310
Files main
Menu

Theme