Repository Public
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