SPB Git

Repository Public

contacts.tsx 9.7 KB 156 lines tsx

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
Files main
Menu

Theme