SPB Git

Repository Public

sheets.tsx 14.0 KB 199 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 { Badge, Button, cx, Field, Notice, Sheet, toast } from "@/components/ui";7import { Loadable, useAdmin, useLoad } from "@/components/admin/shell";8import { fmtDateTime, hhmm, parseHhmm, ymdInTz } from "@/lib/format";9import { HistoryList, SlotPicker, TypeDot, useErr, When } from "@/components/admin/requests/common";10import type { Bk, Block, EventTypeLite, HistoryItem, MailItem } from "@/components/admin/requests/types";11import { BlockForm, type BlockDraft } from "./block-sheet";12import { zonedToIso } from "./zoned";1314type LocKey = "loc_video" | "loc_phone" | "loc_in_person";15type MailKey = "ms_sent" | "ms_queued" | "ms_sending" | "ms_failed" | "ms_dead" | "ms_unconfigured" | "ms_cancelled";1617// ── New: private event or appointment for someone ───────────────────────────────────────────────────18export function NewSheet({ draft, types, onClose, onSaved }: { draft: BlockDraft; types: EventTypeLite[]; onClose: () => void; onSaved: () => void }) {19  const t = useT(adminAgenda);20  const [mode, setMode] = useState<"block" | "booking">("block");21  return (22    <Sheet open onClose={onClose} title={t("newTitle")}>23      <div className="seg w-full mb-4" role="group" aria-label={t("newKind")}>24        <button type="button" aria-pressed={mode === "block"} onClick={() => setMode("block")} className="seg-item">{t("newBlock")}</button>25        <button type="button" aria-pressed={mode === "booking"} onClick={() => setMode("booking")} className="seg-item">{t("newBooking")}</button>26      </div>27      {mode === "block"28        ? <BlockForm draft={draft} onCancel={onClose} onSaved={(closed) => { onSaved(); if (closed) onClose(); }} />29        : <BookingForm draft={draft} types={types} onCancel={onClose} onSaved={() => { onSaved(); onClose(); }} />}30    </Sheet>31  );32}3334function BookingForm({ draft, types, onCancel, onSaved }: { draft: BlockDraft; types: EventTypeLite[]; onCancel: () => void; onSaved: () => void }) {35  const t = useT(adminAgenda);36  const err = useErr();37  const { timezone } = useAdmin();38  const usable = types.filter((x) => x.active);39  const [typeId, setTypeId] = useState(usable[0]?.id ?? "");40  const [ymd, setYmd] = useState(draft.startYmd);41  const [time, setTime] = useState(hhmm(draft.startMin));42  const [name, setName] = useState("");43  const [email, setEmail] = useState("");44  const [notify, setNotify] = useState(true);45  const [busy, setBusy] = useState(false);46  const [error, setError] = useState<string | null>(null);47  const type = usable.find((x) => x.id === typeId);48  const submit = async (ev: React.FormEvent) => {49    ev.preventDefault(); setBusy(true); setError(null);50    try {51      await api("/api/v1/admin/bookings", { body: { eventTypeId: typeId, start: zonedToIso(ymd, parseHhmm(time), timezone), name, email, notify } });52      toast.ok(t("bookingCreated")); onSaved();53    } catch (e) { setError(err(e)); } finally { setBusy(false); }54  };55  if (!usable.length) return <Notice tone="warn">{t("noTypes")} <a className="underline font-semibold" href="/admin/types">{t("navTypesLink")}</a></Notice>;56  return (57    <form onSubmit={submit} className="grid gap-4">58      <p className="t-mute text-sm">{t("bookingLead")}</p>59      <Field label={t("type")} hint={type ? t("typeDuration", { n: type.duration_min }) : undefined}>{(p) => <select {...p} className="input" value={typeId} onChange={(e) => setTypeId(e.target.value)}>{usable.map((x) => <option key={x.id} value={x.id}>{x.title}</option>)}</select>}</Field>60      <div className="grid grid-cols-2 gap-3">61        <Field label={t("date")}>{(p) => <input {...p} type="date" className="input" required value={ymd} onChange={(e) => setYmd(e.target.value)} />}</Field>62        <Field label={t("startTime")} hint={timezone}>{(p) => <input {...p} type="time" step={300} className="input" required value={time} onChange={(e) => setTime(e.target.value)} />}</Field>63      </div>64      <Field label={t("personName")}>{(p) => <input {...p} className="input" required minLength={2} maxLength={120} autoComplete="off" value={name} onChange={(e) => setName(e.target.value)} />}</Field>65      <Field label={t("personEmail")}>{(p) => <input {...p} type="email" className="input" required maxLength={254} autoComplete="off" value={email} onChange={(e) => setEmail(e.target.value)} />}</Field>66      <label className="flex items-start gap-3 cursor-pointer min-h-[44px]"><input type="checkbox" className="w-5 h-5 mt-0.5" checked={notify} onChange={(e) => setNotify(e.target.checked)} /><span>{t("notifyPerson")}<span className="block t-mute text-sm">{t("notifyPersonHint")}</span></span></label>67      {error ? <Notice tone="danger">{error}</Notice> : null}68      <div className="flex justify-end gap-2"><Button onClick={onCancel} disabled={busy}>{t("cancel")}</Button><Button type="submit" variant="primary" busy={busy}>{t("bookingCreate")}</Button></div>69    </form>70  );71}7273// ── Booking detail, move, cancel ────────────────────────────────────────────────────────────────────74export function BookingSheet({ id, onClose, onChanged }: { id: string; onClose: () => void; onChanged: () => void }) {75  const t = useT(adminAgenda);76  const err = useErr();77  const locale = useLocale();78  const { timezone } = useAdmin();79  const state = useLoad<{ booking: Bk; history: HistoryItem[]; notifications: MailItem[] }>(`/api/v1/admin/bookings/${id}`);80  const [mode, setMode] = useState<"view" | "move" | "cancel">("view");81  const [start, setStart] = useState<string | null>(null);82  const [reason, setReason] = useState("");83  const [busy, setBusy] = useState(false);84  const [error, setError] = useState<string | null>(null);85  const b = state.data?.booking;86  const [openedAt] = useState(() => Date.now());87  const future = !!b && new Date(b.startsAt).getTime() > openedAt;8889  const doMove = async () => {90    if (!start) return;91    setBusy(true); setError(null);92    try { await api(`/api/v1/admin/bookings/${id}/reschedule`, { body: { start } }); toast.ok(t("movedOk")); onChanged(); onClose(); }93    catch (e) { setError(err(e)); } finally { setBusy(false); }94  };95  const doCancel = async () => {96    setBusy(true); setError(null);97    try { await api(`/api/v1/admin/bookings/${id}/cancel`, { body: { reason } }); toast.ok(t("cancelledOk")); onChanged(); onClose(); }98    catch (e) { setError(err(e)); } finally { setBusy(false); }99  };100  const footer = !b || b.status !== "confirmed" ? <Button onClick={onClose}>{t("close")}</Button>101    : mode === "view" ? <><Button icon="clock" onClick={() => { setMode("move"); setError(null); }}>{t("move")}</Button><Button icon="x" onClick={() => { setMode("cancel"); setError(null); }}>{t("cancelBooking")}</Button><Button variant="primary" onClick={onClose}>{t("close")}</Button></>102    : mode === "move" ? <><Button onClick={() => setMode("view")} disabled={busy}>{t("back")}</Button><Button variant="primary" busy={busy} disabled={!start} onClick={doMove}>{start ? t("moveTo", { time: fmtDateTime(start, timezone, locale) }) : t("moveToNone")}</Button></>103    : <><Button onClick={() => setMode("view")} disabled={busy}>{t("back")}</Button><Button variant="danger" busy={busy} onClick={doCancel}>{t("cancelConfirm")}</Button></>;104105  return (106    <Sheet open onClose={onClose} title={mode === "move" ? t("moveTitle") : mode === "cancel" ? t("cancelTitle") : t("bookingTitle")} wide footer={footer}>107      <Loadable state={state}>108        {b ? (109          <div className="grid gap-4">110            <div>111              <p className="flex flex-wrap items-center gap-2"><span className="t-headline">{b.contact?.name ?? b.title}</span><Badge tone={b.status === "confirmed" ? "ok" : "danger"}>{b.status === "confirmed" ? t("st_confirmed") : t("st_cancelled")}</Badge></p>112              {b.contact ? <p className="text-sm break-all"><a className="underline" href={`mailto:${b.contact.email}`}>{b.contact.email}</a> · <a className="underline" href="/admin/contacts">{t("contactCard")}</a></p> : null}113              <p className="mt-2 flex items-center gap-2 font-medium"><TypeDot color={b.type?.color ?? "var(--mute)"} />{b.type?.title ?? b.title}</p>114              <p className={cx("text-[16px]", b.status === "cancelled" && "line-through")}><strong><When start={b.startsAt} end={b.endsAt} /></strong></p>115              <p className="t-mute text-sm">{t(`loc_${b.locationKind}` as LocKey)}{b.locationText ? ` · ${b.locationText}` : ""}</p>116            </div>117            {mode === "view" ? (118              <>119                {b.status === "cancelled" ? <Notice tone="danger">{b.cancelledBy === "visitor" ? t("cancelledByVisitor") : t("cancelledByAdmin")}{b.cancelledAt ? ` — ${fmtDateTime(b.cancelledAt, timezone, locale)}` : ""}{b.cancelReason ? ` · « ${b.cancelReason} »` : ""}</Notice> : null}120                {b.status === "confirmed" && !future ? <Notice tone="info">{t("bookingPast")}</Notice> : null}121                {b.reason ? <div><p className="t-eyebrow">{t("reason")}</p><p className="whitespace-pre-wrap break-words">{b.reason}</p></div> : null}122                {b.internalNote ? <div><p className="t-eyebrow">{t("internalNote")}</p><p className="whitespace-pre-wrap break-words">{b.internalNote}</p></div> : null}123                <div>124                  <p className="t-eyebrow mb-2">{t("mailsTitle")}</p>125                  {state.data!.notifications.length === 0 ? <p className="t-mute text-sm">{t("mailsEmpty")}</p> : (126                    <ul className="grid gap-1.5 text-sm">127                      {state.data!.notifications.map((n, i) => (128                        <li key={i} className="flex flex-wrap items-center gap-2">129                          <Badge tone={n.status === "sent" ? "ok" : n.status === "dead" || n.status === "failed" ? "danger" : n.status === "unconfigured" ? "pending" : "mute"}>{t(`ms_${n.status}` as MailKey)}</Badge>130                          <span>{n.kind.replaceAll("_", " ")}</span>131                          {n.sentAt ? <span className="t-mute">{fmtDateTime(n.sentAt, timezone, locale)}</span> : null}132                          {n.error && n.status !== "sent" ? <span className="text-danger break-all">{n.error}</span> : null}133                        </li>134                      ))}135                    </ul>136                  )}137                </div>138                <div><p className="t-eyebrow mb-2">{t("history")}</p><HistoryList items={state.data!.history} /></div>139              </>140            ) : mode === "move" ? (141              <>142                <p className="t-mute">{t("moveLead")}</p>143                <SlotPicker eventTypeId={b.type?.id ?? null} ignoreBookingId={b.id} value={start} onChange={setStart} initialDate={ymdInTz(b.startsAt, timezone)} allowCustom />144              </>145            ) : (146              <>147                <Notice tone="warn">{b.contact ? t("cancelLead") : t("cancelLeadNoContact")}</Notice>148                <Field label={t("cancelReason")} optional={t("optional")} hint={t("cancelReasonHint")}>{(p) => <textarea {...p} className="input" maxLength={500} value={reason} onChange={(e) => setReason(e.target.value)} />}</Field>149              </>150            )}151            {error ? <Notice tone="danger">{error}</Notice> : null}152          </div>153        ) : null}154      </Loadable>155    </Sheet>156  );157}158159// ── Drag-and-drop confirmation ──────────────────────────────────────────────────────────────────────160export function MoveConfirmSheet({ id, title, from, to, onClose, onChanged }: { id: string; title: string; from: string; to: string; onClose: () => void; onChanged: () => void }) {161  const t = useT(adminAgenda);162  const err = useErr();163  const [busy, setBusy] = useState(false);164  const [error, setError] = useState<string | null>(null);165  const go = async () => {166    setBusy(true); setError(null);167    try { await api(`/api/v1/admin/bookings/${id}/reschedule`, { body: { start: to } }); toast.ok(t("movedOk")); onChanged(); onClose(); }168    catch (e) { setError(err(e)); toast.error(err(e)); } finally { setBusy(false); }169  };170  return (171    <Sheet open onClose={onClose} title={t("moveTitle")} footer={<><Button onClick={onClose} disabled={busy}>{t("cancel")}</Button><Button variant="primary" busy={busy} onClick={go}>{t("moveConfirm")}</Button></>}>172      <div className="grid gap-3">173        <p className="t-headline">{title}</p>174        <dl className="grid gap-2">175          <div><dt className="t-eyebrow">{t("currentTime")}</dt><dd className="line-through t-mute"><When start={from} /></dd></div>176          <div><dt className="t-eyebrow">{t("newTime")}</dt><dd className="text-[16px] font-semibold"><When start={to} /></dd></div>177        </dl>178        <p className="t-mute text-sm">{t("moveNotice")}</p>179        {error ? <Notice tone="danger">{error}</Notice> : null}180      </div>181    </Sheet>182  );183}184185// ── External busy period (read-only) ────────────────────────────────────────────────────────────────186export function ExternalBlockSheet({ block, onClose }: { block: Block; onClose: () => void }) {187  const t = useT(adminAgenda);188  return (189    <Sheet open onClose={onClose} title={t("externalTitle")} footer={<Button variant="primary" onClick={onClose}>{t("close")}</Button>}>190      <div className="grid gap-3">191        <p className="text-[16px] font-semibold"><When start={block.startsAt} end={block.endsAt} /></p>192        <p>{t("externalFrom", { name: block.calendar ?? "—" })}</p>193        <Notice tone={block.blocksAvailability ? "info" : "warn"}>{block.blocksAvailability ? t("externalBlocks") : t("externalNoBlock")}</Notice>194        <p className="t-mute text-sm">{t("externalReadOnly")} <a className="underline" href="/admin/settings?tab=integrations">{t("alertOpen")}</a></p>195      </div>196    </Sheet>197  );198}199
Files main
Menu

Theme