// components/AppointmentTable.tsx // Table view of all ysterbaard_director_resignations — same data, but a // dashboard-style "where are we now" view grouped by status. Surfaces // signed + pending so SJ can see at a glance what's waiting on what. "use client"; import { useEffect, useMemo, useState } from "react"; import StatusBadge from "@/components/StatusBadge"; type Status = "pending" | "mail_sent" | "signature_received" | "mail_sent_to_all"; interface Appointment { id: string; status: Status | ""; sent_to: string; sent_at: string; sign_token: string; signature: string; letter: string; missing_emails: string; proceed_anyway: boolean; created: string; updated: string; expand?: { director?: { id: string; first_names?: string; surname?: string; email?: string }; company?: { id: string; enterprise_no?: string; enterprise_name?: string; company_email?: string }; }; } const STATUS_LABELS: Record = { pending: "Pending — letter not yet sent", mail_sent: "Awaiting signature", signature_received: "Signed — distro in flight", mail_sent_to_all: "Complete", }; const STATUS_BADGE: Record = { pending: "muted", mail_sent: "warn", signature_received: "warn", mail_sent_to_all: "success", }; function fmtDate(iso: string | null | undefined): string { if (!iso) return "—"; const d = new Date(iso); if (isNaN(d.getTime())) return iso; return d.toLocaleString("en-ZA", { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", }); } function relTime(iso: string | null | undefined): string { if (!iso) return ""; const diff = Date.now() - new Date(iso).getTime(); const min = Math.floor(diff / 60000); if (min < 1) return "just now"; if (min < 60) return `${min}m ago`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}h ago`; const d = Math.floor(hr / 24); if (d < 30) return `${d}d ago`; return ""; } export default function AppointmentTable() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [filter, setFilter] = useState<"all" | Status>("all"); const [q, setQ] = useState(""); const [busyId, setBusyId] = useState(null); const [actionError, setActionError] = useState(null); const handleCancel = async (a: Appointment) => { if (!confirm(`Cancel and delete the appointment for ${a.expand?.director?.first_names || ""} ${a.expand?.director?.surname || ""} at ${a.expand?.company?.enterprise_name || a.expand?.company?.enterprise_no || "this company"}?\n\nThis permanently removes the record.`)) return; setBusyId(a.id); setActionError(null); try { const res = await fetch(`/api/pb/records/${encodeURIComponent(a.id)}?collection=ysterbaard_director_resignations`, { method: "DELETE" }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || `HTTP ${res.status}`); } setItems((prev) => prev.filter((x) => x.id !== a.id)); } catch (e) { setActionError(e instanceof Error ? e.message : "Cancel failed"); } finally { setBusyId(null); } }; const handleResend = async (a: Appointment, mail: "initial" | "signed") => { setBusyId(a.id); setActionError(null); try { const res = await fetch("/api/resignations/resend", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: a.id, mail }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); const reload = await fetch("/api/resignations"); if (reload.ok) { const fresh = await reload.json(); setItems(fresh.items || []); } } catch (e) { setActionError(e instanceof Error ? e.message : "Resend failed"); } finally { setBusyId(null); } }; const handleUploadSigned = async (a: Appointment, file: File) => { setBusyId(a.id); setActionError(null); try { const form = new FormData(); form.append("file", file); const res = await fetch( `/api/resolutions/${encodeURIComponent(a.id)}/upload` + `?collection=ysterbaard_director_resignations&field=signature`, { method: "POST", body: form } ); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); const reload = await fetch("/api/resignations"); if (reload.ok) { const fresh = await reload.json(); setItems(fresh.items || []); } } catch (e) { setActionError(e instanceof Error ? e.message : "Upload failed"); } finally { setBusyId(null); } }; const handleComplete = async (a: Appointment) => { const who = `${a.expand?.director?.first_names || ""} ${a.expand?.director?.surname || ""}`.trim(); if (!confirm(`Mark the resignation for ${who || "this director"} as complete?\n\nThe status becomes "Complete".`)) return; setBusyId(a.id); setActionError(null); try { const res = await fetch( `/api/pb/records/${encodeURIComponent(a.id)}?collection=ysterbaard_director_resignations`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "mail_sent_to_all" }), } ); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); const reload = await fetch("/api/resignations"); if (reload.ok) { const fresh = await reload.json(); setItems(fresh.items || []); } } catch (e) { setActionError(e instanceof Error ? e.message : "Could not complete"); } finally { setBusyId(null); } }; useEffect(() => { fetch("/api/resignations") .then((r) => r.json()) .then((d) => { if (d.error) throw new Error(d.error); setItems(d.items || []); }) .catch((e) => setError(e instanceof Error ? e.message : "Failed to load")) .finally(() => setLoading(false)); }, []); const counts = useMemo(() => { const c: Record = { all: items.length }; for (const it of items) { const s = it.status || "(empty)"; c[s] = (c[s] || 0) + 1; } return c; }, [items]); const filtered = useMemo(() => { let out = items; if (filter !== "all") { out = out.filter((it) => it.status === filter); } const qq = q.trim().toLowerCase(); if (qq) { out = out.filter((it) => { const d = it.expand?.director; const co = it.expand?.company; return [ it.id, it.sent_to, it.sign_token, d?.first_names, d?.surname, d?.email, co?.enterprise_no, co?.enterprise_name, co?.company_email, ].some((v) => v && String(v).toLowerCase().includes(qq)); }); } return out.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()); }, [items, filter, q]); if (loading) return
Loading appointments…
; if (error) return
Error: {error}
; return (

All appointments

setQ(e.target.value)} placeholder="Search director, company, email…" className="bg-surface border border-border rounded px-3 py-2 text-sm text-fg placeholder-muted focus:outline-none focus:border-accent w-full md:w-72" />
{(["all", "pending", "mail_sent", "signature_received", "mail_sent_to_all"] as const).map((k) => ( ))}
{filtered.length === 0 ? (
No appointments match this filter.
) : (
{filtered.map((a) => { const d = a.expand?.director; const co = a.expand?.company; const directorName = [d?.first_names, d?.surname].filter(Boolean).join(" ") || "—"; const companyName = co?.enterprise_name || co?.enterprise_no || "—"; const sig = !!a.signature; const letter = !!a.letter; const status = (a.status || "pending") as Status; return ( ); })}
Director Company Status Sent to Sent at Signed letter Actions
{directorName}
{d?.email &&
{d.email}
}
{companyName}
{co?.enterprise_no &&
{co.enterprise_no}
}
{STATUS_LABELS[status]} {a.missing_emails && (
Missing: {a.missing_emails}
)}
{a.sent_to || "—"}
{fmtDate(a.sent_at)}
{relTime(a.sent_at)}
{sig ? "✓ Signature" : "— Signature"} {letter ? "✓ Letter" : "— Letter"}
{sig && ( View signed )}
)} {actionError && (
{actionError}
)}
); }