Compare commits

...

3 commits

Author SHA1 Message Date
sjcoetzeesj-coder
dbcb378636 chore(dev): keep the detached dev-server start script
Running "npm run dev" as a tracked agent task gets the process killed
when the runtime cap is reached, which leaves the machine with no dev
server on port 6001. This script starts it with Start-Process so it
survives session churn, and it refuses to start a second server when
the port is already taken.

The older .run-dev.ps1 in the same folder is broken and is replaced by
this one: it invokes the Next binary without its extension, which
PowerShell cannot execute, so node.exe is called explicitly instead.
2026-10-27 12:20:16 +02:00
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
sjcoetzeesj-coder
3595cd54bb feat(nav): replace squeezed mobile tab bar with a header menu
The mobile bottom bar rendered all 15 routes into a single row. On a
390px iPhone that works out at ~17px per tab: the icons overlap, every
label truncates to a single letter and the tap targets sit far below
the 44px minimum, so the tabs could not be read or hit reliably.

Mobile now uses a hamburger in the header that opens a full-screen menu
listing every section, grouped (Main / CIPC & BizPortal / SARS /
Practice). The panel locks background scroll, closes on Escape or on
navigation, and carries the theme toggle so the mobile chrome keeps
parity with the desktop cluster.

Desktop had the same problem in a different shape. The full 15-link row
needed 1752px inside a 1280px container, so the page scrolled sideways
even at 1920px (measured scrollWidth 2065 vs a 1920 viewport) and the
right-hand cluster was pushed off screen. The inline nav now carries
six primary links plus a "More" dropdown, which fits exactly.

- NavBar: mobile tab bar replaced by the header menu panel; desktop nav
  reduced to primary links + More dropdown; grouped list shared by both
  so the two surfaces cannot drift apart.
- RouteChrome: dropped the pb-24 md:pb-0 padding that only existed to
  clear the old bottom bar.
- .gitignore / Workflow/.dockerignore: ignore .next-stale* so a stale
  build directory cannot bloat the Docker build context.
2026-10-27 12:16:58 +02:00
11 changed files with 2522 additions and 71 deletions

10
.gitignore vendored
View file

@ -9,6 +9,7 @@
# ----------------------------------------------- deps, build & tooling ---
node_modules/
.next/
.next-stale*/
out/
dist/
build/
@ -29,6 +30,15 @@ npm-debug.log*
scratch-*/
_backups/
# --------------------------- CIPC report run state (generated) --------
# The report explorer writes its run folder, produced PDFs and sample
# page renders at runtime — none of that belongs in the repo.
Workflow/.cipc-report/
Workflow/reports/
Workflow/scripts/one-id.txt
Workflow/scripts/cipc-report/_sample*/
Workflow/scripts/cipc-report/sample-results*.json
# --------------------------------- debug captures & one-off probe output ---
# Ad-hoc dumps kept while debugging SARS / CIPC screens. Not project source.
_probe*.json

View file

@ -14,6 +14,7 @@
# --- deps, build output, git history ----------------------------------
node_modules
.next
.next-stale*
out
dist
build

View file

@ -0,0 +1,30 @@
# Start the Workflow dev server fully detached from the agent session.
#
# WHY THIS FILE EXISTS
# Running "npm run dev" as a tracked background task gets the process
# killed when the runtime 3600000ms cap is reached (about 1 hour).
# Start-Process detaches it, so it survives session churn.
#
# NOTE: the older .run-dev.ps1 in this folder is broken. It calls
# & '.\node_modules\next\dist\bin\next'
# but that path has no extension, so PowerShell cannot execute it.
# node.exe must be invoked explicitly, which is what this script does.
#
# Usage: powershell -NoProfile -ExecutionPolicy Bypass -File .start-dev-detached.ps1
$ErrorActionPreference = 'Continue'
$root = 'C:\Users\SJ\.minimax-agent\projects\Workflow'
$logFile = Join-Path $root '.dev-detached.log'
# Do not start a second server if one is already listening on 6001.
$existing = (Get-NetTCPConnection -LocalPort 6001 -State Listen -ErrorAction SilentlyContinue).OwningProcess
if ($existing) {
Write-Host "Port 6001 already in use by PID $existing. Nothing to do."
exit 0
}
$proc = Start-Process -FilePath 'node.exe' -ArgumentList 'node_modules\next\dist\bin\next','dev','-p','6001' -WorkingDirectory $root -WindowStyle Hidden -RedirectStandardOutput $logFile -RedirectStandardError "$logFile.err" -PassThru
Write-Host "Workflow dev server started detached (PID $($proc.Id))."
Write-Host "Log: $logFile"

View file

@ -0,0 +1,252 @@
// app/api/cipc/report/action/route.ts
// Applies ONE agreed exception from the CIPC Report tab to the database.
//
// POST /api/cipc/report/action
// { action: "link", id_no, enterprise_no, enterprise_name, enterprise_status }
// { action: "unlink", id_no, enterprise_no }
//
// "link" — the person IS a director of the company on CIPC but we do not
// have that appointment. Creates the company if it is not on file,
// then creates the junction link.
// "unlink" — we still list the person as a director of the company but CIPC
// no longer does. Removes the junction link only; the company and
// the director record are left alone.
//
// One row at a time by design — SJ works through the list himself.
import { NextRequest, NextResponse } from "next/server";
import fs from "node:fs";
import path from "node:path";
import {
createRecord,
deleteRecord,
fetchCollection,
pbFetch,
recomputeDirectorCompanyCounts,
PBRecord,
} from "@/lib/pb";
export const dynamic = "force-dynamic";
const COMPANIES = "ysterbaard_companies";
const DIRECTORS = "ysterbaard_directors";
const JUNCTION = "pbc_2497518006"; // ysterbaard_company_directors
const DEMO_FILE = path.join(process.cwd(), "scripts", "cipc-report", "sample-report.json");
/**
* Sample rows must never be written to the real database. The client
* disables the buttons for sample data, but this is the server-side guard
* for the case where someone posts the sample values directly.
*/
function isSampleEnterpriseNo(entNo: string): boolean {
try {
if (!fs.existsSync(DEMO_FILE)) return false;
const demo = JSON.parse(fs.readFileSync(DEMO_FILE, "utf-8"));
const rows = [...(demo.new_appointments || []), ...(demo.no_longer_director || [])];
return rows.some(
(r: { enterprise_no?: string }) =>
canonicaliseEnterpriseNo(r.enterprise_no || "") === entNo
);
} catch {
return false;
}
}
/** PB filter values are double-quoted; escape embedded quotes. */
function q(v: string): string {
return `"${String(v ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
/** Mirror of the canonicalisation in /api/cipc/save-selection. */
function canonicaliseEnterpriseNo(raw: string): string {
const t = (raw || "").trim().toUpperCase();
if (!t) return "";
const slash = t.match(/^(\d{4})\/(\d{6,7})(?:\/\d{2})?$/);
if (slash) return `K${slash[1]}${slash[2]}`;
if (/^[KMB]\d{10,12}$/.test(t)) return t;
if (/^\d{10,12}$/.test(t)) return `K${t}`;
return t;
}
const today = () => new Date().toISOString().slice(0, 10);
/** The director record that holds this ID number, if we have one. */
async function findDirectorByIdNo(idNo: string): Promise<PBRecord | null> {
const v = String(idNo ?? "").replace(/\s/g, "");
if (!v) return null;
const found = await fetchCollection<PBRecord>(DIRECTORS, {
perPage: 1,
filter: `id_no = ${q(v)} || id_no_masked = ${q(v)}`,
});
return found.items[0] || null;
}
/** The company record with this enterprise number, if we have one. */
async function findCompanyByEntNo(entNo: string): Promise<PBRecord | null> {
const canonical = canonicaliseEnterpriseNo(entNo);
if (!canonical) return null;
const found = await fetchCollection<PBRecord>(COMPANIES, {
perPage: 1,
filter: `enterprise_no = ${q(canonical)}`,
});
return found.items[0] || null;
}
export async function POST(req: NextRequest) {
let body: {
action?: "link" | "unlink";
id_no?: string;
enterprise_no?: string;
enterprise_name?: string;
enterprise_status?: string;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
}
const action = body.action;
const idNo = String(body.id_no ?? "").trim();
const entNoRaw = String(body.enterprise_no ?? "").trim();
if (action !== "link" && action !== "unlink") {
return NextResponse.json({ ok: false, error: "action must be 'link' or 'unlink'" }, { status: 400 });
}
if (!idNo) {
return NextResponse.json({ ok: false, error: "id_no is required" }, { status: 400 });
}
if (!entNoRaw) {
return NextResponse.json({ ok: false, error: "enterprise_no is required" }, { status: 400 });
}
const entNo = canonicaliseEnterpriseNo(entNoRaw);
if (isSampleEnterpriseNo(entNo)) {
return NextResponse.json(
{
ok: false,
error:
"This row is sample data. Run the CIPC Director Check to get live results before " +
"changing the database.",
},
{ status: 400 }
);
}
try {
const director = await findDirectorByIdNo(idNo);
if (!director) {
return NextResponse.json(
{
ok: false,
error:
"We have no director record for this ID number, so the appointment cannot be " +
"recorded against anyone. Add the person to the Directors tab first.",
},
{ status: 404 }
);
}
// ── UNLINK ───────────────────────────────────────────────────────────
if (action === "unlink") {
const company = await findCompanyByEntNo(entNo);
if (!company) {
return NextResponse.json(
{ ok: false, error: "We have no company record for this enterprise number." },
{ status: 404 }
);
}
const existing = await pbFetch<{ items: PBRecord[] }>(
`/api/collections/${JUNCTION}/records?filter=${encodeURIComponent(
`company = ${q(company.id)} && director = ${q(director.id)}`
)}&perPage=1`
);
const link = existing.items?.[0];
if (!link) {
return NextResponse.json(
{ ok: false, error: "That appointment is not linked, so there is nothing to remove." },
{ status: 404 }
);
}
await deleteRecord(JUNCTION, link.id);
await recomputeDirectorCompanyCounts(director.id).catch((e: unknown) => {
console.error("[cipc:report/action] counter recompute failed", e);
});
return NextResponse.json({
ok: true,
action,
company_id: company.id,
director_id: director.id,
message: `${director.surname ?? ""} removed as director of ${company.enterprise_name}.`,
});
}
// ── LINK ─────────────────────────────────────────────────────────────
let company = await findCompanyByEntNo(entNo);
let companyCreated = false;
if (!company) {
const name = String(body.enterprise_name ?? "").trim();
if (!name) {
return NextResponse.json(
{ ok: false, error: "Company name is required to create a new company record." },
{ status: 400 }
);
}
company = await createRecord<PBRecord>(COMPANIES, {
enterprise_no: entNo,
enterprise_name: name,
enterprise_status: String(body.enterprise_status ?? "").trim(),
is_active: false,
client_status: "non_client",
first_seen: today(),
last_checked: today(),
source: "cipc:report",
});
companyCreated = true;
}
// Already linked? Nothing to do.
const existing = await pbFetch<{ items: PBRecord[] }>(
`/api/collections/${JUNCTION}/records?filter=${encodeURIComponent(
`company = ${q(company.id)} && director = ${q(director.id)}`
)}&perPage=1`
);
if (existing.items?.[0]) {
return NextResponse.json({
ok: true,
action,
already: true,
company_id: company.id,
director_id: director.id,
message: "That appointment was already on file.",
});
}
await createRecord<PBRecord>(JUNCTION, {
company: company.id,
director: director.id,
director_type: "Director",
});
await recomputeDirectorCompanyCounts(director.id).catch((e: unknown) => {
console.error("[cipc:report/action] counter recompute failed", e);
});
return NextResponse.json({
ok: true,
action,
company_created: companyCreated,
company_id: company.id,
director_id: director.id,
message: companyCreated
? `${company.enterprise_name} added and ${director.surname ?? ""} linked as director.`
: `${director.surname ?? ""} linked as director of ${company.enterprise_name}.`,
});
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : "Unknown error" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,619 @@
// app/api/cipc/report/route.ts
// CIPC Director Appointment Check — reconciles CIPC BizPortal against the
// Workflow database for every director ID we hold.
//
// WHAT IT REPORTS (and only this):
//
// 1. NEW APPOINTMENT — CIPC says this person is a director of company X,
// but the database does not list them as a director of X.
// 2. NO LONGER A DIRECTOR — the database lists this person as a director of
// company X, but CIPC no longer shows them there (removed from the
// CIPC record, or shown as resigned).
//
// Everything else is in sync and is NOT reported.
//
// HOW IT WORKS
// POST → starts a background run of "Bizportal Directopr id no search.py"
// in --batch mode, writing a log we poll.
// GET → returns run status; once the run finishes, returns the two
// exception lists. `?demo=1` returns a fixed sample instead.
//
// The .py is co-authored and is NOT modified by this route — it is invoked
// exactly as it already behaves, including its own login rotation.
import { NextRequest, NextResponse } from "next/server";
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pbFetch } from "@/lib/pb";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
const SCRIPTS_DIR = path.join(process.cwd(), "scripts");
const RUN_DIR = path.join(process.cwd(), ".cipc-report");
const SCRIPT_NAME = "Bizportal Directopr id no search.py";
const BATCH_FILE = path.join(RUN_DIR, "ids.txt");
const LOG_FILE = path.join(RUN_DIR, "run.log");
const STATE_FILE = path.join(RUN_DIR, "state.json");
const DEMO_FILE = path.join(SCRIPTS_DIR, "cipc-report", "sample-report.json");
// ===================================================================
// TYPES
// ===================================================================
export interface CbizRow {
enterprise_number: string;
enterprise_name: string;
enterprise_status: string;
director_status: string;
}
export interface ExceptionRow {
id_no: string;
director: string;
company: string;
enterprise_no: string;
status: string;
/** "active" for a new appointment, "resigned" / "missing" for a departure. */
reason: "active" | "resigned" | "missing";
/** ysterbaard_directors record id — empty when we hold no director record. */
director_pb_id: string;
/** ysterbaard_companies record id — empty when we hold no company record. */
company_pb_id: string;
/** True when the company itself is already on file. */
company_exists: boolean;
}
export 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;
message?: string;
error?: string;
checked?: number;
failed?: number;
failed_ids?: string[];
new_appointments?: ExceptionRow[];
no_longer_director?: ExceptionRow[];
}
// ===================================================================
// PB READ HELPERS
// ===================================================================
async function fetchAll<T>(collection: string): Promise<T[]> {
const out: T[] = [];
let page = 1;
for (;;) {
const list = await pbFetch<{ items?: T[]; totalPages?: number }>(
`/api/collections/${collection}/records`,
{ page, perPage: 200 }
);
const items = list.items || [];
out.push(...items);
if (page >= (list.totalPages || 1) || items.length === 0) break;
page += 1;
}
return out;
}
interface PbDirector {
id: string;
id_no?: string;
first_names?: string;
surname?: string;
}
interface PbCompany {
id: string;
enterprise_no?: string;
enterprise_name?: string;
enterprise_status?: string;
}
interface PbLink {
company?: string;
director?: string;
}
/** Normalised enterprise number used for matching. */
function entKey(v: unknown): string {
return String(v ?? "").trim().toUpperCase();
}
/**
* CIPC reports the director status as ACTIVE / INACTIVE. A naive
* /active/i test would read "INACTIVE" as active, so check for the
* negative first.
*/
function directorIsActive(status: unknown): boolean {
const s = String(status ?? "").trim().toLowerCase();
if (!s) return false;
if (s.includes("inactive")) return false;
return s.includes("active");
}
function fullName(first?: string, surname?: string): string {
return [first || "", surname || ""].join(" ").replace(/\s+/g, " ").trim();
}
/** A usable full SA ID: 13 digits, not an all-zero placeholder. */
function isFullId(v: unknown): boolean {
const s = String(v ?? "").trim();
return /^\d{13}$/.test(s) && !/^0{13}$/.test(s);
}
// ===================================================================
// THE ID LIST
//
// The same population as the "Bizportal Search List" export: every full
// 13-digit ID across the four collections that hold one, de-duplicated.
// ===================================================================
async function collectTargetIds(): Promise<{
ids: string[];
names: Map<string, string>;
}> {
const directors = await fetchAll<PbDirector>("ysterbaard_directors");
const regDirectors = await fetchAll<PbDirector>("ysterbaard_reg_directors");
const shareholders = await fetchAll<{ id_or_entity_no?: string; name?: string }>(
"ysterbaard_reg_shareholders"
);
const onboarding = await fetchAll<{ directors_data?: any }>(
"ysterbaard_onboarding_submissions"
);
const names = new Map<string, string>();
const ids: string[] = [];
const seen = new Set<string>();
const push = (id: unknown, name?: string) => {
const s = String(id ?? "").trim();
if (!isFullId(s) || seen.has(s)) return;
seen.add(s);
ids.push(s);
if (name && !names.has(s)) names.set(s, name);
};
for (const d of directors) push(d.id_no, fullName(d.first_names, d.surname));
for (const d of regDirectors) push(d.id_no, fullName(d.first_names, d.surname));
for (const s of shareholders) {
const raw = String(s.name ?? "").trim();
const parts = raw.split(/\s+/);
push(s.id_or_entity_no, parts.length > 1 ? parts.slice(0, -1).join(" ") + " " + parts[parts.length - 1] : raw);
}
for (const o of onboarding) {
const dd = o.directors_data;
const list = typeof dd === "string" ? safeJson(dd) : dd;
for (const p of Array.isArray(list) ? list : []) {
push(p?.id_no, fullName(p?.first_names, p?.surname));
}
}
return { ids, names };
}
function safeJson(s: string): any {
try {
return JSON.parse(s);
} catch {
return [];
}
}
// ===================================================================
// LOG PARSING
//
// The .py prints one block per ID:
//
// ========== Director ID 8901010001087 ==========
// logged in OK (Login 3)
// Searches Conducted Today: 44
//
// | Enterprise Number | Enterprise Name | Enterprise Status | Director Status |
// |---|---|---|---|
// | K2026108471 | BRANDLINE HOLDINGS (PTY) LTD | In Business | Active |
//
// ===================================================================
function parseDirectorLog(stdout: string): Map<string, CbizRow[]> {
const out = new Map<string, CbizRow[]>();
const marker = /=+\s*Director ID\s+(\d{13})\s*=+/g;
const bounds: { id: string; start: number; end: number }[] = [];
let m: RegExpExecArray | null;
while ((m = marker.exec(stdout)) !== null) {
bounds.push({ id: m[1], start: m.index, end: -1 });
}
bounds.forEach((b, i) => {
b.end = i + 1 < bounds.length ? bounds[i + 1].start : stdout.length;
});
for (const b of bounds) {
const body = stdout.slice(b.start, b.end);
const rows: CbizRow[] = [];
for (const line of body.split(/\r?\n/)) {
const t = line.trim();
if (!t.startsWith("|")) continue;
const cells = t.split("|").slice(1, -1).map((c) => c.trim());
if (cells.every((c) => /^[-:]+$/.test(c))) continue;
if (cells[0] === "Enterprise Number") continue;
if (!/^[KMB]\d{10,}$/.test(cells[0])) continue;
rows.push({
enterprise_number: cells[0],
enterprise_name: cells[1] ?? "",
enterprise_status: cells[2] ?? "",
director_status: cells[3] ?? "",
});
}
out.set(b.id, rows);
}
return out;
}
/** IDs the .py announced but produced no table for. */
function parseFailedIds(stdout: string): Set<string> {
const failed = new Set<string>();
const marker = /=+\s*Director ID\s+(\d{13})\s*=+/g;
const bounds: { id: string; start: number; end: number }[] = [];
let m: RegExpExecArray | null;
while ((m = marker.exec(stdout)) !== null) {
bounds.push({ id: m[1], start: m.index, end: -1 });
}
bounds.forEach((b, i) => {
b.end = i + 1 < bounds.length ? bounds[i + 1].start : stdout.length;
});
for (const b of bounds) {
const body = stdout.slice(b.start, b.end);
if (/WARN: no director-table rows found/.test(body) || /ERROR:/.test(body)) {
failed.add(b.id);
}
}
return failed;
}
// ===================================================================
// RECONCILIATION
// ===================================================================
async function buildReport(stdout: string): Promise<{
new_appointments: ExceptionRow[];
no_longer_director: ExceptionRow[];
checked: number;
failed: number;
failed_ids: string[];
}> {
const { ids, names } = await collectTargetIds();
const directors = await fetchAll<PbDirector>("ysterbaard_directors");
const companies = await fetchAll<PbCompany>("ysterbaard_companies");
const links = await fetchAll<PbLink>("ysterbaard_company_directors");
const companyById = new Map(companies.map((c) => [c.id, c]));
const companyByEnt = new Map(companies.map((c) => [entKey(c.enterprise_no), c]));
// director record id → set of enterprise numbers they are linked to
const linkedEntByDirector = new Map<string, Set<string>>();
for (const l of links) {
if (!l.director || !l.company) continue;
const comp = companyById.get(l.company);
if (!comp) continue;
const key = entKey(comp.enterprise_no);
if (!key) continue;
if (!linkedEntByDirector.has(l.director)) linkedEntByDirector.set(l.director, new Set());
linkedEntByDirector.get(l.director)!.add(key);
}
// ID number → director record id (so a person found only in reg_* can still
// be matched to the directors record that holds their company links)
const dirRecByIdNo = new Map<string, string>();
for (const d of directors) {
const k = String(d.id_no ?? "").trim();
if (isFullId(k) && !dirRecByIdNo.has(k)) dirRecByIdNo.set(k, d.id);
}
const biz = parseDirectorLog(stdout);
const failedIds = parseFailedIds(stdout);
const newAppointments: ExceptionRow[] = [];
const noLonger: ExceptionRow[] = [];
const failedList: string[] = [];
let checked = 0;
let failed = 0;
for (const id of ids) {
if (failedIds.has(id)) {
failed += 1;
failedList.push(id);
continue;
}
const rows = biz.get(id);
if (!rows) {
failed += 1;
failedList.push(id);
continue;
}
checked += 1;
const name = names.get(id) || "";
const dirRec = dirRecByIdNo.get(id);
const linked = dirRec ? linkedEntByDirector.get(dirRec) || new Set<string>() : new Set<string>();
// 1. NEW APPOINTMENT — active at CIPC, but not linked in our database.
for (const r of rows) {
const key = entKey(r.enterprise_number);
if (!key || linked.has(key)) continue;
if (!directorIsActive(r.director_status)) continue;
const known = companyByEnt.get(key);
newAppointments.push({
id_no: id,
director: name,
company: r.enterprise_name,
enterprise_no: r.enterprise_number,
status: r.enterprise_status || "",
reason: "active",
director_pb_id: dirRec || "",
company_pb_id: known?.id || "",
company_exists: Boolean(known),
});
}
// 2. NO LONGER A DIRECTOR — linked in our database, but CIPC no longer
// shows them there (resigned, or dropped from the record).
const bizByEnt = new Map(rows.map((r) => [entKey(r.enterprise_number), r]));
for (const key of linked) {
const row = bizByEnt.get(key);
const comp = companyByEnt.get(key);
if (!row) {
noLonger.push({
id_no: id,
director: name,
company: comp?.enterprise_name || key,
enterprise_no: comp?.enterprise_no || key,
status: comp?.enterprise_status || "",
reason: "missing",
director_pb_id: dirRec || "",
company_pb_id: comp?.id || "",
company_exists: Boolean(comp),
});
continue;
}
if (!directorIsActive(row.director_status)) {
noLonger.push({
id_no: id,
director: name,
company: row.enterprise_name,
enterprise_no: row.enterprise_number,
status: row.enterprise_status || "",
reason: "resigned",
director_pb_id: dirRec || "",
company_pb_id: comp?.id || "",
company_exists: Boolean(comp),
});
}
}
}
return {
new_appointments: newAppointments,
no_longer_director: noLonger,
checked,
failed,
failed_ids: failedList,
};
}
// ===================================================================
// RUN STATE
// ===================================================================
interface RunState {
status: "idle" | "running" | "done" | "error";
started_at?: string;
finished_at?: string;
total?: number;
error?: string;
pid?: number;
}
function readState(): RunState {
try {
if (!fs.existsSync(STATE_FILE)) return { status: "idle" };
return JSON.parse(fs.readFileSync(STATE_FILE, "utf-8")) as RunState;
} catch {
return { status: "idle" };
}
}
function writeState(s: RunState): void {
fs.mkdirSync(RUN_DIR, { recursive: true });
fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2), "utf-8");
}
function progressFromLog(): { done: number; current_id: string | null } {
try {
if (!fs.existsSync(LOG_FILE)) return { done: 0, current_id: null };
const log = fs.readFileSync(LOG_FILE, "utf-8");
const seen = [...log.matchAll(/=+\s*Director ID\s+(\d{13})\s*=+/g)].map((m) => m[1]);
const done = seen.length;
const last = seen.length ? seen[seen.length - 1] : null;
return { done, current_id: last };
} catch {
return { done: 0, current_id: null };
}
}
function pythonPath(): string {
return path.join(
SCRIPTS_DIR,
".venv",
"Scripts",
process.platform === "win32" ? "python.exe" : "python"
);
}
// ===================================================================
// ROUTES
// ===================================================================
export async function GET(req: NextRequest) {
const demo = req.nextUrl.searchParams.get("demo") === "1";
if (demo) {
try {
const payload = JSON.parse(fs.readFileSync(DEMO_FILE, "utf-8"));
return NextResponse.json({ ...payload, ok: true, is_demo: true, status: "done" });
} catch (err: any) {
return NextResponse.json(
{ ok: false, error: `Demo data unavailable: ${err?.message || String(err)}` },
{ status: 500 }
);
}
}
const state = readState();
const progress = progressFromLog();
if (state.status === "running") {
return NextResponse.json({
ok: true,
status: "running",
started_at: state.started_at,
total: state.total,
done: progress.done,
current_id: progress.current_id,
} satisfies ReportPayload);
}
if (state.status === "error") {
return NextResponse.json({
ok: false,
status: "error",
error: state.error,
started_at: state.started_at,
finished_at: state.finished_at,
total: state.total,
done: progress.done,
} satisfies ReportPayload);
}
if (state.status === "done") {
let stdout = "";
try {
stdout = fs.readFileSync(LOG_FILE, "utf-8");
} catch {
stdout = "";
}
const report = await buildReport(stdout);
return NextResponse.json({
ok: true,
status: "done",
started_at: state.started_at,
finished_at: state.finished_at,
total: state.total,
done: progress.done,
checked: report.checked,
failed: report.failed,
failed_ids: report.failed_ids,
new_appointments: report.new_appointments,
no_longer_director: report.no_longer_director,
} satisfies ReportPayload);
}
return NextResponse.json({ ok: true, status: "idle" } satisfies ReportPayload);
}
export async function POST() {
const state = readState();
if (state.status === "running") {
const progress = progressFromLog();
return NextResponse.json({
ok: true,
status: "running",
started_at: state.started_at,
total: state.total,
done: progress.done,
current_id: progress.current_id,
} satisfies ReportPayload);
}
const { ids } = await collectTargetIds();
if (ids.length === 0) {
return NextResponse.json(
{ ok: false, error: "No director ID numbers with a full 13-digit number were found." },
{ status: 400 }
);
}
fs.mkdirSync(RUN_DIR, { recursive: true });
fs.writeFileSync(BATCH_FILE, ids.join("\n") + "\n", "utf-8");
fs.writeFileSync(LOG_FILE, "", "utf-8");
const py = pythonPath();
if (!fs.existsSync(py)) {
return NextResponse.json(
{ ok: false, error: `Python runtime not found at ${py}` },
{ status: 500 }
);
}
const logFd = fs.openSync(LOG_FILE, "a");
const child = spawn(py, [path.join(SCRIPTS_DIR, SCRIPT_NAME), "--batch", BATCH_FILE], {
cwd: SCRIPTS_DIR,
windowsHide: true,
// The child's stdout/stderr go straight to the log file. Piping them
// through Node makes the script's own agent-browser subprocess calls
// block — the .py's first browser call never returned when spawned
// this way, and the run sat there for 9 minutes with no output.
stdio: ["ignore", logFd, logFd],
});
writeState({
status: "running",
started_at: new Date().toISOString(),
total: ids.length,
pid: child.pid,
});
child.on("close", (code) => {
const finished = new Date().toISOString();
if (code === 0 || code === 1) {
writeState({
status: "done",
started_at: readState().started_at,
finished_at: finished,
total: ids.length,
});
} else {
writeState({
status: "error",
started_at: readState().started_at,
finished_at: finished,
total: ids.length,
error:
code === 2
? "BizPortal stopped the run — the daily search limit was reached or the subscription expired. Run it again tomorrow."
: `The lookup script exited with code ${code}.`,
});
}
});
child.on("error", (err) => {
writeState({
status: "error",
started_at: readState().started_at,
finished_at: new Date().toISOString(),
total: ids.length,
error: err?.message || String(err),
});
});
return NextResponse.json({
ok: true,
status: "running",
started_at: new Date().toISOString(),
total: ids.length,
} satisfies ReportPayload);
}

View file

@ -0,0 +1,577 @@
// 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>
);
}

View file

@ -0,0 +1,31 @@
// app/cipc-report/page.tsx
// CIPC Report tab — reconciles CIPC BizPortal against the Workflow
// database for every director ID we hold, and reports only the differences:
// 1. new director appointments (CIPC has them, we do not)
// 2. appointments to close off (we have them, CIPC does not)
import PageHeader from "@/components/PageHeader";
import CipcReportExplorer from "./CipcReportExplorer";
export const dynamic = "force-dynamic";
export default function CipcReportPage() {
return (
<>
<PageHeader
eyebrow="CIPC Report"
title="Director appointment check"
subtitle={
<span>
Checks every director ID number we hold against CIPC and shows only the differences:
new appointments at companies we do not have them for, and appointments to close
off because the person is no longer a director.
</span>
}
/>
<section className="container-x py-10">
<CipcReportExplorer />
</section>
</>
);
}

View file

@ -3,11 +3,17 @@
// center, secondary status on the right. Sticky on scroll with the
// signature hairline border-bottom.
//
// Mobile (<md): a fixed bottom tab bar with inline-SVG icons (Overview
// · Directors · Companies · Onboarding). The second-row horizontal
// scroller from earlier turns has been replaced because the tab bar
// is the standard mobile-app pattern and reclaims vertical screen
// real estate.
// Mobile (<md): no tab bar. The header carries a hamburger that opens
// a full-screen menu panel listing every section, grouped. A previous
// iteration tried a fixed bottom tab bar, but all 15 routes were
// crammed into a 390px phone width (≈17px per tab, labels cut to one
// letter) — unusable. The full-screen menu keeps every section one
// tap away with full-size rows.
//
// Desktop (md+): primary links inline, everything else behind a
// "More" dropdown. The full 15-link row needed 1752px inside a
// 1280px container, which pushed the right-hand cluster off-screen
// and gave the whole page a horizontal scrollbar.
//
// Icons use stroke-currentColor at 1.6px per the design system
// (anti-slop rule: emoji and gradient icons are forbidden). Brand
@ -18,7 +24,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
const LINKS = [
@ -28,6 +34,7 @@ const LINKS = [
{ href: "/bizportal-2", label: "BizPortal 2.0", icon: IconSearch, badge: "test" },
{ href: "/cipc", label: "CIPC", icon: IconSearch },
{ href: "/cipc-2", label: "CIPC2.0", icon: IconSearch, badge: "test" },
{ href: "/cipc-report", label: "CIPC Report", icon: IconCheckSquare },
{ href: "/sars-returns", label: "SARS Returns", icon: IconReceipt },
{ href: "/sars-registrations", label: "SARS Registrations", icon: IconRegistry },
{ href: "/tasks", label: "Tasks", icon: IconClipboard },
@ -38,8 +45,83 @@ const LINKS = [
{ href: "/resolutions", label: "Resolutions", icon: IconDocument },
] as const;
type LinkItem = (typeof LINKS)[number];
const LINK_BY_HREF: Record<string, LinkItem> = Object.fromEntries(
LINKS.map((l) => [l.href, l]),
) as Record<string, LinkItem>;
/** Sections that stay one click away in the desktop header. */
const DESKTOP_PRIMARY = [
"/",
"/notes",
"/companies",
"/directors",
"/tasks",
"/compliance",
] as const;
/** Every section, grouped — the shared source of truth for the mobile
* menu panel and the desktop "More" dropdown. */
const MENU_GROUPS: { title: string; hrefs: string[] }[] = [
{ title: "Main", hrefs: ["/", "/tasks", "/directors", "/companies"] },
{
title: "CIPC & BizPortal",
hrefs: ["/bizportal", "/bizportal-2", "/cipc", "/cipc-2", "/cipc-report"],
},
{ title: "SARS", hrefs: ["/sars-returns", "/sars-registrations"] },
{ title: "Practice", hrefs: ["/notes", "/groups", "/compliance", "/resolutions"] },
];
export default function NavBar() {
const pathname = usePathname() ?? "/";
const [menuOpen, setMenuOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef<HTMLDivElement | null>(null);
const closeRef = useRef<HTMLButtonElement | null>(null);
// Navigating away closes whichever surface was open.
useEffect(() => {
setMenuOpen(false);
setMoreOpen(false);
}, [pathname]);
// The mobile panel is a modal surface: lock the page behind it.
useEffect(() => {
if (!menuOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
closeRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMenuOpen(false);
};
document.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previous;
document.removeEventListener("keydown", onKey);
};
}, [menuOpen]);
// The desktop dropdown closes on an outside click or Escape.
useEffect(() => {
if (!moreOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMoreOpen(false);
};
const onPointer = (e: MouseEvent) => {
if (!moreRef.current?.contains(e.target as Node)) setMoreOpen(false);
};
document.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onPointer);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onPointer);
};
}, [moreOpen]);
const primaryLinks = LINKS.filter((l) =>
(DESKTOP_PRIMARY as readonly string[]).includes(l.href),
);
return (
<>
@ -47,7 +129,7 @@ export default function NavBar() {
data-od-id="navbar"
className="sticky top-0 z-30 bg-surface border-b border-border"
>
<div className="container-x flex items-center justify-between h-20">
<div className="container-x flex items-center justify-between h-[calc(5rem+env(safe-area-inset-top))] pt-[env(safe-area-inset-top)]">
<Link
href="/"
data-od-id="brand-mark"
@ -68,7 +150,7 @@ export default function NavBar() {
className="hidden md:flex items-center gap-2"
aria-label="Primary"
>
{LINKS.map((link) => {
{primaryLinks.map((link) => {
const isActive = isActiveLink(pathname, link.href);
return (
<Link
@ -83,89 +165,201 @@ export default function NavBar() {
}
>
<span>{link.label}</span>
{"badge" in link && link.badge ? (
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 border border-amber-300">
{link.badge}
</span>
) : null}
</Link>
);
})}
{/* Overflow dropdown — the desktop counterpart of the
mobile menu panel. */}
<div className="relative" ref={moreRef}>
<button
type="button"
onClick={() => setMoreOpen((v) => !v)}
aria-expanded={moreOpen}
aria-haspopup="true"
aria-label="More sections"
data-od-id="desktop-more-button"
className={
"px-4 py-2 rounded-md text-sm font-semibold transition-colors inline-flex items-center gap-1.5 " +
(moreOpen || isOverflowActive(pathname)
? "bg-surface-warm text-fg"
: "text-fg hover:bg-surface-warm")
}
>
More
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className={
"w-4 h-4 transition-transform " + (moreOpen ? "rotate-180" : "")
}
aria-hidden="true"
>
<polyline points="6,9 12,15 18,9" />
</svg>
</button>
{moreOpen ? (
<div
data-od-id="desktop-more-panel"
className="absolute right-0 top-full mt-2 w-[340px] max-h-[70vh] overflow-y-auto bg-surface border border-border rounded-lg shadow-raised p-2 z-40"
>
<GroupedList onNavigate={() => setMoreOpen(false)} />
</div>
) : null}
</div>
</nav>
{/* Right-side desktop cluster: label + theme toggle. Hidden
on mobile — the bottom tab bar carries the brand context. */}
on mobile — the menu panel carries both. */}
<div className="hidden md:flex items-center gap-3">
<span className="text-xs text-muted">Registry Browser</span>
<span className="hidden lg:inline text-xs text-muted">
Registry Browser
</span>
<ThemeToggle />
</div>
{/* Spacer for mobile so the brand lockup stays left-aligned
and the bottom-bar height is the only vertical change. */}
<div className="md:hidden" aria-hidden="true" />
{/* Mobile: hamburger opens the full-screen menu. */}
<button
type="button"
onClick={() => setMenuOpen(true)}
aria-label="Open menu"
aria-haspopup="dialog"
aria-expanded={menuOpen}
data-od-id="mobile-menu-button"
className="md:hidden inline-flex items-center justify-center w-10 h-10 -mr-2 rounded-md text-fg"
>
<IconMenu className="w-6 h-6" />
</button>
</div>
</header>
{/* Mobile bottom tab bar */}
<nav
className="md:hidden fixed inset-x-0 bottom-0 z-30 bg-surface border-t border-border pb-[env(safe-area-inset-bottom)]"
aria-label="Primary"
>
{/*
Layout notes (phone, full-bleed):
- No `container-x` here — the bottom nav should sit edge-to-edge.
- Each tab uses `flex-1 min-w-0` so the labels can truncate cleanly
instead of pushing siblings or wrapping onto a second line.
- `gap-2` between tabs is decorative breathing room; `flex-1` does
the real even spacing.
- `text-[10px] tracking-tight` keeps 8-tab labels inside ~46px slots
on a 390px viewport without truncating every label into dots.
*/}
<ul className="flex items-stretch justify-between h-16 gap-2 px-2">
{LINKS.map((link) => {
const isActive = isActiveLink(pathname, link.href);
const Icon = link.icon;
return (
<li
key={link.href}
className="flex-1 min-w-0"
>
<Link
href={link.href}
aria-current={isActive ? "page" : undefined}
aria-label={link.label}
className={
"group flex flex-col items-center justify-center gap-0.5 h-full w-full px-1 no-underline transition-colors " +
(isActive
? "text-accent"
: "text-muted hover:text-fg")
}
{/* Mobile full-screen menu panel. */}
{menuOpen ? (
<div
role="dialog"
aria-modal="true"
aria-label="Main menu"
data-od-id="mobile-menu-panel"
className="md:hidden fixed inset-0 z-50 bg-bg overflow-y-auto"
>
<div className="sticky top-0 bg-surface border-b border-border">
<div className="container-x flex items-center justify-between h-[calc(5rem+env(safe-area-inset-top))] pt-[env(safe-area-inset-top)]">
<Link href="/" className="flex items-center gap-2 no-underline">
<span
aria-hidden="true"
className="inline-flex items-center justify-center w-8 h-8 rounded-pill bg-accent text-accent-on font-bold text-sm"
>
<Icon
className={
"w-5 h-5 shrink-0 transition-transform " +
(isActive ? "scale-110" : "scale-100")
}
active={isActive}
/>
<span className="text-[10px] font-semibold tracking-tight truncate w-full text-center">
{link.label}
</span>
</Link>
</li>
);
})}
</ul>
</nav>
Y
</span>
<span className="text-lg font-bold text-fg tracking-tight">
Workflow
</span>
</Link>
<button
ref={closeRef}
type="button"
onClick={() => setMenuOpen(false)}
aria-label="Close menu"
data-od-id="mobile-menu-close"
className="w-10 h-10 -mr-2 inline-flex items-center justify-center rounded-md text-fg"
>
<IconClose className="w-6 h-6" />
</button>
</div>
</div>
<GroupedList onNavigate={() => setMenuOpen(false)} />
<div className="container-x flex items-center justify-between py-6 pb-[calc(env(safe-area-inset-bottom)+24px)]">
<span className="text-xs text-muted">Registry Browser</span>
<ThemeToggle />
</div>
</div>
) : null}
</>
);
}
/**
* Grouped list of every section, shared by the mobile menu panel and
* the desktop "More" dropdown.
*/
function GroupedList({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname() ?? "/";
return (
<div className="px-4 pb-2 md:px-0">
{MENU_GROUPS.map((group) => (
<div key={group.title} className="pt-5">
<p className="text-[11px] font-bold uppercase tracking-wider text-meta mb-1 px-1">
{group.title}
</p>
<ul>
{group.hrefs.map((href) => {
const link = LINK_BY_HREF[href];
if (!link) return null;
const Icon = link.icon;
const active = isActiveLink(pathname, href);
return (
<li key={href}>
<Link
href={href}
onClick={onNavigate}
className="flex items-center gap-3 py-2.5 no-underline rounded-md px-1"
>
<span
className={
"w-9 h-9 rounded-md inline-flex items-center justify-center shrink-0 " +
(active
? "bg-accent text-accent-on"
: "bg-surface-warm text-muted")
}
>
<Icon className="w-5 h-5" active={active} />
</span>
<span
className={
"text-[15px] font-semibold " +
(active ? "text-accent" : "text-fg")
}
>
{link.label}
</span>
{"badge" in link && link.badge ? (
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 border border-amber-300">
{link.badge}
</span>
) : null}
<IconChevronRight className="w-4 h-4 ml-auto text-meta shrink-0" />
</Link>
</li>
);
})}
</ul>
</div>
))}
</div>
);
}
function isActiveLink(pathname: string, href: string): boolean {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(href + "/");
}
/** True when the current route lives behind the "More" dropdown. */
function isOverflowActive(pathname: string): boolean {
return LINKS.some(
(l) =>
!(DESKTOP_PRIMARY as readonly string[]).includes(l.href) &&
isActiveLink(pathname, l.href),
);
}
/* ─── Icon set (inline SVG, stroke-currentColor, 1.6px stroke) ──── */
interface IconProps {
@ -201,7 +395,7 @@ function IconUsers({ className }: IconProps): ReactNode {
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
@ -221,7 +415,7 @@ function IconBuilding({ className }: IconProps): ReactNode {
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
@ -407,6 +601,61 @@ function IconNotepad({ className }: IconProps): ReactNode {
);
}
/** Hamburger glyph for the mobile menu button. */
function IconMenu({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
className={className}
aria-hidden="true"
>
<line x1="3.5" y1="7" x2="20.5" y2="7" />
<line x1="3.5" y1="12" x2="20.5" y2="12" />
<line x1="3.5" y1="17" x2="20.5" y2="17" />
</svg>
);
}
/** Close glyph for the mobile menu panel. */
function IconClose({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
className={className}
aria-hidden="true"
>
<line x1="5" y1="5" x2="19" y2="19" />
<line x1="19" y1="5" x2="5" y2="19" />
</svg>
);
}
/** Right-chevron used as the list affordance in the grouped lists. */
function IconChevronRight({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<polyline points="9.5,5 16.5,12 9.5,19" />
</svg>
);
}
/* ─── Theme toggle ────────────────────────────────────────────────── */
/**

View file

@ -49,7 +49,7 @@ export default function RouteChrome({ children }: { children: ReactNode }) {
return (
<>
<NavBar />
<main id="main" className="min-h-[60vh] pb-24 md:pb-0">
<main id="main" className="min-h-[60vh]">
{children}
</main>
<footer className="bg-surface-warm border-t border-border mt-16">

View file

@ -0,0 +1,608 @@
#!/usr/bin/env python3
"""CIPC Director Appointment Check -> PDF report.
Reads a results JSON produced by the CIPC report run and renders it to a
polished A4 PDF via the pdf skill's HTML->Playwright pipeline.
python build_report.py --results results.json --work <dir> --out <pdf.html>
The script writes:
<work>/content.json content manifest (ensure_ascii=False)
<work>/page.html print-ready A4 HTML
Then render with:
bash "<pdf-skill>/scripts/make.sh" render --in <work>/page.html \
--out <pdf> --format A4 --wait 3000
Results JSON shape
------------------
{
"run": {
"generated_at": "2026-10-26 22:31 SAST",
"source_file": "Bizportal Search List.xlsx",
"ids_checked": 44,
"lookups_ok": 42,
"lookups_failed": 2,
"login_used": "Login 3 (st0496)",
"searches_used": 44,
"is_sample": true
},
"exceptions_new_company": [
{"id_no":"...","first":"...","surname":"...",
"enterprise_no":"K...","enterprise_name":"...",
"enterprise_status":"In Business","director_status":"Active"}
],
"exceptions_missing_link": [ ... same fields ... ],
"failures": [{"id_no":"...","first":"...","surname":"...","reason":"..."}],
"clean_count": 36
}
"""
import argparse
import html
import json
import os
from pathlib import Path
def esc(x) -> str:
return html.escape(str(x if x is not None else ""), quote=True)
# ---------------------------------------------------------------- CSS
CSS = """
@page { size: A4 portrait; margin: 13mm 10mm; }
* { box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
:root {
--ink:#1d1d1f; --ink-2:#424245; --ink-3:#6e6e73; --ink-4:#8a8a8f;
--hairline:#e5e5ea; --rule:#d2d2d7; --surface:#fff; --mute:#f5f5f7;
--accent:#0f6f76;
--cover-1:#14202b; --cover-2:#1d3a41; --cover-3:#0f6f76;
}
html, body {
margin:0; padding:0;
font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI","Helvetica Neue",Arial,sans-serif;
color:var(--ink); background:var(--surface);
font-size:10.5pt; line-height:1.55; -webkit-font-smoothing:antialiased;
}
.content { padding: 0 10mm; }
.page-break { page-break-after:always; }
.avoid-break { page-break-inside:avoid; }
/* running header / footer */
.running-header {
position:absolute; top:0; left:0; right:0; height:7mm;
border-bottom:0.5pt solid var(--rule);
font-size:8.5pt; color:var(--ink-4); line-height:7mm;
}
.running-header .left { float:left; }
.running-header .right { float:right; }
.running-footer {
position:absolute; bottom:0; left:0; right:0; height:8mm;
border-top:0.5pt solid var(--rule);
font-size:8pt; color:var(--ink-4); line-height:8mm;
}
.running-footer .left { float:left; }
.running-footer .right { float:right; }
.page { position:relative; min-height:268mm; padding:11mm 0 11mm; box-sizing:border-box; }
/* cover */
.cover {
height:268mm;
background:linear-gradient(135deg,var(--cover-1) 0%,var(--cover-2) 55%,var(--cover-3) 100%);
color:#fff; padding:30mm 18mm; position:relative; overflow:hidden;
page-break-after:always;
}
.cover::before {
content:""; position:absolute; top:-100px; right:-100px; width:500px; height:500px;
border-radius:50%; background:radial-gradient(circle,rgba(15,111,118,0.55),transparent 70%);
}
.cover::after {
content:""; position:absolute; bottom:-160px; left:-140px; width:600px; height:600px;
border-radius:50%; background:radial-gradient(circle,rgba(255,255,255,0.07),transparent 70%);
}
.cover-tag {
display:inline-block; padding:6px 14px; border:1px solid rgba(255,255,255,0.45);
border-radius:20px; font-size:9.5pt; letter-spacing:2px; opacity:0.9;
}
.cover h1 { font-size:34pt; font-weight:700; margin:26px 0 16px; line-height:1.15; letter-spacing:-0.8px; position:relative; z-index:2; }
.cover .subtitle { font-size:12pt; opacity:0.85; max-width:135mm; line-height:1.7; position:relative; z-index:2; }
.cover .sample-banner {
display:inline-block; margin-top:14px; padding:7px 16px; border-radius:8px;
background:rgba(255,214,102,0.92); color:#3d2c05; font-size:10pt; font-weight:700;
letter-spacing:0.6px; position:relative; z-index:2;
}
.cover-meta {
position:absolute; bottom:30mm; left:18mm; right:18mm;
display:flex; justify-content:space-between; align-items:flex-end; z-index:2;
}
.meta-stats { display:flex; gap:30px; }
.meta-stat .num { font-size:26pt; font-weight:700; line-height:1; }
.meta-stat .lbl { font-size:8.5pt; opacity:0.75; margin-top:5px; letter-spacing:1px; }
.cover-info { text-align:right; opacity:0.75; font-size:9pt; line-height:1.8; }
/* section header */
.section-num { display:inline-block; font-size:9.5pt; font-weight:600; color:var(--accent); letter-spacing:3px; margin-bottom:8px; }
h2.section { font-size:19pt; font-weight:700; margin:0 0 4px; letter-spacing:-0.5px; }
.section-line { width:50px; height:3px; background:var(--accent); border-radius:2px; margin:10px 0 16px; }
h3 { font-size:12pt; font-weight:600; margin:14px 0 6px; page-break-after:avoid; }
h3 .num { color:var(--ink-4); font-weight:500; margin-right:8px; }
p { margin:6px 0; }
.lead {
font-size:10.5pt; color:var(--ink-2); line-height:1.65;
border-left:3px solid var(--accent);
background:linear-gradient(90deg,rgba(15,111,118,0.06),transparent);
padding:11px 14px; border-radius:4px; margin:11px 0 14px;
}
/* KPI grid */
.kpis { display:grid; grid-template-columns:repeat(4,1fr); gap:9px; margin:18px 0; }
.kpi { padding:13px; border-radius:10px; background:linear-gradient(135deg,#f5f5f7,#ebebef); border:1px solid var(--rule); }
.kpi.blue { background:linear-gradient(135deg,#e6f1f3,#d5e7ea); border-color:#bcd7db; }
.kpi.green { background:linear-gradient(135deg,#e8f7ee,#d4f0e0); border-color:#b3e0c2; }
.kpi.orange { background:linear-gradient(135deg,#fff0e0,#ffe0c4); border-color:#ffc89b; }
.kpi.purple { background:linear-gradient(135deg,#f0e8ff,#e2d4ff); border-color:#c9b3ff; }
.kpi .label { font-size:8pt; color:var(--ink-3); letter-spacing:0.5px; text-transform:uppercase; font-weight:600; }
.kpi .value { font-size:21pt; font-weight:700; color:var(--ink); line-height:1.1; margin-top:4px; }
.kpi .desc { font-size:8pt; color:var(--ink-3); margin-top:5px; line-height:1.45; }
/* TOC */
.toc { margin:14px 0 0; }
.toc-row {
display:flex; justify-content:space-between; align-items:baseline;
text-decoration:none; color:var(--ink-2); font-size:10pt;
padding:7px 4px; border-bottom:0.5pt solid var(--hairline);
}
.toc-row .t { font-weight:600; color:var(--ink); }
.toc-row .d { color:var(--ink-4); font-size:9pt; margin-left:8px; font-weight:400; }
.toc-row .page-ref { color:var(--accent); font-weight:600; font-size:9.5pt; }
/* data table */
table.data {
width:100%; border-collapse:collapse; font-size:8.8pt; margin:9px 0;
background:#fff; border:1px solid var(--hairline); border-radius:8px; overflow:hidden;
table-layout:fixed; page-break-inside:avoid;
}
table.data col.id { width:15%; }
table.data col.first { width:19%; }
table.data col.sur { width:13%; }
table.data col.ent { width:24%; }
table.data col.stat { width:15%; }
table.data col.act { width:14%; }
table.data col.reason{ width:56%; }
table.data col.n1 { width:17%; }
table.data col.n2 { width:20%; }
table.data col.n3 { width:20%; }
table.data col.n4 { width:14%; }
table.data col.n5 { width:14%; }
table.data col.n6 { width:15%; }
table.data col.f1 { width:17%; }
table.data col.f2 { width:18%; }
table.data col.f3 { width:17%; }
table.data col.f4 { width:48%; }
table.data thead th {
background:linear-gradient(180deg,#f5f5f7,#ebebef); padding:8px 9px; text-align:left;
font-weight:600; color:var(--ink); border-bottom:1px solid var(--rule); font-size:8.2pt;
letter-spacing:0.3px;
}
table.data tbody td { padding:6px 7px; border-bottom:1px solid var(--mute); color:var(--ink-2); vertical-align:top; overflow-wrap:break-word; }
table.data tbody tr:last-child td { border-bottom:0; }
table.data tbody tr:nth-child(even) { background:#fbfbfd; }
table.data tbody td.bold { font-weight:600; color:var(--ink); }
table.data tbody td.mono { font-family:"SF Mono",Consolas,"Courier New",monospace; font-size:8pt; letter-spacing:-0.3px; white-space:nowrap; }
table.data tbody td.nowrap { white-space:nowrap; }
table.data tbody td.center { text-align:center; }
table.data thead { display:table-header-group; }
tr { page-break-inside:avoid; }
/* pills (fixed semantics) */
.pill { display:inline-block; padding:2px 8px; border-radius:10px; font-size:7.8pt; font-weight:600; white-space:nowrap; }
.pill-bad { background:#ffebee; color:#c62828; }
.pill-warn { background:#fff3e0; color:#e65100; }
.pill-ok { background:#e8f5e9; color:#2e7d32; }
.pill-good { background:#e3f2fd; color:#1565c0; }
.pill-best { background:#f3e5f5; color:#6a1b9a; }
/* panels */
.insight { background:linear-gradient(135deg,#fff8e1,#fff3c4); border:1px solid #ffe082; border-radius:10px; padding:12px 15px; margin:14px 0; font-size:9.5pt; }
.insight .label { display:inline-block; font-weight:700; color:#b8860b; font-size:8.5pt; letter-spacing:1px; margin-right:8px; }
.allclear { background:linear-gradient(135deg,#e8f5e9,#d4f0e0); border:1px solid #b3e0c2; border-radius:12px; padding:26px 24px; margin:18px 0; text-align:center; }
.allclear .mark { font-size:30pt; font-weight:700; color:#2e7d32; line-height:1; }
.allclear h3 { margin:10px 0 6px; font-size:15pt; color:#1b5e20; }
.allclear p { margin:0; color:#33691e; font-size:10pt; }
.conclusion { background:linear-gradient(135deg,#14202b,#1d3a41); color:#fff; padding:20px 22px; border-radius:12px; margin:18px 0; }
.conclusion h3 { color:#fff; margin:0 0 8px; font-size:12.5pt; }
.conclusion p { opacity:0.92; font-size:9.5pt; margin:0; }
.takeaway { display:flex; gap:9px; margin:7px 0; padding:9px 12px; border-radius:8px; background:var(--mute); font-size:9.5pt; }
.takeaway .icon { font-weight:700; color:var(--accent); min-width:16px; }
.footer-meta { border-top:1px solid var(--hairline); margin-top:22px; padding-top:14px; font-size:8.5pt; color:var(--ink-4); }
.footer-meta h4 { color:var(--ink-2); font-size:10pt; margin:0 0 7px; }
.footer-meta ul { margin:5px 0; padding-left:17px; line-height:1.7; }
/* screen emulation */
@media screen {
html { background:#d0d0d0; }
body { margin:0; padding:20px 0; }
body > section { width:210mm; min-height:297mm; margin:0 auto 20px; background:#fff;
box-shadow:0 4px 24px rgba(0,0,0,0.18); box-sizing:border-box; overflow:hidden; }
}
"""
def header(title: str, right: str) -> str:
return (f'<div class="running-header"><span class="left">{esc(title)}</span>'
f'<span class="right">{esc(right)}</span></div>')
def footer(left: str, right: str) -> str:
return (f'<div class="running-footer"><span class="left">{esc(left)}</span>'
f'<span class="right">{esc(right)}</span></div>')
def _table(head: list[str], widths: list[str], rows: list[list[str]]) -> str:
"""Fixed-layout table with an explicit colgroup so no cell wraps badly."""
out = ['<table class="data"><colgroup>']
out += [f'<col class="{w}">' for w in widths]
out.append("</colgroup><thead><tr>")
out += [f"<th>{esc(h)}</th>" for h in head]
out.append("</tr></thead><tbody>")
for cells in rows:
out.append("<tr>")
out += cells
out.append("</tr>")
out.append("</tbody></table>")
return "".join(out)
def exception_table(rows: list[dict], kind: str) -> str:
if not rows:
return ('<p class="lead"><span class="pill pill-ok">None found</span>&nbsp; '
"No appointments of this type were found.</p>")
if kind == "new":
pill = '<span class="pill pill-bad">New company</span>'
action = "Add company"
comp_label = "Company (CIPC)"
else:
pill = '<span class="pill pill-warn">Link missing</span>'
action = "Link director"
comp_label = "Company (in database)"
head = ["ID Number", "Director", "Enterprise Number", comp_label, "Status", "Action"]
widths = ["id", "first", "ent", "sur", "stat", "act"]
body = []
for r in rows:
director = " ".join(x for x in (r.get("first", ""), r.get("surname", "")) if x).strip()
body.append([
f'<td class="bold mono">{esc(r.get("id_no",""))}</td>',
f'<td class="bold">{esc(director)}</td>',
f'<td class="mono">{esc(r.get("enterprise_no",""))}</td>',
f'<td class="bold">{esc(r.get("enterprise_name",""))}</td>',
f'<td class="nowrap">{pill}</td>',
f'<td class="nowrap">{esc(r.get("enterprise_status",""))}<br/>{esc(action)}</td>',
])
return _table(head, widths, body)
def affected_table(new_rows: list[dict], link_rows: list[dict]) -> str:
"""Compact per-director summary used on the summary page."""
agg: dict[str, dict] = {}
for r in new_rows:
e = agg.setdefault(r["id_no"], {"first": r.get("first", ""), "surname": r.get("surname", ""),
"new": 0, "link": 0})
e["new"] += 1
for r in link_rows:
e = agg.setdefault(r["id_no"], {"first": r.get("first", ""), "surname": r.get("surname", ""),
"new": 0, "link": 0})
e["link"] += 1
if not agg:
return ""
head = ["ID Number", "Name", "Surname", "New companies", "Missing links", "Total"]
widths = ["n1", "n2", "n3", "n4", "n5", "n6"]
body = []
for id_no, e in sorted(agg.items(), key=lambda kv: (-(kv[1]["new"] + kv[1]["link"]), kv[0])):
total = e["new"] + e["link"]
body.append([
f'<td class="bold mono">{esc(id_no)}</td>',
f'<td class="bold">{esc(e["first"])}</td>',
f'<td class="bold">{esc(e["surname"])}</td>',
f'<td class="center">{esc(e["new"])}</td>',
f'<td class="center">{esc(e["link"])}</td>',
f'<td class="center bold">{esc(total)}</td>',
])
return _table(head, widths, body)
def build_content(data: dict) -> dict:
run = data.get("run", {})
new_rows = data.get("exceptions_new_company", [])
link_rows = data.get("exceptions_missing_link", [])
failures = data.get("failures", [])
clean = data.get("clean_count", 0)
people_new = len({r["id_no"] for r in new_rows})
people_link = len({r["id_no"] for r in link_rows})
total_exc = people_new + people_link
no_exceptions = (total_exc == 0)
has_exc = total_exc > 0
method_page = 4 if has_exc else 3
toc = [
("sec-summary", "Summary", "What was checked, what was found, who is affected", "2"),
]
if has_exc:
toc.append(("sec-exceptions", "Exceptions",
"Appointments not yet recorded in the database", "3"))
toc.append(("sec-method", "Coverage, method and limitations",
"Failed lookups, daily quota and how the comparison works", str(method_page)))
toc_html = '<nav class="toc" aria-label="Table of contents">'
for sid, title, desc, page in toc:
toc_html += (f'<a class="toc-row" href="#{sid}">'
f'<span><span class="t">{esc(title)}</span><span class="d">{esc(desc)}</span></span>'
f'<span class="page-ref">{esc(page)}</span></a>')
toc_html += "</nav>"
if no_exceptions:
summary_block = (
'<div class="allclear avoid-break"><div class="mark">&#10003;</div>'
"<h3>No exceptions found</h3>"
"<p>Every director ID checked returned only appointments that are already "
"recorded in the database. Nothing needs to be added or linked.</p></div>"
)
else:
parts = []
if people_new:
parts.append(f"<strong>{people_new}</strong> director ID"
f"{'s' if people_new != 1 else ''} sit at "
f"<strong>{len(new_rows)} compan"
f"{'ies' if len(new_rows) != 1 else 'y'}</strong> that "
f"{'are' if len(new_rows) != 1 else 'is'} not in the database.")
if people_link:
parts.append(f"<strong>{people_link}</strong> director ID"
f"{'s' if people_link != 1 else ''} {'are' if people_link != 1 else 'is'} "
f"appointed at a company we hold, but the director link is missing.")
summary_block = ('<div class="insight avoid-break"><span class="label">Result</span>'
+ " ".join(parts) + "</div>")
fail_body = [
[f'<td class="bold mono">{esc(f.get("id_no",""))}</td>',
f'<td class="bold">{esc(f.get("first",""))}</td>',
f'<td class="bold">{esc(f.get("surname",""))}</td>',
f'<td>{esc(f.get("reason",""))}</td>']
for f in failures
]
fail_block = (
_table(["ID Number", "Name", "Surname", "Reason the lookup did not complete"],
["f1", "f2", "f3", "f4"], fail_body)
if failures else
'<p class="lead"><span class="pill pill-ok">Complete</span>&nbsp; Every ID number was '
"looked up successfully. There are no gaps in the coverage.</p>"
)
return {
"meta": {
"title": "CIPC Director Appointment Check",
"subtitle": "Director appointments recorded at CIPC that are not yet reflected "
"in the Workflow client database.",
"doc_type": "EXCEPTION REPORT",
"version": run.get("generated_at", ""),
"source_file": run.get("source_file", ""),
"login_used": run.get("login_used", ""),
"searches_used": run.get("searches_used", ""),
"is_sample": bool(run.get("is_sample")),
"ids_checked": run.get("ids_checked", 0),
"lookups_ok": run.get("lookups_ok", 0),
"lookups_failed": run.get("lookups_failed", 0),
"clean_count": clean,
"people_new": people_new,
"people_link": people_link,
"new_rows": len(new_rows),
"link_rows": len(link_rows),
"total_exc": total_exc,
"no_exceptions": no_exceptions,
"has_exceptions": has_exc,
"method_page": method_page,
"failures": failures,
},
"toc_html": toc_html,
"summary_block": summary_block,
"affected_table": affected_table(new_rows, link_rows),
"new_table": exception_table(new_rows, "new"),
"link_table": exception_table(link_rows, "link"),
"fail_block": fail_block,
"toc": toc,
}
def render_html(c: dict) -> str:
m = c["meta"]
sample_banner = (
'<div class="sample-banner">SAMPLE REPORT - DUMMY DATA, NOT A REAL RUN</div>'
if m["is_sample"] else ""
)
cover = f"""<section class="cover">
<span class="cover-tag">CIPC / BIZPORTAL - DIRECTOR APPOINTMENT CHECK</span>
<h1>{esc(m['title'])}</h1>
<p class="subtitle">{esc(m['subtitle'])}</p>
{sample_banner}
<div class="cover-meta">
<div class="meta-stats">
<div class="meta-stat"><div class="num">{esc(m['ids_checked'])}</div><div class="lbl">IDs CHECKED</div></div>
<div class="meta-stat"><div class="num">{esc(m['total_exc'])}</div><div class="lbl">DIRECTORS TO ACTION</div></div>
<div class="meta-stat"><div class="num">{esc(m['new_rows'])}</div><div class="lbl">NEW COMPANIES</div></div>
</div>
<div class="cover-info">
<div>{esc(m['doc_type'])}</div>
<div>{esc(m['version'])}</div>
<div>Source: {esc(m['source_file'])}</div>
</div>
</div>
</section>"""
affected_block = ""
if c["affected_table"]:
affected_block = ('<h3><span class="num">1.1</span>Directors to action</h3>\n'
+ c["affected_table"])
summary = f"""<section class="content page-break page" id="sec-summary">
{header(m['title'], 'Summary')}
<span class="section-num">SECTION 01</span>
<h2 class="section">Summary</h2>
<div class="section-line"></div>
<p class="lead">Every SA ID number on the source list was looked up on CIPC BizPortal to
find the companies that person is currently registered as a director of. Each returned
company was then compared against the Workflow database. This report lists only the
appointments we do <em>not</em> yet have on file.</p>
<div class="kpis">
<div class="kpi blue">
<div class="label">IDs checked</div>
<div class="value">{esc(m['ids_checked'])}</div>
<div class="desc">SA ID numbers looked up on BizPortal</div>
</div>
<div class="kpi green">
<div class="label">Clean</div>
<div class="value">{esc(m['clean_count'])}</div>
<div class="desc">Everything already on record</div>
</div>
<div class="kpi orange">
<div class="label">New companies</div>
<div class="value">{esc(m['new_rows'])}</div>
<div class="desc">Not in the database at all</div>
</div>
<div class="kpi purple">
<div class="label">Links missing</div>
<div class="value">{esc(m['link_rows'])}</div>
<div class="desc">Company known, director not linked</div>
</div>
</div>
{c['summary_block']}
{affected_block}
<h3><span class="num">{'1.2' if c['affected_table'] else '1.1'}</span>Contents</h3>
{c['toc_html']}
{footer('CIPC Director Appointment Check', 'Page 2')}
</section>"""
exceptions = f"""<section class="content page-break page" id="sec-exceptions">
{header(m['title'], 'Exceptions')}
<span class="section-num">SECTION 02</span>
<h2 class="section">Exceptions</h2>
<div class="section-line"></div>
<p class="lead">Every appointment below is held at CIPC but is not yet reflected in the
Workflow database. They are the only entries in this report - appointments we already have
on file are not repeated.</p>
<h3><span class="num">2.1</span>Type A &mdash; companies we do not have</h3>
<p>Registered at CIPC as a director of a company that does not exist in the Workflow database.</p>
{c['new_table']}
<h3><span class="num">2.2</span>Type B &mdash; missing director link</h3>
<p>Registered at CIPC as a director of a company we already hold, but the director is not
linked to that company in the database.</p>
{c['link_table']}
{footer('CIPC Director Appointment Check', 'Page 3')}
</section>"""
method = f"""<section class="content page-break page" id="sec-method">
{header(m['title'], 'Coverage and method')}
<span class="section-num">SECTION 03</span>
<h2 class="section">Coverage, method and limitations</h2>
<div class="section-line"></div>
<h3><span class="num">4.1</span>Lookups that did not complete</h3>
<p>These ID numbers were not confirmed against CIPC, so this report is not complete for
them. Re-run the check for this list before treating the result as final.</p>
{c['fail_block']}
<h3><span class="num">4.2</span>How each appointment was compared</h3>
<div class="takeaway"><span class="icon">1</span><span>BizPortal was searched by Director ID
No. using <em>Bizportal Directopr id no search.py</em>, returning the enterprise number,
name and status of every company the person is a director of.</span></div>
<div class="takeaway"><span class="icon">2</span><span>Each enterprise number was matched
against <em>ysterbaard_companies</em> in PocketBase. A company that is absent there is a
<em>new company</em> exception.</span></div>
<div class="takeaway"><span class="icon">3</span><span>Where the company does exist, the
director was matched against <em>ysterbaard_company_directors</em>. A known company with no
link for that director is a <em>missing link</em> exception.</span></div>
<div class="takeaway"><span class="icon">4</span><span>Everything else is already on file and
is not repeated in this report.</span></div>
<div class="conclusion avoid-break">
<h3>Run details</h3>
<p>Source list: {esc(m['source_file'])} &nbsp;&middot;&nbsp; IDs checked:
{esc(m['ids_checked'])} &nbsp;&middot;&nbsp; Lookups completed:
{esc(m['lookups_ok'])} &nbsp;&middot;&nbsp; Lookups failed:
{esc(m['lookups_failed'])}<br/>
BizPortal login used: {esc(m['login_used'])} &nbsp;&middot;&nbsp; Searches consumed:
{esc(m['searches_used'])} &nbsp;&middot;&nbsp; Report generated:
{esc(m['version'])}</p>
</div>
<div class="footer-meta">
<h4>Notes</h4>
<ul>
<li>BizPortal allows a limited number of searches per day per login. The run detail above
records the login used and the number of searches consumed.</li>
<li>Company status and director status are reproduced exactly as CIPC reported them at the
time of the search.</li>
<li>ID numbers are shown as recorded in the database. No masking was applied and no value
was reconstructed.</li>
</ul>
</div>
{footer('CIPC Director Appointment Check', f'Page {m["method_page"]}')}
</section>"""
doc = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{esc(m['title'])}</title>
<style>{CSS}</style>
</head>
<body>
{cover}
{summary}
{exceptions if m['has_exceptions'] else ''}
{method}
</body>
</html>"""
return doc
def main():
ap = argparse.ArgumentParser(description="Build the CIPC director appointment exception report.")
ap.add_argument("--results", required=True, help="Results JSON from the CIPC report run.")
ap.add_argument("--work", required=True, help="Working directory for content.json + page.html.")
ap.add_argument("--out-html", help="Optional explicit path for the rendered HTML.")
args = ap.parse_args()
with open(args.results, encoding="utf-8-sig") as f:
data = json.load(f)
work = Path(args.work)
work.mkdir(parents=True, exist_ok=True)
content = build_content(data)
(work / "content.json").write_text(
json.dumps(content, ensure_ascii=False, indent=2), encoding="utf-8")
doc = render_html(content)
out_html = Path(args.out_html) if args.out_html else (work / "page.html")
out_html.parent.mkdir(parents=True, exist_ok=True)
out_html.write_text(doc, encoding="utf-8")
m = content["meta"]
print(f"content.json -> {work / 'content.json'}")
print(f"page.html -> {out_html}")
print(f" IDs checked : {m['ids_checked']}")
print(f" new companies : {m['new_rows']} ({m['people_new']} directors)")
print(f" missing links : {m['link_rows']} ({m['people_link']} directors)")
print(f" failed lookups : {m['lookups_failed']}")
print(f" no exceptions : {m['no_exceptions']}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,74 @@
{
"checked": 44,
"failed": 2,
"new_appointments": [
{
"id_no": "8901010001087",
"director": "STEPHANUS JOHANNES COETZEE",
"company": "BRANDLINE HOLDINGS (PTY) LTD",
"enterprise_no": "K2026108471",
"status": "In Business",
"reason": "active",
"director_pb_id": "27d7wjmult95rkq",
"company_pb_id": "",
"company_exists": false
},
{
"id_no": "8901010001087",
"director": "STEPHANUS JOHANNES COETZEE",
"company": "SUNRISE VENTURES (PTY) LTD",
"enterprise_no": "K2026112930",
"status": "In Business",
"reason": "active",
"director_pb_id": "27d7wjmult95rkq",
"company_pb_id": "",
"company_exists": false
},
{
"id_no": "7509015145082",
"director": "Christiaan Marthinus JANEKE",
"company": "JANEKE PROPERTY INVESTMENTS (PTY) LTD",
"enterprise_no": "K2026118402",
"status": "In Business",
"reason": "active",
"director_pb_id": "5m8xj56j9vvp6t4",
"company_pb_id": "",
"company_exists": false
},
{
"id_no": "6801135038086",
"director": "Gary Sean ISHERWOOD",
"company": "BLUE SKY LOGISTICS CC",
"enterprise_no": "2011/123456/07",
"status": "In Business",
"reason": "active",
"director_pb_id": "enp2l0o2g0ojpgs",
"company_pb_id": "",
"company_exists": false
}
],
"no_longer_director": [
{
"id_no": "8210115109081",
"director": "STEPHANUS GERHARDUS BARNARD",
"company": "JEI SA",
"enterprise_no": "K2026409808",
"status": "In Business",
"reason": "resigned",
"director_pb_id": "hovq4risw0o4dq2",
"company_pb_id": "8kj0glx26o4dzap",
"company_exists": true
},
{
"id_no": "8502130078089",
"director": "JOHANITA COETZEE",
"company": "LEATHER IMPORTERS",
"enterprise_no": "K2021789676",
"status": "In Business",
"reason": "missing",
"director_pb_id": "97fk6o880uz9lk5",
"company_pb_id": "35wl4m53bc0b0fc",
"company_exists": true
}
]
}