Repository Public
1"use client";2import { useEffect, useState } from "react";3import { useLocale, useT } from "@/i18n";4import { adminConfig } from "@/i18n/admin-config";5import { api, ApiClientError } from "@/lib/client-api";6import { fmtDateTime, fmtRange } from "@/lib/format";7import { Avatar, Badge, Button, Empty, errorText, Field, Icon, Notice, Sheet, toast } from "@/components/ui";8import { Loadable, PageHeader, useAdmin, useLoad } from "@/components/admin/shell";9import { Confirm } from "./common";1011interface ContactRow { id: string; name: string; email: string; organization: string; createdAt: string; bookings: number; requests: number; lastAt: string | null }12interface Detail {13 contact: { id: string; name: string; email: string; organization: string; locale: string; notes: string; createdAt: string };14 requests: { id: string; status: string; startsAt: string; endsAt: string; createdAt: string; type: { title: string; color: string } }[];15 bookings: { id: string; title: string; status: string; startsAt: string; endsAt: string; type: { title: string; color: string } | null }[];16}17type Tone = "pending" | "ok" | "danger" | "info" | "mute";18const REQ_TONE: Record<string, Tone> = { pending: "pending", reschedule_requested: "info", approved: "ok", declined: "danger", expired: "mute", cancelled: "mute" };1920export function ContactsScreen() {21 const t = useT(adminConfig);22 const locale = useLocale();23 const { timezone } = useAdmin();24 const [input, setInput] = useState("");25 const [q, setQ] = useState("");26 const [openId, setOpenId] = useState<string | null>(null);27 useEffect(() => { const id = setTimeout(() => setQ(input.trim()), 300); return () => clearTimeout(id); }, [input]);28 const state = useLoad<{ contacts: ContactRow[] }>(`/api/v1/admin/contacts?q=${encodeURIComponent(q)}`);29 const list = state.data?.contacts ?? [];3031 return (32 <>33 <PageHeader title={t("ctTitle")} lead={t("ctLead")} />34 <div className="max-w-md mb-4">35 <Field label={t("ctSearch")}>{(p) => <input {...p} type="search" className="input" value={input} placeholder={t("ctSearchPh")} onChange={(e) => setInput(e.target.value)} />}</Field>36 </div>37 <Loadable state={state}>38 {list.length === 0 ? <div className="card"><Empty icon="users" title={q ? t("ctNoMatch") : t("ctEmpty")}>{q ? null : t("ctEmptyLead")}</Empty></div> : (39 <ul className="card divide-y divide-line overflow-hidden" aria-busy={state.loading}>40 {list.map((c) => (41 <li key={c.id}>42 <button className="w-full text-left px-4 sm:px-5 py-3.5 flex items-center gap-3 hover:bg-elev cursor-pointer min-h-[60px]" onClick={() => setOpenId(c.id)}>43 <Avatar name={c.name || c.email} size={42} seed />44 <span className="min-w-0 flex-1">45 <span className="block font-medium truncate">{c.name}</span>46 <span className="block t-mute text-sm truncate">{c.email}{c.organization ? ` · ${c.organization}` : ""}</span>47 </span>48 <span className="hidden sm:block text-right text-sm t-mute shrink-0">49 <span className="block">{t("ctCounts", { r: c.requests, b: c.bookings })}</span>50 {c.lastAt ? <span className="block">{t("ctLast", { date: fmtDateTime(c.lastAt, timezone, locale) })}</span> : null}51 </span>52 <Icon name="right" className="t-mute" />53 </button>54 </li>55 ))}56 </ul>57 )}58 </Loadable>59 {openId ? <ContactSheet id={openId} onClose={() => setOpenId(null)} onChanged={state.reload} /> : null}60 </>61 );62}6364function ContactSheet({ id, onClose, onChanged }: { id: string; onClose: () => void; onChanged: () => void }) {65 const t = useT(adminConfig);66 const locale = useLocale();67 const { timezone } = useAdmin();68 const state = useLoad<Detail>(`/api/v1/admin/contacts/${id}`);69 const [form, setForm] = useState<{ name: string; organization: string; notes: string } | null>(null);70 const [saving, setSaving] = useState(false);71 const [confirm, setConfirm] = useState(false);72 const [erasing, setErasing] = useState(false);73 const [eraseError, setEraseError] = useState<string | null>(null);74 const c = state.data?.contact;75 useEffect(() => { if (c) setForm({ name: c.name, organization: c.organization, notes: c.notes }); }, [c]);76 const dirty = !!c && !!form && (form.name !== c.name || form.organization !== c.organization || form.notes !== c.notes);7778 const save = async (ev: React.FormEvent) => {79 ev.preventDefault();80 if (!form) return;81 setSaving(true);82 try { await api(`/api/v1/admin/contacts/${id}`, { method: "PATCH", body: form }); toast.ok(t("ctSaved")); await state.reload(); onChanged(); }83 catch (e) { toast.error(errorText(e, locale)); } finally { setSaving(false); }84 };85 const erase = async () => {86 setErasing(true); setEraseError(null);87 try { await api(`/api/v1/admin/contacts/${id}`, { method: "DELETE" }); toast.ok(t("ctErased")); onChanged(); onClose(); }88 catch (e) { setEraseError(e instanceof ApiClientError && e.code === "future_booking" ? t("ctFutureBooking") : errorText(e, locale)); setErasing(false); }89 };90 const reqLabel = (s: string) => ({ pending: t("ctStPending"), reschedule_requested: t("ctStProposal"), approved: t("ctStApproved"), declined: t("ctStDeclined"), expired: t("ctStExpired"), cancelled: t("ctStCancelled") }[s] ?? s);91 const when = (a: string, b: string) => `${fmtDateTime(a, timezone, locale)} – ${fmtRange(a, b, timezone, locale).split(" – ")[1]}`;9293 return (94 <>95 <Sheet open={!confirm} onClose={onClose} title={c?.name ?? t("ctTitle")} wide>96 <Loadable state={state}>97 {c && form ? (98 <div className="grid gap-5">99 <p className="t-mute text-sm break-all">{c.email} · {t("ctSince", { date: fmtDateTime(c.createdAt, timezone, locale) })}</p>100 <form onSubmit={save} className="grid gap-4">101 <div className="grid sm:grid-cols-2 gap-4">102 <Field label={t("ctName")}>{(p) => <input {...p} className="input" value={form.name} required maxLength={120} onChange={(e) => setForm({ ...form, name: e.target.value })} />}</Field>103 <Field label={t("ctOrg")} optional={t("cOptional")}>{(p) => <input {...p} className="input" value={form.organization} maxLength={160} onChange={(e) => setForm({ ...form, organization: e.target.value })} />}</Field>104 </div>105 <Field label={t("ctNotes")} optional={t("cOptional")} hint={t("ctNotesHint")}>{(p) => <textarea {...p} className="input" value={form.notes} maxLength={4000} onChange={(e) => setForm({ ...form, notes: e.target.value })} />}</Field>106 <div><Button type="submit" variant="primary" small busy={saving} disabled={!dirty || !form.name.trim()}>{t("cSave")}</Button></div>107 </form>108109 <section aria-labelledby="ct-b">110 <h3 id="ct-b" className="t-eyebrow mb-2">{t("ctBookings", { n: state.data!.bookings.length })}</h3>111 {state.data!.bookings.length === 0 ? <p className="t-mute text-sm">{t("ctNoBookings")}</p> : (112 <ul className="grid gap-2">113 {state.data!.bookings.map((b) => (114 <li key={b.id} className="rounded-2xl border border-line p-3 flex flex-wrap items-center justify-between gap-2">115 <span className="min-w-0"><span className="block font-medium">{b.type?.title ?? b.title}</span><span className="block t-mute text-sm">{when(b.startsAt, b.endsAt)}</span></span>116 <Badge tone={b.status === "confirmed" ? "ok" : "mute"}>{b.status === "confirmed" ? t("ctStConfirmed") : t("ctStCancelled")}</Badge>117 </li>118 ))}119 </ul>120 )}121 </section>122 <section aria-labelledby="ct-r">123 <h3 id="ct-r" className="t-eyebrow mb-2">{t("ctRequests", { n: state.data!.requests.length })}</h3>124 {state.data!.requests.length === 0 ? <p className="t-mute text-sm">{t("ctNoRequests")}</p> : (125 <ul className="grid gap-2">126 {state.data!.requests.map((r) => (127 <li key={r.id} className="rounded-2xl border border-line p-3 flex flex-wrap items-center justify-between gap-2">128 <span className="min-w-0"><span className="block font-medium">{r.type.title}</span><span className="block t-mute text-sm">{when(r.startsAt, r.endsAt)}</span></span>129 <Badge tone={REQ_TONE[r.status] ?? "mute"}>{reqLabel(r.status)}</Badge>130 </li>131 ))}132 </ul>133 )}134 </section>135136 <section className="hairline pt-4 grid gap-3" aria-labelledby="ct-p">137 <h3 id="ct-p" className="t-eyebrow">{t("ctPrivacy")}</h3>138 <p className="t-mute text-sm">{t("ctPrivacyLead")}</p>139 <div className="flex flex-wrap gap-2">140 <a className="btn btn-secondary btn-sm" href={`/api/v1/admin/contacts/${id}?export=1`} download>{t("ctExport")}</a>141 <Button small variant="danger" icon="trash" onClick={() => { setEraseError(null); setConfirm(true); }}>{t("ctErase")}</Button>142 </div>143 </section>144 </div>145 ) : null}146 </Loadable>147 </Sheet>148 <Confirm open={confirm} onClose={() => setConfirm(false)} title={t("ctEraseTitle")} confirmLabel={t("ctErase")} cancelLabel={t("cCancel")} onConfirm={erase} busy={erasing}>149 <p>{t("ctEraseLead", { name: c?.name ?? "" })}</p>150 <p className="t-mute text-sm">{t("ctEraseNote")}</p>151 {eraseError ? <Notice tone="danger">{eraseError}</Notice> : null}152 </Confirm>153 </>154 );155}156