Workflow-App/Workflow/app/cipc-report/CipcReportExplorer.tsx
sjcoetzeesj-coder cd0a818641 feat(cipc): director appointment check tab
Reconcile CIPC BizPortal against the Workflow database for every
director ID number we hold, and report only the two kinds of
difference that need a decision:

  1. new appointment  - CIPC lists the person as a director of company
     X, the database does not.
  2. no longer a director - the database lists the person at company X,
     CIPC no longer shows them there.

Everything that is in sync is not reported, so the list stays short
enough to work through by hand.

- New page /cipc-report with an explorer that runs the check, polls
  the run and lists the exceptions.
- New API route /api/cipc/report: POST starts a background batch run
  of the existing "Bizportal Directopr id no search.py" (invoked
  exactly as it already behaves, including its own login rotation),
  GET returns run status and the exception lists. ?demo=1 returns a
  fixed sample so the screen can be shown without a live run.
- New action route /api/cipc/report/action for the per-exception
  follow-up actions.
- scripts/cipc-report/build_report.py renders the run into a PDF with
  the exceptions marked, plus the sample report used by ?demo=1.

The BizPortal tooling only exists on the Windows dev machine, so like
the other CIPC routes this tab cannot run the live search from the
Coolify Linux image.
2026-10-27 12:19:47 +02:00

577 lines
21 KiB
TypeScript

// app/cipc-report/CipcReportExplorer.tsx
// Client component for the CIPC Report tab.
//
// Runs the CIPC Director Appointment Check and shows ONLY the differences
// between CIPC and the Workflow database:
//
// 1. NEW APPOINTMENT — CIPC shows this person as a director of a company
// the database does not list them as a director of.
// 2. NO LONGER A DIRECTOR — the database lists them as a director of a
// company CIPC no longer shows them at.
//
// Everything already in sync is not listed. If there is nothing to report,
// the tab says so plainly.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import StatCard from "@/components/StatCard";
import StatusBadge from "@/components/StatusBadge";
interface ExceptionRow {
id_no: string;
director: string;
company: string;
enterprise_no: string;
status: string;
reason: "active" | "resigned" | "missing";
director_pb_id: string;
company_pb_id: string;
company_exists: boolean;
}
interface ReportPayload {
ok: boolean;
status: "idle" | "running" | "done" | "error";
is_demo?: boolean;
started_at?: string;
finished_at?: string;
total?: number;
done?: number;
current_id?: string | null;
error?: string;
checked?: number;
failed?: number;
failed_ids?: string[];
new_appointments?: ExceptionRow[];
no_longer_director?: ExceptionRow[];
}
const POLL_MS = 4000;
function rowKey(kind: string, r: ExceptionRow): string {
return `${kind}:${r.id_no}:${r.enterprise_no}`;
}
interface TableProps {
rows: ExceptionRow[];
kind: "link" | "unlink";
emptyText: string;
confirming: string | null;
busy: string | null;
resolved: Set<string>;
messages: Record<string, string>;
/** Sample data must never write to the real database. */
readOnly: boolean;
onRequest: (key: string, r: ExceptionRow) => void;
onCancel: () => void;
}
function ExceptionTable({
rows,
kind,
emptyText,
confirming,
busy,
resolved,
messages,
readOnly,
onRequest,
onCancel,
}: TableProps) {
if (!rows.length) {
return (
<div className="border border-border rounded-lg bg-surface p-8 text-center">
<p className="text-sm text-muted">{emptyText}</p>
</div>
);
}
return (
<div className="border border-border rounded-lg bg-surface overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[900px]">
<thead>
<tr className="border-b border-border bg-surface-warm text-left">
<th className="px-3 py-3 font-semibold text-muted">ID Number</th>
<th className="px-3 py-3 font-semibold text-muted">Director</th>
<th className="px-3 py-3 font-semibold text-muted">Company</th>
<th className="px-3 py-3 font-semibold text-muted">Enterprise No.</th>
<th className="px-3 py-3 font-semibold text-muted">CIPC Status</th>
<th className="px-3 py-3 font-semibold text-muted">Action</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const key = rowKey(kind, r);
const isDone = resolved.has(key);
const isConfirming = confirming === key;
const isBusy = busy === key;
return (
<tr
key={key}
className={`border-b border-border-soft last:border-0 ${
isDone ? "opacity-50" : ""
}`}
>
<td className="px-3 py-3 font-mono text-xs whitespace-nowrap">{r.id_no}</td>
<td className="px-3 py-3 text-fg">
{r.director_pb_id ? (
<Link
href={`/directors/${r.director_pb_id}`}
className="text-fg no-underline hover:underline"
>
{r.director || "—"}
</Link>
) : (
r.director || "—"
)}
</td>
<td className="px-3 py-3 text-fg">
{r.company_pb_id ? (
<Link
href={`/companies/${r.company_pb_id}`}
className="text-fg no-underline hover:underline"
>
{r.company}
</Link>
) : (
<span>
{r.company}
<span className="block text-xs text-muted">not on file yet</span>
</span>
)}
</td>
<td className="px-3 py-3 font-mono text-xs whitespace-nowrap">
{r.enterprise_no}
</td>
<td className="px-3 py-3">
<StatusBadge
variant={/business|active/i.test(r.status) ? "active" : "inactive"}
>
{r.status || "—"}
</StatusBadge>
</td>
<td className="px-3 py-3">
{isDone ? (
<span className="text-xs font-semibold text-[color:var(--success)]">
Done — {messages[key] || "updated"}
</span>
) : isConfirming ? (
<span className="flex flex-col gap-2">
<span className="text-xs text-muted">
{kind === "link"
? r.company_exists
? `Link ${r.director || "this person"} to ${r.company}?`
: `Add ${r.company} to the database and link ${r.director || "this person"}?`
: `Remove ${r.director || "this person"} as director of ${r.company}?`}
</span>
<span className="flex items-center gap-2">
<button
type="button"
onClick={onCancel}
disabled={isBusy}
className="px-3 py-1.5 rounded-pill border border-border text-xs font-semibold text-fg hover:bg-surface-warm disabled:opacity-40"
>
Cancel
</button>
<button
type="button"
onClick={() => onRequest(key, r)}
disabled={isBusy}
className={`px-3 py-1.5 rounded-pill text-xs font-bold text-white disabled:opacity-50 ${
kind === "link" ? "bg-accent" : "bg-[color:var(--danger)]"
}`}
>
{isBusy
? "Saving…"
: kind === "link"
? "Yes, link"
: "Yes, remove"}
</button>
</span>
</span>
) : (
<button
type="button"
onClick={() => onRequest(key, r)}
disabled={readOnly}
title={
readOnly
? "Sample data — run the check to apply changes"
: undefined
}
className={`px-3 py-1.5 rounded-pill border text-xs font-semibold whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed ${
kind === "link"
? "border-accent/50 text-accent hover:bg-accent/10"
: "border-[color:var(--danger)]/50 text-[color:var(--danger)] hover:bg-[color:var(--danger)]/10"
}`}
>
{kind === "link"
? r.company_exists
? "Link"
: "Add & link"
: "Unlink"}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
export default function CipcReportExplorer() {
const [data, setData] = useState<ReportPayload | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState<string | null>(null);
const [actionBusy, setActionBusy] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [resolved, setResolved] = useState<Set<string>>(new Set());
const [messages, setMessages] = useState<Record<string, string>>({});
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopPolling = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
const poll = useCallback(async () => {
try {
const res = await fetch("/api/cipc/report", { cache: "no-store" });
const json = (await res.json()) as ReportPayload;
setData(json);
if (json.status !== "running") {
stopPolling();
setBusy(false);
}
} catch (e: any) {
setError(e?.message || String(e));
stopPolling();
setBusy(false);
}
}, [stopPolling]);
// Resume a run that is already in flight (page reload mid-run).
useEffect(() => {
poll();
return stopPolling;
}, [poll, stopPolling]);
const start = useCallback(async () => {
setBusy(true);
setError(null);
setData(null);
try {
const res = await fetch("/api/cipc/report", { method: "POST" });
const json = (await res.json()) as ReportPayload;
if (!json.ok) {
setError(json.error || "The check could not be started.");
setBusy(false);
return;
}
setData(json);
stopPolling();
pollRef.current = setInterval(poll, POLL_MS);
} catch (e: any) {
setError(e?.message || String(e));
setBusy(false);
}
}, [poll, stopPolling]);
const loadDemo = useCallback(async () => {
setBusy(false);
setError(null);
setConfirming(null);
setActionError(null);
setResolved(new Set());
setMessages({});
try {
const res = await fetch("/api/cipc/report?demo=1", { cache: "no-store" });
const json = (await res.json()) as ReportPayload;
setData(json);
} catch (e: any) {
setError(e?.message || String(e));
}
}, []);
// ── one row at a time ────────────────────────────────────────────────
// First click arms the row (asks for agreement), second click on the
// confirm button writes to the database. Nothing happens in bulk.
const performAction = useCallback(
async (kind: "link" | "unlink", key: string, r: ExceptionRow) => {
setActionBusy(key);
setActionError(null);
try {
const res = await fetch("/api/cipc/report/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: kind,
id_no: r.id_no,
enterprise_no: r.enterprise_no,
enterprise_name: r.company,
enterprise_status: r.status,
}),
});
const json = await res.json();
if (!json.ok) {
setActionError(json.error || "The change could not be saved.");
return;
}
setResolved((prev) => new Set(prev).add(key));
setMessages((prev) => ({ ...prev, [key]: json.message || "updated" }));
setConfirming(null);
} catch (e: any) {
setActionError(e?.message || String(e));
} finally {
setActionBusy(null);
}
},
[]
);
const onLink = useCallback(
(key: string, r: ExceptionRow) => {
if (confirming === key) {
void performAction("link", key, r);
} else {
setConfirming(key);
setActionError(null);
}
},
[confirming, performAction]
);
const onUnlink = useCallback(
(key: string, r: ExceptionRow) => {
if (confirming === key) {
void performAction("unlink", key, r);
} else {
setConfirming(key);
setActionError(null);
}
},
[confirming, performAction]
);
const onCancel = useCallback(() => setConfirming(null), []);
const running = data?.status === "running";
const done = data?.status === "done";
const newRows = data?.new_appointments || [];
const oldRows = data?.no_longer_director || [];
// Rows already applied are no longer outstanding.
const openNewCount = useMemo(
() => newRows.filter((r) => !resolved.has(rowKey("link", r))).length,
[newRows, resolved]
);
const openOldCount = useMemo(
() => oldRows.filter((r) => !resolved.has(rowKey("unlink", r))).length,
[oldRows, resolved]
);
const nothingToReport = done && openNewCount === 0 && openOldCount === 0;
return (
<div className="flex flex-col gap-8">
{/* ---------------- run bar ---------------- */}
<div className="border border-border rounded-lg bg-surface p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-fg">Director appointment check</h2>
<p className="text-sm text-muted mt-1 max-w-2xl">
Looks up every director ID number we hold on CIPC and compares the result with
our database. Only the differences are listed.
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={loadDemo}
disabled={running}
className="px-4 py-2.5 rounded-pill border border-border text-sm font-semibold text-fg hover:bg-surface-warm disabled:opacity-40 disabled:cursor-not-allowed"
>
View sample
</button>
<button
type="button"
onClick={start}
disabled={running}
className="px-5 py-2.5 rounded-pill bg-accent text-accent-on text-sm font-semibold hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed"
>
{running ? "Running…" : "Run CIPC Director Check"}
</button>
</div>
</div>
{running ? (
<div className="flex items-center gap-4 text-sm">
<div className="flex-1 h-2 rounded-pill bg-surface-warm overflow-hidden">
<div
className="h-full bg-accent transition-all"
style={{
width: `${
data?.total ? Math.min(100, ((data.done || 0) / data.total) * 100) : 5
}%`,
}}
/>
</div>
<span className="text-muted whitespace-nowrap tabular-nums">
{data?.done || 0} of {data?.total || 0} checked
{data?.current_id ? ` · ${data.current_id}` : ""}
</span>
</div>
) : null}
{error ? (
<div className="text-sm text-[color:var(--danger)] border border-[color:var(--danger)]/40 rounded-pill px-4 py-2.5 bg-[color-mix(in_oklch,var(--danger)_10%,var(--surface))]">
{error}
</div>
) : null}
{data?.status === "error" ? (
<div className="text-sm text-[color:var(--danger)] border border-[color:var(--danger)]/40 rounded-pill px-4 py-2.5 bg-[color-mix(in_oklch,var(--danger)_10%,var(--surface))]">
{data.error}
</div>
) : null}
</div>
{/* ---------------- results ---------------- */}
{done ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<StatCard
label="IDs checked"
value={data?.checked ?? 0}
hint={
data?.failed
? `${data.failed} lookup${data.failed === 1 ? "" : "s"} failed — re-run those`
: "Every lookup completed"
}
/>
<StatCard
label="New appointments"
value={openNewCount}
hint="Directors at companies we do not have them for"
accent={openNewCount > 0}
/>
<StatCard
label="No longer a director"
value={openOldCount}
hint="Appointments to close off in the database"
accent={openOldCount > 0}
/>
</div>
{actionError ? (
<div className="text-sm text-[color:var(--danger)] border border-[color:var(--danger)]/40 rounded-pill px-4 py-2.5 bg-[color-mix(in_oklch,var(--danger)_10%,var(--surface))]">
{actionError}
</div>
) : null}
{data?.failed_ids?.length ? (
<div className="border border-border rounded-lg bg-surface p-5">
<h3 className="text-sm font-semibold text-fg">
{data.failed_ids.length} ID number
{data.failed_ids.length === 1 ? "" : "s"} could not be checked
</h3>
<p className="text-sm text-muted mt-1">
CIPC rejected these numbers, so the report is not complete for them. Most
likely the ID number on file has a wrong check digit or an impossible date.
</p>
<div className="flex flex-wrap gap-2 mt-3">
{data.failed_ids.map((id) => (
<span
key={id}
className="px-2.5 py-1 rounded-pill border border-border font-mono text-xs text-muted"
>
{id}
</span>
))}
</div>
</div>
) : null}
{nothingToReport ? (
<div className="border border-[color:var(--success)]/40 rounded-lg bg-[color-mix(in_oklch,var(--success)_10%,var(--surface))] p-10 text-center">
<h3 className="text-xl font-semibold text-[color:var(--success)]">
No exceptions found
</h3>
<p className="text-sm text-muted mt-2 max-w-xl mx-auto">
Every director ID checked matches the database — no new appointments and nobody
has left a company we still have them listed at.
</p>
</div>
) : null}
<section className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-xl font-bold text-fg">New director appointments</h2>
<span className="text-sm text-muted">
CIPC shows them here, we do not
</span>
</div>
<ExceptionTable
rows={newRows}
kind="link"
emptyText="No new appointments — every director CIPC lists is already on our records."
confirming={confirming}
busy={actionBusy}
resolved={resolved}
messages={messages}
readOnly={Boolean(data?.is_demo)}
onRequest={onLink}
onCancel={onCancel}
/>
</section>
<section className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-xl font-bold text-fg">No longer a director</h2>
<span className="text-sm text-muted">
We still list them here, CIPC does not
</span>
</div>
<ExceptionTable
rows={oldRows}
kind="unlink"
emptyText="Nobody has left a company we still list them as a director of."
confirming={confirming}
busy={actionBusy}
resolved={resolved}
messages={messages}
readOnly={Boolean(data?.is_demo)}
onRequest={onUnlink}
onCancel={onCancel}
/>
</section>
{data?.is_demo ? (
<p className="text-xs text-meta">
This is sample data. Run the check to replace it with live results.
</p>
) : null}
</>
) : null}
{!done && !running ? (
<div className="border border-border rounded-lg bg-surface p-10 text-center">
<h3 className="text-lg font-semibold text-fg">No run yet</h3>
<p className="text-sm text-muted mt-2 max-w-lg mx-auto">
Press <span className="font-semibold text-fg">Run CIPC Director Check</span> to look
up every director ID number we hold, or open the sample to see what the report
looks like.
</p>
</div>
) : null}
</div>
);
}