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.
316 lines
14 KiB
TypeScript
316 lines
14 KiB
TypeScript
"use client";
|
|
|
|
// components/DirectorResignationTab.tsx
|
|
// Send a director's Letter of Resignation. Mirrors DirectorAppointmentTab UX:
|
|
// pick director + company → preview recipients → send.
|
|
// On submit, POST /api/resignations which creates the PB record + emails the
|
|
// director with a sign link.
|
|
|
|
import { useState } from "react";
|
|
import SearchableSelect, { type SearchableSelectRecord } from "./SearchableSelect";
|
|
|
|
// PB row shapes for the picker. Cast happens at the JSX site; we keep these
|
|
// type aliases so the rest of the file reads naturally.
|
|
type DirectorRow = SearchableSelectRecord & {
|
|
first_names?: string;
|
|
surname?: string;
|
|
email?: string;
|
|
telephone?: string;
|
|
};
|
|
type CompanyRow = SearchableSelectRecord & {
|
|
enterprise_name?: string;
|
|
company_email?: string;
|
|
};
|
|
|
|
type RecipientStatus = "valid" | "missing" | "invalid";
|
|
interface Recipient {
|
|
key: string;
|
|
label: string;
|
|
email: string;
|
|
role: string;
|
|
status: RecipientStatus;
|
|
}
|
|
interface PreviewReport {
|
|
recipients: Recipient[];
|
|
problems: number;
|
|
}
|
|
|
|
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 DirectorResignationTab() {
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [sending, setSending] = useState(false);
|
|
const [previewing, setPreviewing] = useState(false);
|
|
const [sendError, setSendError] = useState<string | null>(null);
|
|
const [sent, setSent] = useState<{ id: string; signUrl: string } | null>(null);
|
|
const [selectedDirector, setSelectedDirector] = useState<DirectorRow | null>(null);
|
|
const [selectedCompany, setSelectedCompany] = useState<CompanyRow | null>(null);
|
|
const [report, setReport] = useState<PreviewReport | null>(null);
|
|
|
|
const openModal = () => {
|
|
setShowModal(true);
|
|
setSendError(null);
|
|
setSent(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.
|
|
};
|
|
|
|
const handlePreview = async () => {
|
|
if (!selectedDirector || !selectedCompany) {
|
|
setSendError("Please select a company and a director first.");
|
|
return;
|
|
}
|
|
setPreviewing(true);
|
|
setSendError(null);
|
|
setReport(null);
|
|
|
|
try {
|
|
const director = selectedDirector;
|
|
const company = selectedCompany;
|
|
|
|
// Re-fetch to get the freshest email/company_email.
|
|
const dirDetails = await fetch(`/api/pb/records/${director.id}?collection=ysterbaard_directors&fields=id,first_names,surname,email`).then((r) => r.ok ? r.json() : null).catch(() => null);
|
|
const coDetails = await fetch(`/api/pb/records/${company.id}?collection=ysterbaard_companies&fields=id,enterprise_name,company_email`).then((r) => r.ok ? r.json() : null).catch(() => null);
|
|
|
|
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";
|
|
};
|
|
|
|
const dirLabel = [director.first_names, director.surname].filter(Boolean).join(" ") || director.id;
|
|
const recipients: Recipient[] = [
|
|
{
|
|
key: "director",
|
|
label: `Director: ${dirLabel}`,
|
|
email: dirDetails?.email ?? director.email ?? "",
|
|
role: "to",
|
|
status: classify(dirDetails?.email ?? director.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 problems = recipients.filter((r) => r.status !== "valid").length;
|
|
setReport({ recipients, 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);
|
|
|
|
try {
|
|
const res = await fetch("/api/resignations", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
director_id: selectedDirector.id,
|
|
company_id: selectedCompany.id,
|
|
proceed_anyway: proceedAnyway,
|
|
}),
|
|
});
|
|
|
|
// The route answers 502 (not 200) when the mail itself failed, and
|
|
// 500 on a hard error. Previously it answered 200 with
|
|
// { ok: false } and this client ignored `ok` — so the UI showed
|
|
// "Letter sent successfully" while nothing had left the machine.
|
|
const data = await res.json().catch(() => ({} as Record<string, unknown>));
|
|
|
|
if (!res.ok) {
|
|
throw new Error(
|
|
(data?.error as string) ||
|
|
(data?.warning as string) ||
|
|
`Letter failed (HTTP ${res.status})`
|
|
);
|
|
}
|
|
|
|
if (data?.ok === false) {
|
|
throw new Error(
|
|
(data?.warning as string) || "The letter was saved but the email could not be sent."
|
|
);
|
|
}
|
|
|
|
const signToken = data?.record?.sign_token;
|
|
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
|
|
setSent({
|
|
id: data?.record?.id || "",
|
|
signUrl: signToken ? `${baseUrl}/resign/${signToken}` : "",
|
|
});
|
|
setReport(null);
|
|
} catch (err: unknown) {
|
|
setSendError(err instanceof Error ? err.message : "Failed to send letter");
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setShowModal(false);
|
|
setSent(null);
|
|
setReport(null);
|
|
setSendError(null);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm font-semibold text-fg">Director resignations</p>
|
|
<button
|
|
onClick={openModal}
|
|
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm hover:opacity-90 transition-opacity"
|
|
>
|
|
Letter of Resignation
|
|
</button>
|
|
</div>
|
|
|
|
{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) closeModal(); }}
|
|
>
|
|
<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 Letter of Resignation</h2>
|
|
<p className="text-sm text-muted mt-0.5">
|
|
{sent ? "Letter sent — share the sign link with the director." :
|
|
report ? "Review the recipient list before sending." :
|
|
"Select a company and director to send the resignation letter for signature."}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-4 overflow-y-auto flex-1">
|
|
{sent ? (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-fg font-semibold">Letter sent successfully.</p>
|
|
{sent.signUrl && (
|
|
<>
|
|
<p className="text-sm text-muted">Share this link with the director to sign:</p>
|
|
<div className="flex gap-2">
|
|
<input readOnly className="flex-1 bg-surface border border-border rounded px-3 py-2 text-sm text-fg font-mono" value={sent.signUrl} />
|
|
<button
|
|
onClick={() => {
|
|
const el = document.createElement("textarea");
|
|
el.value = sent.signUrl;
|
|
el.style.position = "fixed"; el.style.opacity = "0";
|
|
document.body.appendChild(el); el.focus(); el.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(el);
|
|
}}
|
|
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm flex-shrink-0"
|
|
>
|
|
Copy
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<SearchableSelect
|
|
id="res-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="res-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>
|
|
|
|
{sendError && <p className="text-danger text-sm">{sendError}</p>}
|
|
|
|
{report && (
|
|
<div className="border border-border rounded-lg p-3 space-y-2 bg-surface-warm/40">
|
|
<p className="text-xs font-semibold text-muted uppercase tracking-wide">Recipients</p>
|
|
{report.recipients.map((r) => (
|
|
<div key={r.key} className="flex items-center gap-2 text-sm">
|
|
<StatusDot status={r.status} />
|
|
<span className="text-fg">{r.label}</span>
|
|
<span className="text-muted font-mono text-xs truncate">
|
|
{r.email || "(no email)"}
|
|
</span>
|
|
</div>
|
|
))}
|
|
{report.problems > 0 && (
|
|
<p className="text-xs text-amber-700 mt-1">
|
|
⚠ {report.problems} recipient(s) missing or invalid email.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border px-6 py-4 flex justify-end gap-3">
|
|
{sent ? (
|
|
<button onClick={closeModal} className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm hover:opacity-90 transition-opacity">
|
|
Done
|
|
</button>
|
|
) : report ? (
|
|
<>
|
|
<button onClick={() => setReport(null)} disabled={sending} className="px-4 py-2 text-sm font-semibold text-muted hover:text-fg transition-colors">Edit</button>
|
|
<button
|
|
onClick={() => handleSend(report.problems === 0)}
|
|
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"
|
|
>
|
|
{report.problems === 0 ? (sending ? "Sending..." : "Send") : (sending ? "Sending..." : "Send anyway")}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button onClick={closeModal} className="px-4 py-2 text-sm font-semibold text-muted hover:text-fg transition-colors">Cancel</button>
|
|
<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..." : "Continue"}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|