"use client"; // components/DirectorAppointmentTab.tsx // Table of all director appointments + "Acceptance Letter" modal // with a pre-send validation gate (per SJ 2026-08-18): the modal shows // the resolved recipient list (new director + linked directors + company // email) and flags any missing/invalid emails BEFORE sending. The user // can Cancel, Edit (jump to the edit screen), or Proceed anyway. import { useEffect, useState, useCallback } from "react"; import type { YsterbaardDirectorAppointment } from "@/lib/types"; import SearchableSelect, { type SearchableSelectRecord } from "./SearchableSelect"; type Status = YsterbaardDirectorAppointment["status"]; const STATUS_LABELS: Record = { pending: "Pending", mail_sent: "Mail sent", signature_received: "Signature received", mail_sent_to_all: "Mail sent to all", }; // PB record shapes used by the picker. Keeps the search call and the // `find(...).label` lookups type-safe without sprinkling `any`. type DirectorRow = SearchableSelectRecord & { first_names?: string; surname?: string; email?: string; telephone?: string; }; type CompanyRow = SearchableSelectRecord & { enterprise_name?: string; company_email?: string; }; const STATUS_VARIANTS: Record = { pending: "muted", mail_sent: "warn", signature_received: "success", mail_sent_to_all: "info", }; interface Appointment extends YsterbaardDirectorAppointment { _director_name?: string; _company_name?: string; } // Surname-or-id search fields match the LinkEntityModal convention. // Email/telephone are intentionally NOT in the search filter so that a // missing email doesn't make a director unsearchable. type RecipientStatus = "valid" | "missing" | "invalid"; interface Recipient { key: string; label: string; email: string; role: string; status: RecipientStatus; } interface ValidationReport { preview: boolean; initial: Recipient[]; distro: Recipient[]; problems: number; } function StatusBadge({ variant, children }: { variant: string; children: React.ReactNode }) { const classes: Record = { muted: "bg-slate-100 text-slate-600", warn: "bg-amber-100 text-amber-700", success: "bg-green-100 text-green-700", info: "bg-teal-100 text-teal-700", }; return ( {children} ); } function StatusDot({ status }: { status: RecipientStatus }) { if (status === "valid") { return ( ✓ ); } if (status === "missing") { return ( ! ); } return ( x ); } export default function DirectorAppointmentTab() { const [appointments, setAppointments] = useState([]); const [loading, setLoading] = useState(true); const [showModal, setShowModal] = useState(false); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); // Modal form state — we hold the FULL record (not just an id) so the // validation gate can read email/telephone without an extra PB round-trip. const [selectedDirector, setSelectedDirector] = useState(null); const [selectedCompany, setSelectedCompany] = useState(null); // Validation gate const [report, setReport] = useState(null); const [previewing, setPreviewing] = useState(false); const loadAppointments = useCallback(async () => { try { const res = await fetch("/api/appointments"); if (!res.ok) throw new Error(); const data = await res.json(); const items: Appointment[] = (data.items || []).map((item: YsterbaardDirectorAppointment) => ({ ...item, _director_name: item.expand?.director ? [item.expand.director.first_names, item.expand.director.surname].filter(Boolean).join(" ") : item.expand?.director?.first_names || "—", _company_name: item.expand?.company?.enterprise_name || "—", })); setAppointments(items); } catch { // silently fail } finally { setLoading(false); } }, []); useEffect(() => { loadAppointments(); }, [loadAppointments]); const openModal = () => { setShowModal(true); setSendError(null); setSelectedDirector(null); setSelectedCompany(null); setReport(null); // Director + company lists are fetched on-demand by the SearchableSelect // when the user opens the dropdown — no upfront load. }; // Fetch the full company record so we can show the company email in the // validation panel. const fetchCompanyDetails = async (id: string) => { try { const res = await fetch(`/api/pb/records/get?collection=ysterbaard_companies&id=${id}&fields=id,enterprise_name,company_email`); if (res.ok) return res.json(); } catch { /* ignore */ } return null; }; const fetchDirectorDetails = async (id: string) => { try { const res = await fetch(`/api/pb/records/get?collection=ysterbaard_directors&id=${id}&fields=id,first_names,surname,email`); if (res.ok) return res.json(); } catch { /* ignore */ } return null; }; const handlePreview = async () => { if (!selectedDirector || !selectedCompany) { setSendError("Please select a company and a director first."); return; } setPreviewing(true); setSendError(null); setReport(null); try { // Build the local validation report — same shape the server would // return, but computed client-side so we don't round-trip to the // server just to render the gate. The server re-validates on POST. const director = selectedDirector; const company = selectedCompany; // Re-fetch to get the freshest email/company_email (in case the user // edited the record after it was picked in the typeahead). const dirDetails = await fetchDirectorDetails(director.id); const coDetails = await fetchCompanyDetails(company.id); const emailRx = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/; const classify = (e: string | undefined | null): RecipientStatus => { const v = (e ?? "").trim(); if (!v) return "missing"; return emailRx.test(v) ? "valid" : "invalid"; }; // Fetch linked directors for the company let linkedDirectors: Array<{ id: string; first_names?: string; surname?: string; email?: string }> = []; try { const res = await fetch("/api/pb/records/list?collection=ysterbaard_company_directors&perPage=200&filter=" + encodeURIComponent(`company = '${company.id}'`) + "&expand=director&fields=expand.director.id,expand.director.first_names,expand.director.surname,expand.director.email"); if (res.ok) { const data = await res.json(); linkedDirectors = (data.items || []) .map((j: { expand?: { director?: { id: string; first_names?: string; surname?: string; email?: string } } }) => j.expand?.director) .filter(Boolean); } } catch { /* ignore — junction may not exist */ } const dirLabel = [director.first_names, director.surname].filter(Boolean).join(" ") || director.id; const initial: Recipient[] = [{ key: "new_director", label: `New director: ${dirLabel}`, email: dirDetails?.email ?? director.email ?? "", role: "to", status: classify(dirDetails?.email ?? director.email), }]; const distro: Recipient[] = [ ...linkedDirectors.map((d) => ({ key: `director:${d.id}`, label: `Director: ${[d.first_names, d.surname].filter(Boolean).join(" ") || d.id}`, email: d.email ?? "", role: "to", status: classify(d.email), })), { key: "company_email", label: `Company: ${coDetails?.enterprise_name ?? company.enterprise_name ?? company.id}`, email: coDetails?.company_email ?? "", role: "to", status: classify(coDetails?.company_email), }, ]; const all = [...initial, ...distro]; const problems = all.filter((r) => r.status !== "valid").length; setReport({ preview: true, initial, distro, problems }); } catch (err: unknown) { setSendError(err instanceof Error ? err.message : "Validation failed."); } finally { setPreviewing(false); } }; const handleSend = async (proceedAnyway: boolean) => { if (!selectedDirector || !selectedCompany) return; setSending(true); setSendError(null); const director = selectedDirector; const company = selectedCompany; try { const res = await fetch("/api/appointments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ director_id: director.id, company_id: company.id, director_email: director.email || "SJ@Dyfins.com", director_cell: director.telephone || "", director_name: director.first_names || "", director_surname: director.surname || "", company_name: company.enterprise_name || "", proceed_anyway: proceedAnyway, }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); // 422 is the validation failure — surface the report so the user can fix if (res.status === 422 && data.initial && data.distro) { setReport({ preview: false, initial: data.initial, distro: data.distro, problems: (data.initial.length + data.distro.length), }); throw new Error(data.error || "Server-side validation failed."); } throw new Error(data.error || `HTTP ${res.status}`); } // 200 does not guarantee delivery — the route returns 502 with // { ok: false, warning: "Letter saved but email failed" } when the // mail itself fails. Never report success on a non-OK response. const sent = await res.json().catch(() => null); if (sent?.ok === false) { throw new Error( (sent?.error as string) || (sent?.warning as string) || "The letter was saved but the email could not be sent." ); } setShowModal(false); setReport(null); loadAppointments(); } catch (err: unknown) { setSendError(err instanceof Error ? err.message : "Failed to send letter"); } finally { setSending(false); } }; return (
{/* Header */}

{loading ? "Loading..." : `${appointments.length} appointment${appointments.length !== 1 ? "s" : ""}`}

{/* Table removed 2026-08-19 — replaced by AppointmentTable below in ResolutionExplorer which has filter chips, search, and Resend/Cancel action buttons. */} {/* Modal */} {showModal && (
{ if (e.target === e.currentTarget) { setShowModal(false); setReport(null); } }} >

Send Consent to Act as Director

{report ? "Review the recipient list before sending." : "Select a company and director to send the consent letter for signature."}

{/* Always show the picker so the user can change selection */}
{ setSelectedCompany((r as CompanyRow | null)); setReport(null); }} renderLabel={(r) => (r.enterprise_name as string) || (r.id as string)} renderSub={(r) => (r.enterprise_no as string) || (r.company_email as string) || ""} required /> { setSelectedDirector((r as DirectorRow | null)); setReport(null); }} renderLabel={(r) => [r.first_names as string, r.surname as string].filter(Boolean).join(" ") || (r.id as string)} renderSub={(r) => (r.email as string) || (r.telephone as string) || (r.id_no as string) || ""} required />
{/* Validation panel */} {report && (
{report.problems === 0 ? "All recipients look good" : `${report.problems} recipient${report.problems !== 1 ? "s" : ""} need attention`}

Initial send

{report.initial.map((r) => (
{r.label} {r.status === "missing" ? "(missing)" : r.status === "invalid" ? r.email : r.email}
))}

After director signs (sent to all)

{report.distro.map((r) => (
{r.label} {r.status === "missing" ? "(missing)" : r.status === "invalid" ? r.email : r.email}
))}
{report.problems > 0 && (

Heads up: recipients flagged above will be skipped when the signed letter is distributed. You can fix the records first, or proceed anyway and email them manually.

)}
)} {sendError && (
{sendError}
)}
{/* Left side: Cancel / Edit records */}
{report && report.problems > 0 && ( Fix records first → )}
{/* Right side: Validate / Send / Proceed anyway */} {!report ? ( ) : report.problems === 0 ? ( ) : ( )}
)}
); }