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 { Avatar, Badge, Button, cx, Empty, Field, Icon, Notice, Sheet, Spinner, toast } from "@/components/ui";7import { Loadable, PageHeader, requestsChanged, useAdmin, useLoad } from "@/components/admin/shell";8import { fmtDateTime, fmtTime, tzAbbr, tzCity, ymdInTz } from "@/lib/format";9import { codeOf, HistoryList, RequestStatus, SlotPicker, TypeDot, useErr, When } from "./common";10import type { HistoryItem, Req } from "./types";1112const FILTERS = [13 { key: "open", q: "open", label: "fOpen" },14 { key: "all", q: "all", label: "fAll" },15 { key: "approved", q: "approved", label: "fApproved" },16 { key: "declined", q: "declined", label: "fDeclined" },17 { key: "closed", q: "expired,cancelled", label: "fClosed" },18] as const;1920export function RequestsInbox() {21 const t = useT(adminAgenda);22 const err = useErr();23 const [filter, setFilter] = useState<(typeof FILTERS)[number]>(FILTERS[0]);24 const state = useLoad<{ requests: Req[] }>(`/api/v1/admin/requests?filter=${filter.q}`);25 const [busy, setBusy] = useState<string | null>(null);26 const [selected, setSelected] = useState<Set<string>>(new Set());27 const [decline, setDecline] = useState<{ ids: string[]; name: string | null } | null>(null);28 const [propose, setPropose] = useState<{ r: Req; taken: boolean } | null>(null);29 const [outside, setOutside] = useState<Req | null>(null);3031 const changed = () => { requestsChanged(); setSelected(new Set()); void state.reload(); };32 const approve = async (r: Req, allowOutsideHours = false) => {33 setBusy(r.id);34 try {35 await api(`/api/v1/admin/requests/${r.id}/approve`, { body: { allowOutsideHours } });36 toast.ok(r.rescheduleOf ? t("movedOk") : t("approvedOk"));37 setOutside(null); changed();38 } catch (e) {39 const code = codeOf(e);40 if (code === "outside_hours" && !allowOutsideHours) setOutside(r);41 else if (code === "slot_taken") { setOutside(null); toast.error(t("takenToast")); setPropose({ r, taken: true }); void state.reload(); }42 else { toast.error(err(e)); void state.reload(); }43 } finally { setBusy(null); }44 };45 const pendingIds = (state.data?.requests ?? []).filter((r) => r.status === "pending").map((r) => r.id);46 const toggle = (id: string) => setSelected((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });4748 return (49 <>50 <PageHeader title={t("reqTitle")} lead={t("reqLead")} />51 <div className="scroll-x pb-1 mb-4 -mx-4 px-4 sm:mx-0 sm:px-0" role="group" aria-label={t("filterLabel")}>52 {FILTERS.map((f) => <button key={f.key} type="button" aria-pressed={filter.key === f.key} onClick={() => { setFilter(f); setSelected(new Set()); }} className="pill">{t(f.label)}</button>)}53 </div>54 {pendingIds.length > 1 ? (55 <div className="flex flex-wrap items-center gap-3 mb-3 min-h-[44px]">56 <label className="inline-flex items-center gap-2 text-sm cursor-pointer min-h-[44px]">57 <input type="checkbox" className="w-[22px] h-[22px]" checked={selected.size === pendingIds.length} onChange={(e) => setSelected(e.target.checked ? new Set(pendingIds) : new Set())} />{t("selectAll")}58 </label>59 {selected.size ? <Button small variant="danger" onClick={() => setDecline({ ids: [...selected], name: null })}>{t("declineSelection", { n: selected.size })}</Button> : null}60 </div>61 ) : null}62 <Loadable state={state}>63 {state.data && state.data.requests.length === 0 ? (64 <div className="card"><Empty icon="inbox" title={filter.key === "open" ? t("reqEmptyOpen") : t("reqEmpty")}>{filter.key === "open" ? t("reqEmptyOpenLead") : null}</Empty></div>65 ) : (66 <ul className={cx("grid gap-3.5 stagger transition-opacity", state.loading && "opacity-70")} aria-busy={state.loading}>67 {state.data?.requests.map((r, i) => (68 <li key={r.id} style={{ "--i": Math.min(i, 8) } as React.CSSProperties}>69 <RequestCard r={r} busy={busy === r.id} selectable={pendingIds.length > 1 && r.status === "pending"} selected={selected.has(r.id)} onSelect={() => toggle(r.id)}70 onApprove={() => approve(r)} onDecline={() => setDecline({ ids: [r.id], name: r.contact?.name ?? null })} onPropose={() => setPropose({ r, taken: r.conflict === "taken" })} />71 </li>72 ))}73 </ul>74 )}75 </Loadable>7677 {decline ? <DeclineSheet ids={decline.ids} name={decline.name} onClose={() => setDecline(null)} onDone={() => { setDecline(null); changed(); }} /> : null}78 {propose ? <ProposeSheet r={propose.r} taken={propose.taken} onClose={() => setPropose(null)} onDone={() => { setPropose(null); changed(); }} /> : null}79 <Sheet open={!!outside} onClose={() => setOutside(null)} title={t("outsideTitle")}80 footer={<><Button onClick={() => setOutside(null)}>{t("cancel")}</Button><Button variant="ok" busy={!!busy} onClick={() => outside && approve(outside, true)}>{t("outsideConfirm")}</Button></>}>81 {outside ? <div className="grid gap-3"><Notice tone="warn">{t("outsideBody")}</Notice><p><strong>{outside.contact?.name}</strong> — <When start={outside.startsAt} end={outside.endsAt} /></p></div> : null}82 </Sheet>83 </>84 );85}8687function RequestCard({ r, busy, selectable, selected, onSelect, onApprove, onDecline, onPropose }: {88 r: Req; busy: boolean; selectable: boolean; selected: boolean; onSelect: () => void; onApprove: () => void; onDecline: () => void; onPropose: () => void;89}) {90 const t = useT(adminAgenda);91 const err = useErr();92 const locale = useLocale();93 const { timezone } = useAdmin();94 const [note, setNote] = useState(r.internalNote);95 const [savedNote, setSavedNote] = useState(r.internalNote);96 const [saving, setSaving] = useState(false);97 const [history, setHistory] = useState<HistoryItem[] | null>(null);98 const [historyError, setHistoryError] = useState<string | null>(null);99 const otherZone = r.visitorTz !== timezone;100 const blocked = r.conflict === "taken" || r.conflict === "past";101 const saveNote = async () => {102 setSaving(true);103 try { await api(`/api/v1/admin/requests/${r.id}/note`, { body: { note } }); setSavedNote(note); toast.ok(t("noteSaved")); }104 catch (e) { toast.error(err(e)); } finally { setSaving(false); }105 };106 const loadHistory = async (open: boolean) => {107 if (!open || history) return;108 try { setHistory((await api<{ history: HistoryItem[] }>(`/api/v1/admin/requests/${r.id}`)).history); setHistoryError(null); }109 catch (e) { setHistoryError(err(e)); }110 };111 return (112 <article className={cx("card p-4 sm:p-5 relative overflow-hidden transition-shadow", selected && "!border-[var(--a1)] shadow-e2")} aria-label={`${r.contact?.name ?? ""} — ${r.type.title}`}>113 {r.status === "pending" || r.status === "approved" ? <span aria-hidden className={cx("absolute left-0 top-5 bottom-5 w-[4px] rounded-r-full", r.status === "pending" ? "bg-warn" : "bg-emerald")} /> : null}114 <div className="flex items-start gap-3">115 {selectable ? <input type="checkbox" className="absolute top-4 right-4 w-[22px] h-[22px]" checked={selected} onChange={onSelect} aria-label={t("selectOne", { name: r.contact?.name ?? "" })} /> : null}116 <Avatar name={r.contact?.name ?? "?"} size={44} seed />117 <div className={cx("min-w-0 flex-1", selectable && "pr-7")}>118 <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">119 <h2 className="t-headline !text-[18px]">{r.contact?.name ?? t("contactErased")}</h2>120 <RequestStatus status={r.status} />121 {r.rescheduleOf ? <Badge tone="info">{t("rescheduleBadge")}</Badge> : null}122 </div>123 {r.contact ? <p className="t-mute text-sm break-all"><a className="underline underline-offset-2" href={`mailto:${r.contact.email}`}>{r.contact.email}</a>{r.contact.organization ? ` · ${r.contact.organization}` : ""}</p> : null}124 <p className="mt-2.5 flex items-center gap-2 font-medium"><TypeDot color={r.type.color} />{r.type.title}</p>125126 {r.rescheduleOf ? (127 <p className="mt-1">{r.rescheduleOf.currentStartsAt ? <><span className="t-mute">{t("currentTime")} </span><When start={r.rescheduleOf.currentStartsAt} /><span aria-hidden> → </span></> : null}<span className="t-mute">{t("requestedTime")} </span><strong><When start={r.startsAt} end={r.endsAt} /></strong></p>128 ) : <p className="mt-0.5 text-[17px] tracking-tight t-num"><strong><When start={r.startsAt} end={r.endsAt} /></strong></p>}129 {otherZone ? <p className="t-mute text-sm">{t("visitorSees", { time: fmtDateTime(r.startsAt, r.visitorTz, locale), zone: `${tzAbbr(r.startsAt, r.visitorTz, locale)}, ${tzCity(r.visitorTz)}` })}</p> : null}130131 {r.status === "pending" ? <p className="t-mute text-sm mt-1">{r.holds && r.holdExpiresAt ? t("holdsUntil", { time: fmtDateTime(r.holdExpiresAt, timezone, locale) }) : t("holdsNot")}</p> : null}132 {r.status === "reschedule_requested" && r.proposal ? (133 <div className="mt-3"><Notice tone="info"><p>{t("proposalSent")} <strong><When start={r.proposal.startsAt} end={r.proposal.endsAt} /></strong></p>134 {r.proposal.expiresAt ? <p className="text-sm">{t("proposalExpires", { time: fmtDateTime(r.proposal.expiresAt, timezone, locale) })}</p> : null}135 {r.proposal.message ? <p className="text-sm mt-1">« {r.proposal.message} »</p> : null}</Notice></div>136 ) : null}137 {r.status === "pending" && r.conflict ? (138 <div className="mt-3"><Notice tone={blocked ? "danger" : "warn"}>{r.conflict === "taken" ? t("conflictTaken") : r.conflict === "past" ? t("conflictPast") : r.conflict === "closed" ? t("conflictClosed") : t("conflictOther")}</Notice></div>139 ) : null}140 {r.conflictFlag === "external_stale" && r.status === "pending" ? <div className="mt-3"><Notice tone="warn">{t("staleNote")}</Notice></div> : null}141 {r.status === "declined" && r.declineMessage ? <p className="text-sm mt-2"><span className="t-mute">{t("declineMsgSent")} </span>« {r.declineMessage} »</p> : null}142143 {r.reason ? <div className="mt-3"><p className="t-eyebrow">{t("reason")}</p><p className="whitespace-pre-wrap break-words">{r.reason}</p></div> : null}144 {r.answers.length ? <dl className="mt-3 grid gap-2">{r.answers.map((a, i) => <div key={i}><dt className="t-eyebrow">{a.label}</dt><dd className="whitespace-pre-wrap break-words">{a.value}</dd></div>)}</dl> : null}145 </div>146 </div>147148 {r.status === "pending" || r.status === "reschedule_requested" ? (149 <div className="grid grid-cols-2 sm:flex sm:flex-wrap gap-2 mt-4">150 {r.status === "pending" ? <Button variant="ok" icon="check" className="col-span-2 !min-h-[50px] sm:!min-h-[46px]" busy={busy} disabled={blocked} onClick={onApprove}>{r.rescheduleOf ? t("approveMove") : t("approve")}</Button> : null}151 <Button icon="clock" className="max-sm:!px-2.5 max-sm:!text-[14px] max-sm:whitespace-normal max-sm:leading-tight" disabled={busy} onClick={onPropose}>{r.status === "reschedule_requested" ? t("proposeAgain") : t("propose")}</Button>152 <Button icon="x" className="!text-danger" disabled={busy} onClick={onDecline}>{t("decline")}</Button>153 </div>154 ) : null}155156 <details className="mt-3.5 hairline pt-1.5" onToggle={(e) => void loadHistory((e.currentTarget as HTMLDetailsElement).open)}>157 <summary className="cursor-pointer text-sm font-semibold t-mute min-h-[44px] flex items-center gap-2 select-none list-none [&::-webkit-details-marker]:hidden"><Icon name="down" size={16} className="transition-transform [details[open]_&]:rotate-180" />{t("noteAndHistory")}{savedNote ? <Badge>{t("hasNote")}</Badge> : null}</summary>158 <div className="grid gap-4 pt-2">159 <Field label={t("internalNote")} hint={t("internalNoteHint")}>{(p) => <textarea {...p} className="input" value={note} maxLength={4000} onChange={(e) => setNote(e.target.value)} />}</Field>160 <div><Button small busy={saving} disabled={note === savedNote} onClick={saveNote}>{t("saveNote")}</Button></div>161 <div>162 <p className="t-eyebrow mb-2">{t("history")}</p>163 {historyError ? <Notice tone="danger">{historyError}</Notice> : history ? <HistoryList items={history} /> : <span className="t-mute text-sm inline-flex items-center gap-2"><Spinner size={14} />{t("loading")}</span>}164 <p className="t-mute text-sm mt-2">{t("receivedAt", { time: fmtDateTime(r.createdAt, timezone, locale) })}</p>165 </div>166 </div>167 </details>168 </article>169 );170}171172function DeclineSheet({ ids, name, onClose, onDone }: { ids: string[]; name: string | null; onClose: () => void; onDone: () => void }) {173 const t = useT(adminAgenda);174 const err = useErr();175 const [message, setMessage] = useState("");176 const [busy, setBusy] = useState(false);177 const [failed, setFailed] = useState<number | null>(null);178 const submit = async () => {179 setBusy(true);180 try {181 if (ids.length === 1) { await api(`/api/v1/admin/requests/${ids[0]}/decline`, { body: { message } }); toast.ok(t("declinedOk")); onDone(); }182 else {183 const res = await api<{ results: { id: string; ok: boolean }[] }>("/api/v1/admin/requests/bulk-decline", { body: { ids, message } });184 const bad = res.results.filter((x) => !x.ok).length;185 if (bad) { setFailed(bad); requestsChanged(); toast.error(t("bulkPartial", { ok: res.results.length - bad, bad })); }186 else { toast.ok(t("bulkOk", { n: res.results.length })); onDone(); }187 }188 } catch (e) { toast.error(err(e)); } finally { setBusy(false); }189 };190 return (191 <Sheet open onClose={failed ? onDone : onClose} title={ids.length > 1 ? t("declineTitleN", { n: ids.length }) : t("declineTitle")}192 footer={failed ? <Button onClick={onDone}>{t("close")}</Button> : <><Button onClick={onClose}>{t("cancel")}</Button><Button variant="danger" busy={busy} onClick={submit}>{ids.length > 1 ? t("declineSelection", { n: ids.length }) : t("decline")}</Button></>}>193 {failed ? <Notice tone="danger">{t("bulkFailedBody", { bad: failed })}</Notice> : (194 <div className="grid gap-3">195 <p className="t-mute">{name ? t("declineLead", { name }) : t("declineLeadN")}</p>196 <Field label={t("messageToVisitor")} optional={t("optional")} hint={t("declineHint")}>{(p) => <textarea {...p} className="input" value={message} maxLength={1000} onChange={(e) => setMessage(e.target.value)} />}</Field>197 </div>198 )}199 </Sheet>200 );201}202203function ProposeSheet({ r, taken, onClose, onDone }: { r: Req; taken: boolean; onClose: () => void; onDone: () => void }) {204 const t = useT(adminAgenda);205 const err = useErr();206 const { timezone } = useAdmin();207 const locale = useLocale();208 const [start, setStart] = useState<string | null>(null);209 const [message, setMessage] = useState("");210 const [busy, setBusy] = useState(false);211 const [error, setError] = useState<string | null>(null);212 const submit = async () => {213 if (!start) return;214 setBusy(true); setError(null);215 try { await api(`/api/v1/admin/requests/${r.id}/propose`, { body: { start, message } }); toast.ok(t("proposedOk")); onDone(); }216 catch (e) { setError(err(e)); } finally { setBusy(false); }217 };218 return (219 <Sheet open onClose={onClose} title={t("proposeTitle")} wide220 footer={<><Button onClick={onClose}>{t("cancel")}</Button><Button variant="primary" busy={busy} disabled={!start} onClick={submit}>{start ? t("proposeSend", { time: `${fmtDateTime(start, timezone, locale)}` }) : t("proposeSendNone")}</Button></>}>221 <div className="grid gap-4">222 {taken ? <Notice tone="danger">{t("takenExplain")}</Notice> : null}223 <p className="t-mute">{t("proposeLead", { name: r.contact?.name ?? "" })} <span className="whitespace-nowrap">({fmtTime(r.startsAt, timezone, locale)} – {fmtTime(r.endsAt, timezone, locale)})</span></p>224 <SlotPicker eventTypeId={r.type.id} ignoreRequestId={r.id} ignoreBookingId={r.rescheduleOf?.bookingId} value={start} onChange={setStart} initialDate={ymdInTz(r.startsAt, timezone)} allowCustom />225 <Field label={t("messageToVisitor")} optional={t("optional")}>{(p) => <textarea {...p} className="input" value={message} maxLength={1000} onChange={(e) => setMessage(e.target.value)} />}</Field>226 {error ? <Notice tone="danger">{error}</Notice> : null}227 </div>228 </Sheet>229 );230}231