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