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 { 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