SPB Git

Repository Public

block-sheet.tsx 6.1 KB 100 lines tsx

1"use client";2import { useState } from "react";3import { useLocale, useT } from "@/i18n";4import { adminAgenda } from "@/i18n/admin-agenda";5import { api } from "@/lib/client-api";6import { Button, Field, Notice, Sheet, toast } from "@/components/ui";7import { useAdmin } from "@/components/admin/shell";8import { fmtDateTime, hhmm, parseHhmm } from "@/lib/format";9import { useErr } from "@/components/admin/requests/common";10import { zonedToIso } from "./zoned";1112export interface BlockDraft { id?: string; startYmd: string; startMin: number; endYmd: string; endMin: number; title: string; note: string }13interface Overlap { id: string; title: string; startsAt: string }1415const isYmd = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s);1617/** Create or edit a private event / blocked period. Times are typed in the owner's zone. */18export function BlockForm({ draft, onCancel, onSaved }: { draft: BlockDraft; onCancel: () => void; onSaved: (closed: boolean) => void }) {19  const t = useT(adminAgenda);20  const err = useErr();21  const { timezone } = useAdmin();22  const locale = useLocale();23  const [startYmd, setStartYmd] = useState(draft.startYmd);24  const [startT, setStartT] = useState(hhmm(draft.startMin));25  const [endYmd, setEndYmd] = useState(draft.endYmd);26  const [endT, setEndT] = useState(hhmm(draft.endMin));27  const [title, setTitle] = useState(draft.title);28  const [note, setNote] = useState(draft.note);29  const [busy, setBusy] = useState<"save" | "delete" | null>(null);30  const [error, setError] = useState<string | null>(null);31  const [overlaps, setOverlaps] = useState<Overlap[] | null>(null);32  const [confirmDelete, setConfirmDelete] = useState(false);3334  const valid = isYmd(startYmd) && isYmd(endYmd) && !!startT && !!endT;35  const startIso = valid ? zonedToIso(startYmd, parseHhmm(startT), timezone) : null;36  const endIso = valid ? zonedToIso(endYmd, parseHhmm(endT), timezone) : null;37  const ordered = !!startIso && !!endIso && endIso > startIso;3839  const save = async (ev: React.FormEvent) => {40    ev.preventDefault();41    if (!ordered) { setError(t("blockOrder")); return; }42    setBusy("save"); setError(null);43    try {44      if (draft.id) { await api(`/api/v1/admin/busy-blocks/${draft.id}`, { method: "PATCH", body: { start: startIso, end: endIso, title, note } }); toast.ok(t("blockSaved")); onSaved(true); }45      else {46        const res = await api<{ overlaps: Overlap[] }>("/api/v1/admin/busy-blocks", { body: { start: startIso, end: endIso, title, note } });47        toast.ok(t("blockCreated"));48        if (res.overlaps.length) { setOverlaps(res.overlaps); onSaved(false); } else onSaved(true);49      }50    } catch (e) { setError(err(e)); } finally { setBusy(null); }51  };52  const remove = async () => {53    setBusy("delete"); setError(null);54    try { await api(`/api/v1/admin/busy-blocks/${draft.id}`, { method: "DELETE" }); toast.ok(t("blockDeleted")); onSaved(true); }55    catch (e) { setError(err(e)); } finally { setBusy(null); }56  };5758  if (overlaps) return (59    <div className="grid gap-3">60      <Notice tone="warn">61        <p className="font-semibold">{t("overlapTitle", { n: overlaps.length })}</p>62        <p className="text-sm">{t("overlapBody")}</p>63        <ul className="mt-2 grid gap-1 text-sm">{overlaps.map((o) => <li key={o.id}>{o.title} — {fmtDateTime(o.startsAt, timezone, locale)}</li>)}</ul>64      </Notice>65      <div className="flex justify-end gap-2"><a className="btn btn-secondary" href="/admin/agenda">{t("openAgenda")}</a><Button variant="primary" onClick={onCancel}>{t("close")}</Button></div>66    </div>67  );68  return (69    <form onSubmit={save} className="grid gap-4">70      <p className="t-mute text-sm">{t("blockLead", { zone: timezone })}</p>71      <div className="grid grid-cols-2 gap-3">72        <Field label={t("startDate")}>{(p) => <input {...p} type="date" className="input" required value={startYmd} onChange={(e) => { setStartYmd(e.target.value); if (endYmd < e.target.value) setEndYmd(e.target.value); }} />}</Field>73        <Field label={t("startTime")}>{(p) => <input {...p} type="time" step={300} className="input" required value={startT} onChange={(e) => setStartT(e.target.value)} />}</Field>74        <Field label={t("endDate")}>{(p) => <input {...p} type="date" className="input" required value={endYmd} min={startYmd} onChange={(e) => setEndYmd(e.target.value)} />}</Field>75        <Field label={t("endTime")}>{(p) => <input {...p} type="time" step={300} className="input" required value={endT} onChange={(e) => setEndT(e.target.value)} />}</Field>76      </div>77      <Field label={t("blockTitle")} optional={t("optional")} hint={t("blockTitleHint")}>{(p) => <input {...p} className="input" maxLength={120} value={title} onChange={(e) => setTitle(e.target.value)} />}</Field>78      <Field label={t("blockNote")} optional={t("optional")}>{(p) => <textarea {...p} className="input" maxLength={1000} value={note} onChange={(e) => setNote(e.target.value)} />}</Field>79      {error ? <Notice tone="danger">{error}</Notice> : null}80      {confirmDelete ? (81        <Notice tone="warn"><p>{t("blockDeleteConfirm")}</p><div className="flex gap-2 mt-2"><Button small onClick={() => setConfirmDelete(false)}>{t("cancel")}</Button><Button small variant="danger" busy={busy === "delete"} onClick={remove}>{t("delete")}</Button></div></Notice>82      ) : (83        <div className="flex flex-wrap justify-between gap-2">84          <span>{draft.id ? <Button icon="trash" disabled={!!busy} onClick={() => setConfirmDelete(true)}>{t("delete")}</Button> : null}</span>85          <span className="flex gap-2"><Button onClick={onCancel} disabled={!!busy}>{t("cancel")}</Button><Button type="submit" variant="primary" busy={busy === "save"}>{draft.id ? t("save") : t("blockCreate")}</Button></span>86        </div>87      )}88    </form>89  );90}9192export function BlockSheet({ draft, onClose, onSaved }: { draft: BlockDraft; onClose: () => void; onSaved: () => void }) {93  const t = useT(adminAgenda);94  return (95    <Sheet open onClose={onClose} title={draft.id ? t("blockEdit") : t("blockNew")}>96      <BlockForm draft={draft} onCancel={onClose} onSaved={(closed) => { onSaved(); if (closed) onClose(); }} />97    </Sheet>98  );99}100
Files main
Menu

Theme