Compare commits
3 commits
2f01b55d5a
...
dbcb378636
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbcb378636 | ||
|
|
cd0a818641 | ||
|
|
3595cd54bb |
11 changed files with 2522 additions and 71 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
# --- deps, build output, git history ----------------------------------
|
||||
node_modules
|
||||
.next
|
||||
.next-stale*
|
||||
out
|
||||
dist
|
||||
build
|
||||
|
|
|
|||
30
Workflow/.start-dev-detached.ps1
Normal file
30
Workflow/.start-dev-detached.ps1
Normal 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"
|
||||
252
Workflow/app/api/cipc/report/action/route.ts
Normal file
252
Workflow/app/api/cipc/report/action/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
619
Workflow/app/api/cipc/report/route.ts
Normal file
619
Workflow/app/api/cipc/report/route.ts
Normal 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);
|
||||
}
|
||||
577
Workflow/app/cipc-report/CipcReportExplorer.tsx
Normal file
577
Workflow/app/cipc-report/CipcReportExplorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
Workflow/app/cipc-report/page.tsx
Normal file
31
Workflow/app/cipc-report/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
608
Workflow/scripts/cipc-report/build_report.py
Normal file
608
Workflow/scripts/cipc-report/build_report.py
Normal 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> '
|
||||
"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">✓</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> 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 — 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 — 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'])} · IDs checked:
|
||||
{esc(m['ids_checked'])} · Lookups completed:
|
||||
{esc(m['lookups_ok'])} · Lookups failed:
|
||||
{esc(m['lookups_failed'])}<br/>
|
||||
BizPortal login used: {esc(m['login_used'])} · Searches consumed:
|
||||
{esc(m['searches_used'])} · 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()
|
||||
74
Workflow/scripts/cipc-report/sample-report.json
Normal file
74
Workflow/scripts/cipc-report/sample-report.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue