Workflow-App/Workflow/components/DirectorAppointmentTab.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

484 lines
20 KiB
TypeScript

"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<Status, string> = {
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<Status, "muted" | "warn" | "success" | "info"> = {
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<string, string> = {
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 (
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-semibold ${classes[variant] || "bg-slate-100 text-slate-600"}`}>
{children}
</span>
);
}
function StatusDot({ status }: { status: RecipientStatus }) {
if (status === "valid") {
return (
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-green-100 text-green-700 text-xs font-bold flex-shrink-0" title="Valid email">✓</span>
);
}
if (status === "missing") {
return (
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-amber-100 text-amber-700 text-xs font-bold flex-shrink-0" title="Missing email">!</span>
);
}
return (
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-red-100 text-red-700 text-xs font-bold flex-shrink-0" title="Invalid format">x</span>
);
}
export default function DirectorAppointmentTab() {
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(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<DirectorRow | null>(null);
const [selectedCompany, setSelectedCompany] = useState<CompanyRow | null>(null);
// Validation gate
const [report, setReport] = useState<ValidationReport | null>(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 (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-fg">
{loading ? "Loading..." : `${appointments.length} appointment${appointments.length !== 1 ? "s" : ""}`}
</p>
<button
onClick={openModal}
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm hover:opacity-90 transition-opacity"
>
Acceptance Letter
</button>
</div>
{/* Table removed 2026-08-19 — replaced by AppointmentTable below in ResolutionExplorer
which has filter chips, search, and Resend/Cancel action buttons. */}
{/* Modal */}
{showModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={(e) => { if (e.target === e.currentTarget) { setShowModal(false); setReport(null); } }}
>
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-md flex flex-col max-h-[90vh]">
<div className="border-b border-border px-6 py-4 flex-shrink-0">
<h2 className="text-lg font-semibold text-fg">Send Consent to Act as Director</h2>
<p className="text-sm text-muted mt-0.5">
{report ? "Review the recipient list before sending." : "Select a company and director to send the consent letter for signature."}
</p>
</div>
<div className="p-6 space-y-4 overflow-y-auto flex-1">
{/* Always show the picker so the user can change selection */}
<div className="grid grid-cols-2 gap-3">
<SearchableSelect
id="appt-company"
label="Company"
collection="ysterbaard_companies"
fields={["id", "enterprise_name", "company_email", "enterprise_no"]}
searchFields={["enterprise_name", "enterprise_no"]}
placeholder="Search companies..."
value={selectedCompany?.id ?? ""}
onChange={(r) => { 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
/>
<SearchableSelect
id="appt-director"
label="Director"
collection="ysterbaard_directors"
fields={["id", "first_names", "surname", "email", "telephone", "id_no"]}
searchFields={["first_names", "surname", "id_no"]}
placeholder="Search directors..."
value={selectedDirector?.id ?? ""}
onChange={(r) => { 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
/>
</div>
{/* Validation panel */}
{report && (
<div className="border border-border rounded-lg overflow-hidden">
<div className={`px-4 py-2.5 text-sm font-semibold flex items-center justify-between ${
report.problems === 0
? "bg-green-50 text-green-800 border-b border-green-200"
: "bg-amber-50 text-amber-900 border-b border-amber-200"
}`}>
<span>
{report.problems === 0
? "All recipients look good"
: `${report.problems} recipient${report.problems !== 1 ? "s" : ""} need attention`}
</span>
</div>
<div className="p-3 space-y-3 text-sm">
<div>
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-1.5">Initial send</p>
<div className="space-y-1.5">
{report.initial.map((r) => (
<div key={r.key} className="flex items-center gap-2">
<StatusDot status={r.status} />
<span className="text-fg-2 text-xs flex-1">{r.label}</span>
<span className={r.status === "valid" ? "text-fg-2 text-xs font-mono" : "text-amber-800 text-xs font-mono"}>
{r.status === "missing" ? "(missing)" : r.status === "invalid" ? r.email : r.email}
</span>
</div>
))}
</div>
</div>
<div>
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-1.5">
After director signs (sent to all)
</p>
<div className="space-y-1.5">
{report.distro.map((r) => (
<div key={r.key} className="flex items-center gap-2">
<StatusDot status={r.status} />
<span className="text-fg-2 text-xs flex-1">{r.label}</span>
<span className={r.status === "valid" ? "text-fg-2 text-xs font-mono" : "text-amber-800 text-xs font-mono"}>
{r.status === "missing" ? "(missing)" : r.status === "invalid" ? r.email : r.email}
</span>
</div>
))}
</div>
</div>
{report.problems > 0 && (
<p className="text-xs text-amber-900 bg-amber-100 border border-amber-200 rounded px-3 py-2">
<strong>Heads up:</strong> 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.
</p>
)}
</div>
</div>
)}
{sendError && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-sm text-red-400">
{sendError}
</div>
)}
</div>
<div className="border-t border-border px-6 py-4 flex justify-between gap-3 flex-shrink-0">
{/* Left side: Cancel / Edit records */}
<div className="flex gap-2">
<button
onClick={() => { setShowModal(false); setReport(null); }}
className="px-4 py-2 text-sm font-semibold text-muted hover:text-fg transition-colors"
>
Cancel
</button>
{report && report.problems > 0 && (
<a
href={selectedCompany ? `/companies/${selectedCompany.id}` : "/companies"}
target="_blank"
rel="noopener"
className="px-4 py-2 text-sm font-semibold text-accent hover:text-accent-hover transition-colors"
>
Fix records first →
</a>
)}
</div>
{/* Right side: Validate / Send / Proceed anyway */}
{!report ? (
<button
onClick={handlePreview}
disabled={!selectedDirector || !selectedCompany || previewing}
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-40 hover:opacity-90 transition-opacity"
>
{previewing ? "Validating..." : "Validate recipients"}
</button>
) : report.problems === 0 ? (
<button
onClick={() => handleSend(false)}
disabled={sending}
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-40 hover:opacity-90 transition-opacity"
>
{sending ? "Sending..." : "Send letter"}
</button>
) : (
<button
onClick={() => handleSend(true)}
disabled={sending}
className="px-4 py-2 bg-amber-600 text-white rounded font-semibold text-sm disabled:opacity-40 hover:bg-amber-700 transition-colors"
title="Send anyway — the flagged recipients will be skipped when the signed letter is distributed"
>
{sending ? "Sending..." : "Proceed anyway"}
</button>
)}
</div>
</div>
</div>
)}
</div>
);
}