next build runs 'Linting and checking validity of types' and fails on the first type error, so the Coolify image build died at app/api/cipc/search/route.ts:640. These errors were pre-existing but only surface on next build (npm run dev does not type check). - cipc/search/route.ts: coerce spawnSync stdout/stderr with toString(); result is typed as the un-overloaded return, so they are string|Buffer - group-report pdf+email: use fetchCollection<YsterbaardGroup> instead of an invalid cast; normalise description null -> undefined for ReportData - CipcExplorer (cipc + cipc-2): split the state ternary into two returns; the RowState union correlates state with differs, so 'diff' | 'match' matched neither variant - ComplianceTreeGrid: ar_non_compliance_date is optional; the API omits it and this component never reads it Verified: tsc --noEmit 0 errors, next build exit 0, standalone output ok.
3779 lines
141 KiB
TypeScript
3779 lines
141 KiB
TypeScript
// app/cipc-2/CipcExplorer.tsx
|
||
// Client component — DESIGN SANDBOX COPY of app/cipc/CipcExplorer.tsx.
|
||
// Everything here is a byte-for-byte clone of the CIPC tab except the
|
||
// localStorage cache namespace (see CACHE_PREFIX below). Behaviour is
|
||
// identical: two search cards POSTing to /api/cipc/search, which runs
|
||
// the matching Python script and returns captured markdown. The markdown
|
||
// is parsed into structured sections and rendered as a corporate-style
|
||
// report with a Print / Save PDF button.
|
||
//
|
||
// WHY THE CACHE NAMESPACE IS SEPARATE — the two tabs must be able to
|
||
// live side by side without fighting. The original writes its last
|
||
// result under "cipc:lastSearch:*" and, on every mount, sweeps that
|
||
// whole prefix. If this copy reused it, simply opening CIPC2.0 would
|
||
// silently wipe the original tab's saved report. Hence "cipc2:*" here.
|
||
//
|
||
// Change freely — this copy is the throwaway. The original tab stays on
|
||
// its own file and is not imported from here.
|
||
//
|
||
// FROZEN-BY-POLICY: the two .py files this dispatches to ("Bizportal
|
||
// Enterprise No. Search.py" and "Bizportal Directopr id no search.py")
|
||
// are co-authored and untouched, here or in the original.
|
||
|
||
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { useSearchParams } from "next/navigation";
|
||
|
||
type SearchKind = "enterprise_no" | "director_id_no";
|
||
|
||
interface CollectionMatch {
|
||
in_collection: boolean;
|
||
pb_id?: string;
|
||
pb_name?: string;
|
||
pb_status?: string;
|
||
}
|
||
|
||
interface SearchResponse {
|
||
ok: boolean;
|
||
markdown?: string;
|
||
raw?: string;
|
||
duration_ms?: number;
|
||
error?: string;
|
||
counter?: number;
|
||
search_kind?: SearchKind;
|
||
query?: string;
|
||
resolved_query?: string;
|
||
resolved_via?: "k_number" | "company_name" | "director_id" | null;
|
||
/** Map of canonical Enterprise Number → whether it's already in your
|
||
* `ysterbaard_companies` PocketBase collection. */
|
||
collection_matches?: Record<string, CollectionMatch>;
|
||
/** Enterprise No. only — full PocketBase record if the company exists.
|
||
* Used by the diff UI to show side-by-side field comparisons. */
|
||
pb_record?: Record<string, any> | null;
|
||
/** Enterprise No. only — KV pairs extracted from CIPC's ENTERPRISE
|
||
* DETAILS section ("Enterprise Type: Private Company" etc.). */
|
||
cipc_fields?: Record<string, string>;
|
||
/** Enterprise No. only — directors from CIPC's DIRECTOR DETAILS table. */
|
||
cipc_directors?: Array<{ name: string; id: string }> | null;
|
||
}
|
||
|
||
// ===================================================================
|
||
// LOCALSTORAGE PERSISTENCE — last result per kind is cached across tab
|
||
// navigation, page reload, and browser restart (24h TTL). Hydrated on
|
||
// mount, written on successful search, cleared via the "Clear" button.
|
||
//
|
||
// CACHE KEY VERSIONING — bump the prefix version whenever the SearchResponse
|
||
// shape changes. v1 entries don't carry `collection_matches`, so re-using
|
||
// the v1 key after we added enrichment would silently break the
|
||
// "In your collection" pills.
|
||
//
|
||
// NAMESPACE — deliberately "cipc2:*", NOT "cipc:*". This is a separate
|
||
// tab from the original CIPC tab; sharing the namespace would mean one
|
||
// tab's mount-time sweep erases the other's saved report. Keep the "2"
|
||
// until this sandbox is promoted or deleted.
|
||
// ===================================================================
|
||
|
||
const CACHE_PREFIX = "cipc2:lastSearch:v3:";
|
||
// v1 (pre-collection-enrichment). Hydration sweeps this once per mount so
|
||
// stale rows don't haunt the cache forever.
|
||
const LEGACY_CACHE_PREFIX = "cipc2:lastSearch:";
|
||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||
|
||
function purgeLegacyCache() {
|
||
if (typeof window === "undefined") return;
|
||
try {
|
||
const toRemove: string[] = [];
|
||
for (let i = 0; i < window.localStorage.length; i++) {
|
||
const k = window.localStorage.key(i);
|
||
// NOTE: `!k.startsWith(CACHE_PREFIX)` is load-bearing. The legacy
|
||
// prefix is a PREFIX OF the current one, so without this guard the
|
||
// sweep deletes the v3 keys too — which meant every mount wiped the
|
||
// saved report and the cache never survived a reload. The original
|
||
// /cipc tab still has this bug; fixed here only.
|
||
if (k && k.startsWith(LEGACY_CACHE_PREFIX) && !k.startsWith(CACHE_PREFIX)) {
|
||
toRemove.push(k);
|
||
}
|
||
}
|
||
for (const k of toRemove) window.localStorage.removeItem(k);
|
||
} catch {}
|
||
}
|
||
|
||
interface CachedEntry {
|
||
savedAt: number; // epoch ms
|
||
data: SearchResponse;
|
||
}
|
||
|
||
function cacheKey(kind: SearchKind): string {
|
||
return CACHE_PREFIX + kind;
|
||
}
|
||
|
||
function readCache(kind: SearchKind): CachedEntry | null {
|
||
if (typeof window === "undefined") return null;
|
||
try {
|
||
const raw = window.localStorage.getItem(cacheKey(kind));
|
||
if (!raw) return null;
|
||
const parsed = JSON.parse(raw) as CachedEntry;
|
||
if (
|
||
!parsed ||
|
||
typeof parsed.savedAt !== "number" ||
|
||
Date.now() - parsed.savedAt > CACHE_TTL_MS
|
||
) {
|
||
window.localStorage.removeItem(cacheKey(kind));
|
||
return null;
|
||
}
|
||
return parsed;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function writeCache(kind: SearchKind, data: SearchResponse): void {
|
||
if (typeof window === "undefined") return;
|
||
try {
|
||
const entry: CachedEntry = { savedAt: Date.now(), data };
|
||
window.localStorage.setItem(cacheKey(kind), JSON.stringify(entry));
|
||
} catch {
|
||
// QuotaExceededError or serialisation issue — fail silently.
|
||
}
|
||
}
|
||
|
||
function clearCache(kind: SearchKind): void {
|
||
if (typeof window === "undefined") return;
|
||
try {
|
||
window.localStorage.removeItem(cacheKey(kind));
|
||
} catch {}
|
||
}
|
||
|
||
function ageLabel(savedAt: number): string {
|
||
const seconds = Math.max(0, Math.floor((Date.now() - savedAt) / 1000));
|
||
if (seconds < 60) return `${seconds}s ago`;
|
||
const minutes = Math.floor(seconds / 60);
|
||
if (minutes < 60) return `${minutes} min${minutes === 1 ? "" : "s"} ago`;
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||
const days = Math.floor(hours / 24);
|
||
return `${days} day${days === 1 ? "" : "s"} ago`;
|
||
}
|
||
|
||
// ===================================================================
|
||
// DIRECTOR ID HANDLING
|
||
// ===================================================================
|
||
//
|
||
// DISPLAY RULE (SJ): the app never masks an ID number it already holds
|
||
// in full. Whatever CIPC or BizPortal returned is shown verbatim — a
|
||
// masked ID stays masked, a full ID stays full. `maskForm()` below is
|
||
// NOT for display; it exists only to build a comparison key so the same
|
||
// person arriving from a company search (masked) can be matched against
|
||
// a DB row that stores their full number.
|
||
//
|
||
// The checksum gives us a trustworthy "needs checking" signal without
|
||
// guessing at patterns: the BizPortal placeholder bug (X → 0) produces
|
||
// numbers that fail it, which is how the three damaged records in
|
||
// ysterbaard_directors were positively identified.
|
||
// ===================================================================
|
||
|
||
/** True when `s` is a 13-digit string whose SA ID check digit agrees. */
|
||
function isValidSaId(s: string | undefined | null): boolean {
|
||
const v = String(s ?? "").replace(/\s/g, "");
|
||
if (!/^\d{13}$/.test(v)) return false;
|
||
const d = v.split("").map(Number);
|
||
let sum = 0;
|
||
for (let i = 0; i < 12; i++) {
|
||
let n = d[i];
|
||
if (i % 2 === 1) {
|
||
n *= 2;
|
||
if (n > 9) n -= 9;
|
||
}
|
||
sum += n;
|
||
}
|
||
return (10 - (sum % 10)) % 10 === d[12];
|
||
}
|
||
|
||
/** MATCH KEY ONLY — never rendered. Full IDs collapse to the masked
|
||
* form so a masked CIPC result matches a DB row holding the real
|
||
* number. Masked input passes through unchanged. */
|
||
function maskForm(s: string | undefined | null): string {
|
||
const v = String(s ?? "").replace(/\s/g, "");
|
||
if (!v) return "";
|
||
if (/X/i.test(v)) return v.toUpperCase();
|
||
if (v.length === 13) return v.slice(0, 6) + "XXXX" + v.slice(10, 12) + "X";
|
||
return v.toUpperCase();
|
||
}
|
||
|
||
interface DirectorDbRow {
|
||
id: string;
|
||
/** Exactly as stored — shown verbatim, never masked. */
|
||
id_no: string;
|
||
id_no_masked: string;
|
||
name: string;
|
||
/** A usable, checksum-valid full ID number. */
|
||
hasFullId: boolean;
|
||
/** Stored but not a real ID (the X→0 placeholder bug, or bad imports). */
|
||
isDamaged: boolean;
|
||
}
|
||
|
||
/** Outcome banner shown after a save attempt. */
|
||
interface SaveSummary {
|
||
ok: boolean;
|
||
text: string;
|
||
}
|
||
|
||
/**
|
||
* Read-only index of ysterbaard_directors so each row can say whether
|
||
* that person is already saved. Deliberately read-only: nothing in this
|
||
* hook creates, updates or deletes anything.
|
||
*/
|
||
function useDirectorDbIndex(nonce = 0): {
|
||
loaded: boolean;
|
||
byKey: Map<string, DirectorDbRow>;
|
||
} {
|
||
const [state, setState] = useState<{
|
||
loaded: boolean;
|
||
byKey: Map<string, DirectorDbRow>;
|
||
}>({ loaded: false, byKey: new Map() });
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
const byKey = new Map<string, DirectorDbRow>();
|
||
try {
|
||
const res = await fetch(
|
||
"/api/pb/records/list?collection=ysterbaard_directors&perPage=5000"
|
||
);
|
||
if (res.ok) {
|
||
const json = await res.json();
|
||
for (const r of (json.items || []) as any[]) {
|
||
const idNo = String(r.id_no ?? "");
|
||
const idMasked = String(r.id_no_masked ?? "");
|
||
const row: DirectorDbRow = {
|
||
id: r.id,
|
||
id_no: idNo,
|
||
id_no_masked: idMasked,
|
||
name: `${r.first_names ?? ""} ${r.surname ?? ""}`.trim(),
|
||
hasFullId: isValidSaId(idNo),
|
||
// A 13-digit run of digits that fails the check digit is a
|
||
// fabricated placeholder. All-zero counts as damaged too.
|
||
isDamaged: /^\d{13}$/.test(idNo) && !isValidSaId(idNo),
|
||
};
|
||
for (const k of new Set([idMasked, idNo, maskForm(idNo)])) {
|
||
if (k && !byKey.has(k)) byKey.set(k, row);
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// PocketBase unreachable — leave the index empty and let the UI
|
||
// fall back to a neutral "unknown" state rather than lying.
|
||
}
|
||
if (!cancelled) setState({ loaded: true, byKey });
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [nonce]);
|
||
|
||
return state;
|
||
}
|
||
|
||
/** Look a director up by whatever ID form we were handed. */
|
||
function findDirector(
|
||
byKey: Map<string, DirectorDbRow>,
|
||
id: string | undefined | null
|
||
): DirectorDbRow | null {
|
||
const raw = String(id ?? "").replace(/\s/g, "");
|
||
if (!raw) return null;
|
||
return byKey.get(raw) ?? byKey.get(maskForm(raw)) ?? null;
|
||
}
|
||
|
||
const PLACEHOLDERS: Record<SearchKind, string> = {
|
||
enterprise_no: "K-number, company name, or legacy CK",
|
||
director_id_no: "13-digit SA ID number",
|
||
};
|
||
|
||
const ACTION_LABELS: Record<SearchKind, string> = {
|
||
enterprise_no: "Search by Enterprise No.",
|
||
director_id_no: "Search by Director ID No.",
|
||
};
|
||
|
||
const ACCENT: Record<SearchKind, string> = {
|
||
enterprise_no: "Search CIPC for a company by its Enterprise Number, legacy CK number, or registered name.",
|
||
director_id_no: "Search CIPC for every company where a given 13-digit SA ID is currently a registered director.",
|
||
};
|
||
|
||
// ===================================================================
|
||
// MARKDOWN PARSER — converts the .py's stdout into structured sections.
|
||
// ===================================================================
|
||
|
||
type SectionContent =
|
||
| { kind: "kv"; rows: [string, string][] } // "### TITLE\n- K: V"
|
||
| { kind: "table"; columns: string[]; rows: string[][] }
|
||
| { kind: "text"; lines: string[] };
|
||
|
||
interface Section {
|
||
title: string;
|
||
body: SectionContent;
|
||
}
|
||
|
||
interface ParsedReport {
|
||
title: string;
|
||
subtitle?: string;
|
||
caption?: string;
|
||
sections: Section[];
|
||
unmatched: string[]; // any body text we couldn't classify
|
||
}
|
||
|
||
function parseReport(md: string): ParsedReport {
|
||
const lines = md.split(/\r?\n/);
|
||
const titleLine = lines.find((l) => l.startsWith("## "));
|
||
const title = titleLine ? titleLine.replace(/^##\s+/, "") : "CIPC Report";
|
||
const titleIdx = titleLine ? lines.indexOf(titleLine) : -1;
|
||
|
||
// Find section starts ("### ").
|
||
const sectionStarts: { idx: number; title: string }[] = [];
|
||
lines.forEach((l, i) => {
|
||
const m = l.match(/^###\s+(.*)$/);
|
||
if (m) sectionStarts.push({ idx: i, title: m[1].trim() });
|
||
});
|
||
|
||
// Root block: the slice of the body AFTER the title and BEFORE the first
|
||
// "### " header. The Enterprise No. script leaves this empty (everything is
|
||
// wrapped in `### ` blocks). The Director ID script prints the title, an
|
||
// italic summary, and a single table with no `### ` headers — so the root
|
||
// block carries the entire result set. We promote it to a "Results"
|
||
// section only when there are no other sections; otherwise it's just
|
||
// pre-section prose (and lands in `unmatched`).
|
||
const rootStart = titleIdx >= 0 ? titleIdx + 1 : 0;
|
||
const rootEnd = sectionStarts.length > 0 ? sectionStarts[0].idx : lines.length;
|
||
const rootBlock = lines.slice(rootStart, rootEnd);
|
||
|
||
// Build sections.
|
||
const sections: Section[] = [];
|
||
if (sectionStarts.length === 0 && rootBlockHasContent(rootBlock)) {
|
||
sections.push({ title: "Results", body: classifyBlock(rootBlock) });
|
||
}
|
||
for (let s = 0; s < sectionStarts.length; s++) {
|
||
const start = sectionStarts[s].idx + 1;
|
||
const end = s + 1 < sectionStarts.length ? sectionStarts[s + 1].idx : lines.length;
|
||
const block = lines.slice(start, end);
|
||
|
||
sections.push({
|
||
title: sectionStarts[s].title,
|
||
body: classifyBlock(block),
|
||
});
|
||
}
|
||
|
||
// Unmatched: any line outside the sections that we ignored. We don't render
|
||
// this in the report UI (the script prints a small "Done." footer etc.)
|
||
// — but keeping it accessible helps if the parser ever needs debugging.
|
||
const used = new Set<number>();
|
||
sectionStarts.forEach((s, i) => {
|
||
const next = i + 1 < sectionStarts.length ? sectionStarts[i + 1].idx : lines.length;
|
||
for (let j = s.idx; j < next; j++) used.add(j);
|
||
});
|
||
// When we promoted the root block into a "Results" section, also mark its
|
||
// lines as used so they don't double-count in `unmatched`.
|
||
if (sections.length > 0 && sections[0].title === "Results") {
|
||
for (let j = rootStart; j < rootEnd; j++) used.add(j);
|
||
}
|
||
const unmatched = lines.filter((_, i) => !used.has(i));
|
||
|
||
// FILED + OUTSTANDING ANNUAL RETURNS collapse into one ANNUAL RETURNS
|
||
// table here, so every consumer below only ever sees the merged shape.
|
||
return { title, sections: mergeAnnualReturnSections(sections), unmatched };
|
||
}
|
||
|
||
function rootBlockHasContent(block: string[]): boolean {
|
||
// At least one non-blank line that isn't a pure metadata footer (e.g. the
|
||
// .py's "Done." marker) — classifyBlock will decide what shape it is.
|
||
return block.some((l) => l.trim() !== "");
|
||
}
|
||
|
||
function classifyBlock(block: string[]): SectionContent {
|
||
// Find the first significant line, skipping blanks AND leading italic-style
|
||
// summary lines ("_..._") that the .py uses for metadata like "Searches
|
||
// Conducted Today: 23 · 4 matches". Without this, a table preceded by a
|
||
// single italic line would fall through to the "text" branch and be lost.
|
||
let i = 0;
|
||
while (i < block.length) {
|
||
const t = block[i].trim();
|
||
if (t === "") { i++; continue; }
|
||
if (/^_[^_].*[^_]_$|^_[^_]+_$/.test(t)) { i++; continue; }
|
||
break;
|
||
}
|
||
if (i >= block.length) return { kind: "text", lines: [] };
|
||
|
||
// Detect table: line starts with "|"
|
||
if (block[i].trim().startsWith("|")) {
|
||
return parseTable(block);
|
||
}
|
||
|
||
// Detect key-value list: lines start with "- Label:"
|
||
if (block[i].trim().startsWith("- ")) {
|
||
return parseKvList(block);
|
||
}
|
||
|
||
// Fallback: plain text block.
|
||
return { kind: "text", lines: block };
|
||
}
|
||
|
||
function parseTable(block: string[]): SectionContent {
|
||
const rows: string[][] = [];
|
||
let header: string[] | null = null;
|
||
let bodyStartIdx = 0;
|
||
for (let i = 0; i < block.length; i++) {
|
||
const raw = block[i].trim();
|
||
if (!raw.startsWith("|")) continue;
|
||
const cells = raw.split("|").slice(1, -1).map((c) => c.trim());
|
||
// Skip the alignment row (all dashes / colons)
|
||
if (cells.every((c) => /^[-:]+$/.test(c))) {
|
||
bodyStartIdx = i + 1;
|
||
continue;
|
||
}
|
||
if (header === null) {
|
||
header = cells;
|
||
} else {
|
||
rows.push(cells);
|
||
}
|
||
}
|
||
return {
|
||
kind: "table",
|
||
columns: header ?? [],
|
||
rows,
|
||
};
|
||
}
|
||
|
||
function parseKvList(block: string[]): SectionContent {
|
||
const rows: [string, string][] = [];
|
||
let i = 0;
|
||
while (i < block.length) {
|
||
const line = block[i].trim();
|
||
if (!line.startsWith("- ")) {
|
||
i++;
|
||
continue;
|
||
}
|
||
const m = line.match(/^-\s+([^:]+?):\s*(.*)$/);
|
||
if (m) {
|
||
rows.push([m[1].trim(), m[2].trim()]);
|
||
}
|
||
i++;
|
||
}
|
||
return { kind: "kv", rows };
|
||
}
|
||
|
||
// ===================================================================
|
||
// ANNUAL RETURNS MERGE (CIPC2.0 sandbox only — the original /cipc tab
|
||
// still renders the two sections separately and is left untouched.)
|
||
// ===================================================================
|
||
//
|
||
// CIPC hands back Filed and Outstanding annual returns as TWO separate
|
||
// tables with different columns:
|
||
//
|
||
// ### FILED ANNUAL RETURNS
|
||
// | AR Year | Amount Paid | Date Filed |
|
||
// ### OUTSTANDING ANNUAL RETURNS
|
||
// | AR Year | AR Month | Non-Compliance Date |
|
||
//
|
||
// For an accountant that is one list with a status column, not two
|
||
// lists. We fold them together at PARSE time rather than render time,
|
||
// so the renderer, the diff rule and the PocketBase write all see a
|
||
// single "ANNUAL RETURNS" section and never need to know the source
|
||
// tables were ever split.
|
||
//
|
||
// Merged row shape: [year, status, period, amount, prefixedDate]
|
||
// Filed → 2024 | Filed | | 1,500.00 | Filed 2024/06/30
|
||
// Outstanding → 2023 | Outstanding | AUGUST| | Non-compliant 2024/10/31
|
||
//
|
||
// The Date cell carries a prefix because the column means different
|
||
// things per row (filed-on vs non-compliant-since). `stripArDatePrefix`
|
||
// removes it again before the value is written back to PocketBase.
|
||
|
||
const AR_COLUMNS = ["AR Year", "Status", "AR Period", "Amount Paid", "Date"];
|
||
const AR_STATUS_FILED = "Filed";
|
||
const AR_STATUS_OUTSTANDING = "Outstanding";
|
||
const AR_DATE_PREFIX_FILED = "Filed";
|
||
const AR_DATE_PREFIX_OUTSTANDING = "Non-compliant";
|
||
|
||
/** Row indices inside a merged ANNUAL RETURNS table. */
|
||
const AR_COL_YEAR = 0;
|
||
const AR_COL_STATUS = 1;
|
||
const AR_COL_PERIOD = 2;
|
||
const AR_COL_AMOUNT = 3;
|
||
const AR_COL_DATE = 4;
|
||
|
||
function stripArDatePrefix(v: string): string {
|
||
return String(v ?? "").replace(/^(?:Filed|Non-compliant)\s+/i, "").trim();
|
||
}
|
||
|
||
function isArFiledRow(row: string[]): boolean {
|
||
return (row[AR_COL_STATUS] ?? "").trim().toLowerCase() === AR_STATUS_FILED.toLowerCase();
|
||
}
|
||
|
||
function isArOutstandingRow(row: string[]): boolean {
|
||
return (row[AR_COL_STATUS] ?? "").trim().toLowerCase() === AR_STATUS_OUTSTANDING.toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* Fold FILED / OUTSTANDING ANNUAL RETURNS into one ANNUAL RETURNS table.
|
||
* Returns `sections` untouched when neither section is present (e.g. the
|
||
* Director-ID report, which has no annual returns at all).
|
||
*/
|
||
function mergeAnnualReturnSections(sections: Section[]): Section[] {
|
||
const filedIdx = sections.findIndex((s) => /^filed annual returns$/i.test(s.title.trim()));
|
||
const outIdx = sections.findIndex((s) => /^outstanding annual returns$/i.test(s.title.trim()));
|
||
if (filedIdx === -1 && outIdx === -1) return sections;
|
||
|
||
const rows: string[][] = [];
|
||
|
||
const filed = filedIdx >= 0 ? sections[filedIdx] : null;
|
||
if (filed && filed.body.kind === "table") {
|
||
for (const r of filed.body.rows) {
|
||
const date = (r[2] ?? "").trim();
|
||
rows.push([
|
||
(r[0] ?? "").trim(),
|
||
AR_STATUS_FILED,
|
||
"",
|
||
(r[1] ?? "").trim(),
|
||
date ? `${AR_DATE_PREFIX_FILED} ${date}` : "",
|
||
]);
|
||
}
|
||
}
|
||
|
||
const outstanding = outIdx >= 0 ? sections[outIdx] : null;
|
||
if (outstanding && outstanding.body.kind === "table") {
|
||
for (const r of outstanding.body.rows) {
|
||
const date = (r[2] ?? "").trim();
|
||
rows.push([
|
||
(r[0] ?? "").trim(),
|
||
AR_STATUS_OUTSTANDING,
|
||
(r[1] ?? "").trim(),
|
||
"",
|
||
date ? `${AR_DATE_PREFIX_OUTSTANDING} ${date}` : "",
|
||
]);
|
||
}
|
||
}
|
||
|
||
// Outstanding first — that's the actionable list — then newest year
|
||
// first inside each group.
|
||
rows.sort((a, b) => {
|
||
const ao = isArOutstandingRow(a) ? 0 : 1;
|
||
const bo = isArOutstandingRow(b) ? 0 : 1;
|
||
if (ao !== bo) return ao - bo;
|
||
return (parseInt(b[AR_COL_YEAR], 10) || 0) - (parseInt(a[AR_COL_YEAR], 10) || 0);
|
||
});
|
||
|
||
const merged: Section = {
|
||
title: "ANNUAL RETURNS",
|
||
body: { kind: "table", columns: [...AR_COLUMNS], rows },
|
||
};
|
||
|
||
// Splice in at the earlier of the two positions and drop the later.
|
||
const keep = Math.min(
|
||
filedIdx === -1 ? outIdx : filedIdx,
|
||
outIdx === -1 ? filedIdx : outIdx
|
||
);
|
||
const drop = filedIdx !== -1 && outIdx !== -1 ? Math.max(filedIdx, outIdx) : -1;
|
||
|
||
const next: Section[] = [];
|
||
sections.forEach((s, i) => {
|
||
if (i === keep) next.push(merged);
|
||
else if (i === drop) {
|
||
/* removed — folded into the merged section above */
|
||
} else next.push(s);
|
||
});
|
||
return next;
|
||
}
|
||
|
||
// ===================================================================
|
||
// MAIN COMPONENT
|
||
// ===================================================================
|
||
|
||
export default function CipcExplorer() {
|
||
const params = useSearchParams();
|
||
const initialKind: SearchKind =
|
||
params.get("kind") === "director_id_no" ? "director_id_no" : "enterprise_no";
|
||
const initialQuery = params.get("q") || "";
|
||
|
||
const [data, setData] = useState<Record<SearchKind, SearchResponse | null>>({
|
||
enterprise_no: null,
|
||
director_id_no: null,
|
||
});
|
||
// When the cached entry was saved — used to show a "cached Xh ago" pill.
|
||
// null when the report is fresh from this session.
|
||
const [savedAt, setSavedAt] = useState<Record<SearchKind, number | null>>({
|
||
enterprise_no: null,
|
||
director_id_no: null,
|
||
});
|
||
const [inputs, setInputs] = useState<Record<SearchKind, string>>({
|
||
enterprise_no: initialKind === "enterprise_no" ? initialQuery : "",
|
||
director_id_no: initialKind === "director_id_no" ? initialQuery : "",
|
||
});
|
||
const [loading, setLoading] = useState<Record<SearchKind, boolean>>({
|
||
enterprise_no: false,
|
||
director_id_no: false,
|
||
});
|
||
|
||
// Hydrate from localStorage on mount. Also drop any pre-v2 (missing
|
||
// collection_matches) entries so we never re-display the stale "UNKNOWN"
|
||
// pills the user just hit.
|
||
useEffect(() => {
|
||
purgeLegacyCache();
|
||
(["enterprise_no", "director_id_no"] as SearchKind[]).forEach((kind) => {
|
||
const cached = readCache(kind);
|
||
if (cached) {
|
||
setData((d) => ({ ...d, [kind]: cached.data }));
|
||
setSavedAt((s) => ({ ...s, [kind]: cached.savedAt }));
|
||
}
|
||
});
|
||
}, []);
|
||
|
||
// Auto-fire deep-link if URL has ?q=&kind=
|
||
useEffect(() => {
|
||
if (!initialQuery) return;
|
||
const form = document.getElementById(`cipc-form-${initialKind}`) as HTMLFormElement | null;
|
||
if (form) form.requestSubmit();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
async function runSearch(kind: SearchKind) {
|
||
const q = (inputs[kind] || "").trim();
|
||
if (!q) return;
|
||
setLoading((s) => ({ ...s, [kind]: true }));
|
||
setData((d) => ({ ...d, [kind]: null }));
|
||
setSavedAt((s) => ({ ...s, [kind]: null }));
|
||
try {
|
||
const res = await fetch("/api/cipc/search", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ kind, query: q }),
|
||
});
|
||
const json: SearchResponse = await res.json();
|
||
setData((d) => ({ ...d, [kind]: json }));
|
||
// Persist successful results so the report survives tab navigation.
|
||
// Failed searches are intentionally NOT cached — re-runs may succeed.
|
||
if (json.ok) {
|
||
const now = Date.now();
|
||
writeCache(kind, json);
|
||
setSavedAt((s) => ({ ...s, [kind]: now }));
|
||
} else {
|
||
clearCache(kind);
|
||
}
|
||
} catch (err: any) {
|
||
setData((d) => ({
|
||
...d,
|
||
[kind]: { ok: false, error: err?.message || "Network error" },
|
||
}));
|
||
clearCache(kind);
|
||
} finally {
|
||
setLoading((s) => ({ ...s, [kind]: false }));
|
||
}
|
||
}
|
||
|
||
function onSubmit(kind: SearchKind, e: React.FormEvent) {
|
||
e.preventDefault();
|
||
runSearch(kind);
|
||
}
|
||
|
||
function onClear(kind: SearchKind) {
|
||
setData((d) => ({ ...d, [kind]: null }));
|
||
setSavedAt((s) => ({ ...s, [kind]: null }));
|
||
clearCache(kind);
|
||
}
|
||
|
||
// One search at a time, full width — same pattern as the BizPortal tab.
|
||
// Side-by-side cards squeezed the report into ~540px, which clipped the
|
||
// directors table's "Saved" column off-screen.
|
||
const [activeKind, setActiveKind] = useState<SearchKind>(initialKind);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div
|
||
className="flex gap-1 rounded-md bg-surface-warm p-1 w-fit"
|
||
role="tablist"
|
||
aria-label="CIPC search type"
|
||
>
|
||
{(
|
||
[
|
||
["enterprise_no", ACTION_LABELS.enterprise_no],
|
||
["director_id_no", ACTION_LABELS.director_id_no],
|
||
] as [SearchKind, string][]
|
||
).map(([val, label]) => {
|
||
const active = activeKind === val;
|
||
const hasResult = !!data[val];
|
||
return (
|
||
<button
|
||
key={val}
|
||
role="tab"
|
||
aria-selected={active}
|
||
onClick={() => setActiveKind(val)}
|
||
className={
|
||
"px-5 py-2.5 rounded-sm text-sm font-semibold transition-all inline-flex items-center gap-2 " +
|
||
(active
|
||
? "bg-surface text-fg shadow-sm"
|
||
: "text-muted hover:text-fg")
|
||
}
|
||
>
|
||
<span>{label}</span>
|
||
{hasResult && (
|
||
<span
|
||
title={savedAt[val] ? `Result saved ${ageLabel(savedAt[val]!)}` : "Result loaded"}
|
||
className="w-1.5 h-1.5 rounded-full bg-accent"
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{activeKind === "enterprise_no" ? (
|
||
<SearchCard
|
||
kind="enterprise_no"
|
||
loading={loading.enterprise_no}
|
||
result={data.enterprise_no}
|
||
cachedAt={savedAt.enterprise_no}
|
||
input={inputs.enterprise_no}
|
||
onInputChange={(v) => setInputs((s) => ({ ...s, enterprise_no: v }))}
|
||
onSubmit={(e) => onSubmit("enterprise_no", e)}
|
||
onClear={() => onClear("enterprise_no")}
|
||
/>
|
||
) : (
|
||
<SearchCard
|
||
kind="director_id_no"
|
||
loading={loading.director_id_no}
|
||
result={data.director_id_no}
|
||
cachedAt={savedAt.director_id_no}
|
||
input={inputs.director_id_no}
|
||
onInputChange={(v) => setInputs((s) => ({ ...s, director_id_no: v }))}
|
||
onSubmit={(e) => onSubmit("director_id_no", e)}
|
||
onClear={() => onClear("director_id_no")}
|
||
/>
|
||
)}
|
||
|
||
<PrintStyles />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ===================================================================
|
||
// SEARCH CARD
|
||
// ===================================================================
|
||
|
||
interface SearchCardProps {
|
||
kind: SearchKind;
|
||
loading: boolean;
|
||
result: SearchResponse | null;
|
||
cachedAt: number | null;
|
||
input: string;
|
||
onInputChange: (v: string) => void;
|
||
onSubmit: (e: React.FormEvent) => void;
|
||
onClear: () => void;
|
||
}
|
||
|
||
/** A CIPC search that takes this long is on the slow fallback path. */
|
||
const SLOW_SEARCH_WARNING_MS = 20_000;
|
||
/** Past this the request will be killed server-side at 180s. */
|
||
const HARD_TIMEOUT_WARNING_MS = 150_000;
|
||
|
||
/**
|
||
* Elapsed-time feedback while a search runs.
|
||
*
|
||
* Without this a 70-90 second cold login looks exactly like a broken app —
|
||
* which is how the "searches for ages" problem went unnoticed for three
|
||
* weeks. Now the wait is explained, and past ~20s we say plainly that the
|
||
* fast service isn't running so it isn't a mystery.
|
||
*/
|
||
function SearchProgress({ startedAt }: { startedAt: number }) {
|
||
const [, force] = useState(0);
|
||
useEffect(() => {
|
||
const t = setInterval(() => force((n) => n + 1), 1000);
|
||
return () => clearInterval(t);
|
||
}, []);
|
||
|
||
const elapsed = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
|
||
const slow = elapsed * 1000 >= SLOW_SEARCH_WARNING_MS;
|
||
|
||
return (
|
||
<div className="px-6 py-3 border-t border-border bg-surface-warm no-print">
|
||
<p className="text-xs text-muted">
|
||
Still searching — {elapsed}s elapsed.
|
||
{elapsed * 1000 >= HARD_TIMEOUT_WARNING_MS &&
|
||
" It will stop automatically at 180s if CIPC does not answer."}
|
||
</p>
|
||
{slow && (
|
||
<p className="text-[11px] text-amber-700 mt-1.5">
|
||
Taking longer than usual. The fast CIPC service looks offline, so this
|
||
is doing a full sign-in to BizPortal, which normally takes 1–2 minutes.
|
||
Do not refresh — it will still finish.
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SearchCard({
|
||
kind,
|
||
loading,
|
||
result,
|
||
cachedAt,
|
||
input,
|
||
onInputChange,
|
||
onSubmit,
|
||
onClear,
|
||
}: SearchCardProps) {
|
||
// When this card entered the loading state. Null until then.
|
||
const [searchStartedAt, setSearchStartedAt] = useState<number | null>(null);
|
||
useEffect(() => {
|
||
setSearchStartedAt(loading ? Date.now() : null);
|
||
}, [loading]);
|
||
|
||
return (
|
||
<section
|
||
data-cipc-card={kind}
|
||
className="rounded-md border border-border bg-surface shadow-sm overflow-hidden"
|
||
>
|
||
<header className="px-6 py-4 border-b border-border bg-surface-warm">
|
||
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
||
<h2 className="text-xl font-bold tracking-tight text-fg">
|
||
{ACTION_LABELS[kind]}
|
||
</h2>
|
||
{loading ? (
|
||
<StatusPill tone="busy">Searching CIPC…</StatusPill>
|
||
) : result ? (
|
||
<StatusPill tone={result.ok ? "ok" : "err"}>
|
||
{result.ok ? "Complete" : "Failed"}
|
||
</StatusPill>
|
||
) : (
|
||
<StatusPill tone="idle">Ready</StatusPill>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-muted mt-1.5 leading-relaxed">{ACCENT[kind]}</p>
|
||
</header>
|
||
|
||
<form
|
||
id={`cipc-form-${kind}`}
|
||
onSubmit={onSubmit}
|
||
className="px-6 py-5 space-y-3 border-b border-border"
|
||
>
|
||
<label
|
||
htmlFor={`input-${kind}`}
|
||
className="block text-[11px] uppercase tracking-wider font-semibold text-muted"
|
||
>
|
||
{kind === "enterprise_no" ? "Enterprise No. or company name" : "Director ID No."}
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
id={`input-${kind}`}
|
||
type="text"
|
||
value={input}
|
||
onChange={(e) => onInputChange(e.target.value)}
|
||
placeholder={PLACEHOLDERS[kind]}
|
||
className="flex-1 px-3.5 py-2.5 rounded-md border border-border bg-surface text-fg text-sm placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
||
required
|
||
disabled={loading}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="px-5 py-2.5 rounded-md bg-accent text-accent-on font-semibold text-sm hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||
>
|
||
{loading ? (
|
||
<span className="inline-flex items-center gap-2">
|
||
<Spinner /> Working…
|
||
</span>
|
||
) : (
|
||
"Search"
|
||
)}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
{loading && searchStartedAt !== null && <SearchProgress startedAt={searchStartedAt} />}
|
||
|
||
{result && (
|
||
<div className="px-6 py-5 bg-surface">
|
||
{result.ok ? (
|
||
<ReportView result={result} cachedAt={cachedAt} onClear={onClear} />
|
||
) : (
|
||
<ErrorView result={result} />
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ===================================================================
|
||
// REPORT VIEW (the polished output)
|
||
// ===================================================================
|
||
|
||
function ReportView({
|
||
result,
|
||
cachedAt,
|
||
onClear,
|
||
}: {
|
||
result: SearchResponse;
|
||
cachedAt: number | null;
|
||
onClear: () => void;
|
||
}) {
|
||
const parsed = useMemo(() => parseReport(result.markdown ?? ""), [result.markdown]);
|
||
const now = useNow();
|
||
const kind: SearchKind = result.search_kind ?? "enterprise_no";
|
||
|
||
// Read-only index of the directors already in PocketBase, so each row
|
||
// can show "In DB" / "Select to add" / "Needs ID" without a re-search.
|
||
// Bumping the nonce after a save forces a re-read so new rows appear.
|
||
const [directorDbNonce, setDirectorDbNonce] = useState(0);
|
||
const directorDb = useDirectorDbIndex(directorDbNonce);
|
||
|
||
// ── Selection (checkbox) state ──────────────────────────────────
|
||
// Keys are `company:<ENTNO>` and `director:<maskForm(ID)>`. Nothing is
|
||
// written anywhere from this state yet — it only drives the summary
|
||
// bar and the highlight. The PROCESS action is wired up in a later
|
||
// step, so the button stays disabled and says so.
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
const [ackCleared, setAckCleared] = useState(false);
|
||
|
||
// A new search is a new set of rows — drop any stale selection.
|
||
useEffect(() => {
|
||
setSelected(new Set());
|
||
setClientTicks(new Set());
|
||
setAckCleared(false);
|
||
setSaveMsg(null);
|
||
}, [result.markdown, kind]);
|
||
|
||
const toggleSelected = useCallback((key: string) => {
|
||
setSelected((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(key)) next.delete(key);
|
||
else next.add(key);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
// Client status is tracked SEPARATELY from selection so one batch can mix
|
||
// real clients and prospects. Everything defaults to "not a client" —
|
||
// SJ ticks the Client box deliberately per row (2026-10-08).
|
||
const [clientTicks, setClientTicks] = useState<Set<string>>(new Set());
|
||
const [saving, setSaving] = useState(false);
|
||
const [saveMsg, setSaveMsg] = useState<SaveSummary | null>(null);
|
||
|
||
const toggleClient = useCallback((key: string) => {
|
||
setClientTicks((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(key)) next.delete(key);
|
||
else next.add(key);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
// Backend-supplied enrichment (or empty when PB was unreachable).
|
||
const serverMatches = result.collection_matches ?? {};
|
||
// Local optimistic adds — added through this modal during the session,
|
||
// so the row flips to "In collection" without needing a re-search.
|
||
const [addedOverrides, setAddedOverrides] = useState<Record<string, CollectionMatch>>({});
|
||
const collectionMatches = useMemo(
|
||
() => ({ ...serverMatches, ...addedOverrides }),
|
||
[serverMatches, addedOverrides]
|
||
);
|
||
|
||
// Locate the table in the parsed report that lists director-search results.
|
||
const directorTableSection = useMemo(() => {
|
||
if (kind !== "director_id_no") return null;
|
||
return (
|
||
parsed.sections.find(
|
||
(s) =>
|
||
s.body.kind === "table" &&
|
||
(s.body.columns[0] ?? "").trim().toLowerCase() === "enterprise number"
|
||
) ?? null
|
||
);
|
||
}, [parsed, kind]);
|
||
const directorRows: string[][] =
|
||
directorTableSection?.body.kind === "table"
|
||
? directorTableSection.body.rows
|
||
: [];
|
||
|
||
// Enterprise No.: the DIRECTOR DETAILS table carries the full 5 columns
|
||
// (ID Number | Name(s) | Surname | Type | Status). We render those raw
|
||
// rows rather than `result.cipc_directors`, because that field collapses
|
||
// Name + Surname into one string and drops Type/Status entirely.
|
||
const directorDetailRows: string[][] = useMemo(() => {
|
||
if (kind !== "enterprise_no") return [];
|
||
const s = parsed.sections.find(
|
||
(sec) => sec.body.kind === "table" && /^director details$/i.test(sec.title)
|
||
);
|
||
return s?.body.kind === "table" ? s.body.rows : [];
|
||
}, [parsed, kind]);
|
||
|
||
// For Enterprise No. reports we render KV sections (COMPANY DETAILS,
|
||
// OTHER DETAILS) as inline diff tables, and the 2 multi-row tables
|
||
// (DIRECTOR DETAILS, the merged ANNUAL RETURNS) as table diffs. Match
|
||
// them by title so the .py can evolve without us rewriting the
|
||
// renderer. Sections with no diff rule fall back to the default
|
||
// SectionBlock rendering.
|
||
const kvSections = useMemo(() => {
|
||
if (kind !== "enterprise_no") return [] as Section[];
|
||
return parsed.sections.filter(
|
||
(s) => s.body.kind === "kv" && hasAnyMappingForKv(s.title)
|
||
);
|
||
}, [parsed, kind]);
|
||
const tableSections = useMemo(() => {
|
||
if (kind !== "enterprise_no") return [] as Section[];
|
||
return parsed.sections.filter(
|
||
(s) => s.body.kind === "table" && getTableDiffRule(s.title)
|
||
);
|
||
}, [parsed, kind]);
|
||
|
||
// Enterprise No. reports: pull the canonical K-number from the title.
|
||
const enterpriseNo = useMemo(() => {
|
||
if (kind !== "enterprise_no") return null;
|
||
const m = parsed.title.match(/\(([KMB]\d{10,12})\)/);
|
||
return m ? m[1].toUpperCase() : null;
|
||
}, [parsed.title, kind]);
|
||
const enterpriseName = useMemo(() => {
|
||
if (kind !== "enterprise_no" || !parsed.title) return null;
|
||
// Title looks like "Company: <NAME> (Kxxxxx)" — strip both ends.
|
||
const stripped = parsed.title
|
||
.replace(/^Company:\s*/i, "")
|
||
.replace(/\s*\([KMB]\d{10,12}\)\s*$/, "")
|
||
.trim();
|
||
return stripped || null;
|
||
}, [parsed.title, kind]);
|
||
|
||
// Counts behind the selection bar. "Missing" means not yet in
|
||
// PocketBase; "damaged" means present but the stored ID fails its
|
||
// checksum, so it cannot be trusted as this person's real number.
|
||
const selCounts = useMemo(() => {
|
||
let companiesIn = 0;
|
||
let companiesMissing = 0;
|
||
let directorsIn = 0;
|
||
let directorsMissing = 0;
|
||
let directorsDamaged = 0;
|
||
|
||
if (kind === "enterprise_no") {
|
||
if (enterpriseNo) {
|
||
if (collectionMatches[enterpriseNo]?.in_collection) companiesIn++;
|
||
else companiesMissing++;
|
||
}
|
||
for (const row of directorDetailRows) {
|
||
const hit = findDirector(directorDb.byKey, row[0]);
|
||
if (hit?.hasFullId) directorsIn++;
|
||
else {
|
||
directorsMissing++;
|
||
if (hit?.isDamaged) directorsDamaged++;
|
||
}
|
||
}
|
||
} else {
|
||
for (const row of directorRows) {
|
||
const ent = (row[0] || "").replace(/\s/g, "").toUpperCase();
|
||
if (collectionMatches[ent]?.in_collection) companiesIn++;
|
||
else companiesMissing++;
|
||
}
|
||
}
|
||
return { companiesIn, companiesMissing, directorsIn, directorsMissing, directorsDamaged };
|
||
}, [
|
||
kind,
|
||
enterpriseNo,
|
||
collectionMatches,
|
||
directorDetailRows,
|
||
directorRows,
|
||
directorDb.byKey,
|
||
]);
|
||
|
||
// ── Save the selection to PocketBase ───────────────────────────────
|
||
// Companies and directors are sent as flat arrays; links reference them
|
||
// by array index so the server can wire up real PB ids after creating
|
||
// them. Directors whose ID came back masked are sent with `id_no` left
|
||
// EMPTY and the masked value in `id_no_masked` — the server decides what
|
||
// is trustworthy, we never guess here.
|
||
async function saveSelection() {
|
||
if (selected.size === 0 || saving) return;
|
||
setSaving(true);
|
||
setSaveMsg(null);
|
||
|
||
const regDate = (() => {
|
||
const raw = result.cipc_fields?.["Registration Date"] ?? "";
|
||
const m = raw.match(/^(\d{4})[\/\-](\d{2})[\/\-](\d{2})$/);
|
||
return m ? `${m[1]}-${m[2]}-${m[3]}` : "";
|
||
})();
|
||
const sourceTag =
|
||
kind === "enterprise_no"
|
||
? `cipc_enterprise_search:${result.query ?? ""}`
|
||
: `cipc_director_search:${result.query ?? ""}`;
|
||
|
||
const companyPayload: Record<string, unknown>[] = [];
|
||
const directorPayload: Record<string, unknown>[] = [];
|
||
const linkPayload: Record<string, unknown>[] = [];
|
||
let companyIndex = -1;
|
||
|
||
if (kind === "enterprise_no") {
|
||
const companyKey = `company:${enterpriseNo}`;
|
||
if (enterpriseNo && enterpriseName && selected.has(companyKey)) {
|
||
companyIndex = 0;
|
||
companyPayload.push({
|
||
enterprise_no: enterpriseNo,
|
||
enterprise_name: enterpriseName,
|
||
enterprise_status: result.cipc_fields?.["Status"] ?? "",
|
||
enterprise_type: result.cipc_fields?.["Enterprise Type"] ?? "",
|
||
registration_date: regDate,
|
||
physical_address: result.cipc_fields?.["Physical Address"] ?? "",
|
||
postal_address: result.cipc_fields?.["Postal Address"] ?? "",
|
||
is_client: clientTicks.has(companyKey),
|
||
source: sourceTag,
|
||
});
|
||
}
|
||
|
||
directorDetailRows.forEach((row, i) => {
|
||
const rawId = (row[0] ?? "").trim();
|
||
const key = `director:${maskForm(rawId)}`;
|
||
if (!rawId || !selected.has(key)) return;
|
||
const directorIndex = directorPayload.length;
|
||
// Verbatim ID. The server validates the check digit and decides
|
||
// whether it may go in `id_no` or only `id_no_masked`.
|
||
directorPayload.push({
|
||
id_no: rawId,
|
||
id_no_masked: maskForm(rawId),
|
||
first_names: (row[1] ?? "").trim(),
|
||
surname: (row[2] ?? "").trim(),
|
||
director_type: (row[3] ?? "Director").trim() || "Director",
|
||
is_client: clientTicks.has(key),
|
||
source: sourceTag,
|
||
});
|
||
// Link to the company when both are in this batch.
|
||
if (companyIndex >= 0) {
|
||
linkPayload.push({
|
||
company: companyIndex,
|
||
director: directorIndex,
|
||
director_type: (row[3] ?? "Director").trim() || "Director",
|
||
});
|
||
}
|
||
void i;
|
||
});
|
||
} else {
|
||
for (const row of directorRows) {
|
||
const ent = (row[0] ?? "").replace(/\s/g, "").toUpperCase();
|
||
const key = `company:${ent}`;
|
||
if (!ent || !selected.has(key)) continue;
|
||
companyPayload.push({
|
||
enterprise_no: ent,
|
||
enterprise_name: (row[1] ?? "").trim(),
|
||
enterprise_status: (row[2] ?? "").trim(),
|
||
is_client: clientTicks.has(key),
|
||
source: sourceTag,
|
||
});
|
||
}
|
||
}
|
||
|
||
try {
|
||
const res = await fetch("/api/cipc/save-selection", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
companies: companyPayload,
|
||
directors: directorPayload,
|
||
links: linkPayload,
|
||
}),
|
||
});
|
||
const json = await res.json();
|
||
if (!res.ok || !json.ok) {
|
||
setSaveMsg({ ok: false, text: json?.error || `Save failed (HTTP ${res.status})` });
|
||
return;
|
||
}
|
||
|
||
// Flip the saved companies so the rows read "in your records"
|
||
// without paying for another CIPC search.
|
||
const entNos: string[] = json.saved_enterprise_nos || [];
|
||
if (entNos.length > 0) {
|
||
setAddedOverrides((prev) => {
|
||
const next = { ...prev };
|
||
entNos.forEach((no, idx) => {
|
||
next[no.toUpperCase()] = {
|
||
in_collection: true,
|
||
pb_id: json.companies?.results?.[idx]?.id ?? "",
|
||
pb_name: companyPayload[idx]?.enterprise_name as string,
|
||
pb_status: "",
|
||
};
|
||
});
|
||
return next;
|
||
});
|
||
}
|
||
|
||
// Re-read the directors collection so freshly created rows (and the
|
||
// masked-only ones) reflect immediately.
|
||
setDirectorDbNonce((n) => n + 1);
|
||
|
||
setSaveMsg(summariseSave(json));
|
||
setSelected(new Set());
|
||
setClientTicks(new Set());
|
||
} catch (e) {
|
||
setSaveMsg({
|
||
ok: false,
|
||
text: e instanceof Error ? e.message : "Network error",
|
||
});
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
function summariseSave(json: any): SaveSummary {
|
||
const c = json.companies ?? {};
|
||
const d = json.directors ?? {};
|
||
const l = json.links ?? {};
|
||
const parts: string[] = [];
|
||
if (c.created) parts.push(`${c.created} company${c.created === 1 ? "" : "s"} saved`);
|
||
if (c.updated) parts.push(`${c.updated} company updated`);
|
||
if (c.existing) parts.push(`${c.existing} already up to date`);
|
||
if (c.failed) parts.push(`${c.failed} company failed`);
|
||
if (d.created) parts.push(`${d.created} director${d.created === 1 ? "" : "s"} saved`);
|
||
if (d.updated) parts.push(`${d.updated} director updated`);
|
||
if (d.existing) parts.push(`${d.existing} director(s) already up to date`);
|
||
if (d.skipped) parts.push(`${d.skipped} skipped (no name)`);
|
||
if (d.failed) parts.push(`${d.failed} director failed`);
|
||
if (l.created) parts.push(`${l.created} link${l.created === 1 ? "" : "s"} created`);
|
||
if (l.skipped) parts.push(`${l.skipped} link(s) skipped`);
|
||
if (l.failed) parts.push(`${l.failed} link(s) failed`);
|
||
|
||
const failed = (c.failed ?? 0) + (d.failed ?? 0) + (l.failed ?? 0);
|
||
return {
|
||
ok: failed === 0,
|
||
text: parts.length ? parts.join(" · ") : "Nothing to save.",
|
||
};
|
||
}
|
||
|
||
// Pre-fill payload for the Add Company modal. Director ID rows provide
|
||
// every column; Enterprise No. pulls name from the title.
|
||
const [adding, setAdding] = useState<AddCompanyPreFill | null>(null);
|
||
|
||
// Local override for the PB record. Set when the user applies a diff
|
||
// update via CompanyDetailsWithDiff; takes precedence over
|
||
// `result.pb_record` so the merged COMPANY DETAILS table re-renders with
|
||
// the new PB values without re-hitting CIPC.
|
||
const [pbRecordOverride, setPbRecordOverride] = useState<Record<string, any> | null>(
|
||
null
|
||
);
|
||
const livePbRecord = pbRecordOverride ?? result.pb_record ?? null;
|
||
|
||
return (
|
||
<article
|
||
data-cipc-report
|
||
className="rounded-md border border-border bg-surface text-fg overflow-hidden"
|
||
>
|
||
{/* ── Cover banner ──────────────────────────────────────────── */}
|
||
<header className="px-7 py-5 bg-surface-warm border-b border-border flex items-start justify-between gap-4">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<p className="text-[10px] uppercase tracking-[0.18em] font-bold text-accent">
|
||
CIPC BizProfile Report
|
||
</p>
|
||
{cachedAt !== null && (
|
||
<span
|
||
title={`Stored ${new Date(cachedAt).toLocaleString("en-ZA")} · cleared on Clear or new search`}
|
||
className="no-print inline-flex items-center gap-1 px-1.5 py-0.5 rounded-pill bg-amber-50 border border-amber-200 text-amber-800 text-[10px] font-semibold uppercase tracking-wider"
|
||
>
|
||
<PinIcon />
|
||
Cached {ageLabel(cachedAt)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<h3 className="text-lg font-bold tracking-tight mt-1">{parsed.title}</h3>
|
||
{parsed.subtitle && (
|
||
<p className="text-xs text-muted mt-0.5">{parsed.subtitle}</p>
|
||
)}
|
||
</div>
|
||
<div className="no-print shrink-0 flex items-center gap-2">
|
||
<button
|
||
onClick={onClear}
|
||
title="Clear cached report"
|
||
type="button"
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border border-border bg-surface text-muted text-xs font-semibold hover:text-red-700 hover:border-red-200 transition-colors"
|
||
>
|
||
<ClearIcon />
|
||
Clear
|
||
</button>
|
||
<button
|
||
onClick={() => window.print()}
|
||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-surface text-fg text-xs font-semibold hover:bg-accent hover:text-accent-on hover:border-accent transition-colors"
|
||
type="button"
|
||
>
|
||
<PrintIcon />
|
||
Print / Save PDF
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* ── Metadata strip ────────────────────────────────────────── */}
|
||
<dl className="px-7 py-3 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-x-6 gap-y-2 text-xs border-b border-border bg-surface">
|
||
<MetaCell label="Query" value={result.query ?? "—"} mono />
|
||
{result.resolved_query && result.resolved_query !== result.query && (
|
||
<MetaCell
|
||
label="Resolved"
|
||
value={result.resolved_query}
|
||
mono
|
||
accent
|
||
/>
|
||
)}
|
||
<MetaCell
|
||
label="Duration"
|
||
value={
|
||
result.duration_ms !== undefined
|
||
? `${(result.duration_ms / 1000).toFixed(1)}s`
|
||
: "—"
|
||
}
|
||
/>
|
||
<MetaCell
|
||
label="Counter"
|
||
value={result.counter !== undefined ? `${result.counter} / 100` : "—"}
|
||
/>
|
||
<MetaCell label="Generated" value={now} />
|
||
{/* Enterprise No. only: collection status lives inline as a 6th cell. */}
|
||
{kind === "enterprise_no" && enterpriseNo && enterpriseName && (
|
||
<div className="col-span-2 sm:col-span-1">
|
||
<dt className="text-[10px] uppercase tracking-wider font-semibold text-muted">
|
||
Collection
|
||
</dt>
|
||
<dd className="mt-0.5 flex items-center gap-2 flex-wrap">
|
||
<InCollectionPill match={collectionMatches[enterpriseNo]} />
|
||
<CollectionRowAction
|
||
entNo={enterpriseNo}
|
||
match={collectionMatches[enterpriseNo]}
|
||
onAdd={() =>
|
||
setAdding({
|
||
enterprise_no: enterpriseNo,
|
||
enterprise_name: enterpriseName,
|
||
enterprise_status: result.cipc_fields?.["Status"] ?? "",
|
||
enterprise_type: result.cipc_fields?.["Enterprise Type"] ?? "",
|
||
registration_date: (() => {
|
||
const raw = result.cipc_fields?.["Registration Date"] ?? "";
|
||
const m = raw.match(/^(\d{4})[\/\-](\d{2})[\/\-](\d{2})$/);
|
||
return m ? `${m[1]}-${m[2]}-${m[3]}` : "";
|
||
})(),
|
||
physical_address: result.cipc_fields?.["Physical Address"] ?? "",
|
||
postal_address: result.cipc_fields?.["Postal Address"] ?? "",
|
||
source: `cipc_enterprise_search:${result.query ?? ""}`,
|
||
})
|
||
}
|
||
/>
|
||
{/* Batch-save controls for the company itself. The Client
|
||
flag defaults to OFF — SJ marks clients deliberately. */}
|
||
{!collectionMatches[enterpriseNo]?.in_collection && (
|
||
<>
|
||
<label className="inline-flex items-center gap-1.5 cursor-pointer select-none px-2 py-1 rounded border border-border bg-surface">
|
||
<input
|
||
type="checkbox"
|
||
className="w-4 h-4 accent-amber-600 cursor-pointer"
|
||
checked={selected.has(`company:${enterpriseNo}`)}
|
||
onChange={() => toggleSelected(`company:${enterpriseNo}`)}
|
||
/>
|
||
<span className="text-[10px] uppercase tracking-wider font-semibold text-muted">
|
||
Save company
|
||
</span>
|
||
</label>
|
||
{selected.has(`company:${enterpriseNo}`) && (
|
||
<label className="inline-flex items-center gap-1.5 cursor-pointer select-none px-2 py-1 rounded border border-border bg-surface">
|
||
<input
|
||
type="checkbox"
|
||
className="w-4 h-4 accent-accent cursor-pointer"
|
||
checked={clientTicks.has(`company:${enterpriseNo}`)}
|
||
onChange={() => toggleClient(`company:${enterpriseNo}`)}
|
||
/>
|
||
<span className="text-[10px] uppercase tracking-wider font-semibold text-muted">
|
||
Client
|
||
</span>
|
||
</label>
|
||
)}
|
||
</>
|
||
)}
|
||
</dd>
|
||
</div>
|
||
)}
|
||
</dl>
|
||
|
||
{/* ── Selection bar ───────────────────────────────────────────── */}
|
||
<div className="no-print px-7 py-3 border-b border-border bg-surface">
|
||
<SelectionBar
|
||
counts={selCounts}
|
||
kind={kind}
|
||
dbLoaded={directorDb.loaded}
|
||
selectedCount={selected.size}
|
||
clientCount={clientTicks.size}
|
||
cleared={ackCleared}
|
||
saving={saving}
|
||
saveMsg={saveMsg}
|
||
onSave={saveSelection}
|
||
onSelectAllMissing={() =>
|
||
setSelected(
|
||
kind === "enterprise_no"
|
||
? selectAllMissingDirectors(directorDetailRows, directorDb.byKey)
|
||
: selectAllMissingCompanies(directorRows, collectionMatches)
|
||
)
|
||
}
|
||
onClear={() => {
|
||
setSelected(new Set());
|
||
setClientTicks(new Set());
|
||
setAckCleared(true);
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* ── Sections ─────────────────────────────────────────────── */}
|
||
<div className="px-7 py-5 space-y-5 bg-surface">
|
||
{parsed.sections.length === 0 && (
|
||
<p className="text-sm text-muted italic">No structured data returned.</p>
|
||
)}
|
||
{/* Say so explicitly when CIPC returned no directors at all. The
|
||
.py only emits "### DIRECTOR DETAILS" when its parser finds
|
||
rows, and that parse depends on BizPortal having rendered the
|
||
directors pane within a fixed 35s window. When it doesn't, the
|
||
section is simply absent — silence here reads like a broken
|
||
page rather than "the registry didn't send this". */}
|
||
{kind === "enterprise_no" && directorDetailRows.length === 0 && (
|
||
<section className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3">
|
||
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-amber-800 mb-1">
|
||
Directors
|
||
</h4>
|
||
<p className="text-xs text-amber-900">
|
||
CIPC returned <strong>no directors</strong> for this company. The
|
||
registry sometimes renders its directors panel after a delay —
|
||
if you know this company has directors, re-run the search; the
|
||
result is usually complete on the second attempt.
|
||
</p>
|
||
</section>
|
||
)}
|
||
{parsed.sections.map((section, idx) => {
|
||
// Director ID: the "Results" table becomes a 5-column table with
|
||
// an in-collection pill + action inline.
|
||
if (
|
||
kind === "director_id_no" &&
|
||
section === directorTableSection &&
|
||
section.body.kind === "table"
|
||
) {
|
||
return (
|
||
<DirectorResultsTableWithCollection
|
||
key={idx}
|
||
section={section}
|
||
matches={collectionMatches}
|
||
selected={selected}
|
||
clientTicks={clientTicks}
|
||
onToggle={toggleSelected}
|
||
onToggleClient={toggleClient}
|
||
onAdd={(row) =>
|
||
setAdding({
|
||
enterprise_no: row[0],
|
||
enterprise_name: row[1] ?? "",
|
||
enterprise_status: row[2] ?? "",
|
||
director_status: row[3] ?? "",
|
||
source: `cipc_director_search:${result.query ?? ""}`,
|
||
})
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
// Enterprise No.: KV blocks become diff tables (COMPANY DETAILS,
|
||
// OTHER DETAILS, etc.). No match → fall through to default.
|
||
if (
|
||
kind === "enterprise_no" &&
|
||
section.body.kind === "kv" &&
|
||
hasAnyMappingForKv(section.title)
|
||
) {
|
||
return (
|
||
<KvSectionWithDiff
|
||
key={idx}
|
||
section={section}
|
||
pbId={livePbRecord?.id ?? null}
|
||
pbRecord={livePbRecord}
|
||
cipcKv={result.cipc_fields ?? {}}
|
||
cipcDirectors={result.cipc_directors ?? []}
|
||
onApplied={(rec) => setPbRecordOverride(rec)}
|
||
/>
|
||
);
|
||
}
|
||
// Enterprise No.: table-shaped sections that have a diff rule
|
||
// (the merged ANNUAL RETURNS) render the CIPC table + a single
|
||
// Apply button overwriting every PB field the rule targets.
|
||
// Falls through to default otherwise.
|
||
const tableRule =
|
||
kind === "enterprise_no" &&
|
||
section.body.kind === "table" &&
|
||
getTableDiffRule(section.title);
|
||
if (tableRule) {
|
||
return (
|
||
<TableSectionWithDiff
|
||
key={idx}
|
||
section={section}
|
||
pbId={livePbRecord?.id ?? null}
|
||
pbRecord={livePbRecord}
|
||
rule={tableRule}
|
||
onApplied={(rec) => setPbRecordOverride(rec)}
|
||
/>
|
||
);
|
||
}
|
||
// DIRECTOR DETAILS — company-card style table with per-row
|
||
// selection and PocketBase state, instead of a flat list.
|
||
if (
|
||
kind === "enterprise_no" &&
|
||
section.body.kind === "table" &&
|
||
/^director details$/i.test(section.title)
|
||
) {
|
||
return (
|
||
<DirectorsTable
|
||
key={idx}
|
||
rows={directorDetailRows}
|
||
db={directorDb}
|
||
selected={selected}
|
||
clientTicks={clientTicks}
|
||
onToggle={toggleSelected}
|
||
onToggleClient={toggleClient}
|
||
/>
|
||
);
|
||
}
|
||
return <SectionBlock key={idx} section={section} />;
|
||
})}
|
||
</div>
|
||
|
||
{/* ── Footer ───────────────────────────────────────────────── */}
|
||
<footer className="px-7 py-3 border-t border-border bg-surface-warm flex items-center justify-between text-[11px] text-muted">
|
||
<span>Ysterbaard · Workflow · CIPC BizProfile</span>
|
||
<span className="font-mono">{now}</span>
|
||
</footer>
|
||
|
||
{/* ── Add modal (fixed-positioned overlay) ─────────────────── */}
|
||
{adding && (
|
||
<AddCompanyModal
|
||
prefill={adding}
|
||
onClose={() => setAdding(null)}
|
||
onSaved={(saved) => {
|
||
// Optimistic flip: the row now shows "in collection" without
|
||
// needing a re-search of the upstream CIPC API (which would burn
|
||
// another search counter credit).
|
||
setAddedOverrides((o) => ({
|
||
...o,
|
||
[saved.enterprise_no.toUpperCase()]: {
|
||
in_collection: true,
|
||
pb_id: saved.id,
|
||
pb_name: saved.enterprise_name,
|
||
pb_status: "",
|
||
},
|
||
}));
|
||
setAdding(null);
|
||
}}
|
||
/>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
// ===================================================================
|
||
// COLLECTION STATUS (per kind)
|
||
// ===================================================================
|
||
|
||
function summariseMatches(matches: CollectionMatch[]) {
|
||
let inCollection = 0;
|
||
let missing = 0;
|
||
for (const m of matches) {
|
||
if (m?.in_collection) inCollection++;
|
||
else missing++;
|
||
}
|
||
return { inCollection, missing };
|
||
}
|
||
|
||
/**
|
||
* Replaces the standard DataTable for the Director ID "Results" section.
|
||
* Renders the original 4 BizPortal columns PLUS a 5th "In your collection"
|
||
* column whose cells contain the in-collection pill and the per-row action
|
||
* (Open in PB ↗ for existing rows, + Add for missing ones, "—" when PB
|
||
* didn't return data). Keeps the summary line "X of Y already · Z new"
|
||
* immediately above the table so the user sees totals without scrolling.
|
||
*/
|
||
function DirectorResultsTableWithCollection({
|
||
section,
|
||
matches,
|
||
onAdd,
|
||
selected,
|
||
clientTicks,
|
||
onToggle,
|
||
onToggleClient,
|
||
}: {
|
||
section: Section;
|
||
matches: Record<string, CollectionMatch>;
|
||
onAdd: (row: string[]) => void;
|
||
selected: Set<string>;
|
||
clientTicks: Set<string>;
|
||
onToggle: (key: string) => void;
|
||
onToggleClient: (key: string) => void;
|
||
}) {
|
||
if (section.body.kind !== "table") return null;
|
||
const cols = section.body.columns;
|
||
const rows = section.body.rows;
|
||
|
||
const items = rows.map((r) => {
|
||
const entNo = (r[0] ?? "").trim().toUpperCase();
|
||
return { entNo, row: r, match: matches[entNo] };
|
||
});
|
||
const summary = summariseMatches(
|
||
items.map((it) => it.match ?? { in_collection: false })
|
||
);
|
||
|
||
return (
|
||
<section data-cipc-collection>
|
||
<div className="flex items-baseline justify-between gap-3 flex-wrap mb-2">
|
||
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted">
|
||
Results
|
||
</h4>
|
||
<p className="text-[11px] text-muted">
|
||
<span className="font-semibold text-emerald-700">
|
||
{summary.inCollection}
|
||
</span>{" "}
|
||
of {items.length} already in your collection ·{" "}
|
||
<span className="font-semibold text-amber-700">{summary.missing}</span>{" "}
|
||
new
|
||
</p>
|
||
</div>
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-surface-warm border-b border-border">
|
||
{/* Selection checkbox column. */}
|
||
<th className="px-3 py-2 w-10" />
|
||
{/* Column order: BizPortal's 4 columns + "In your collection"
|
||
interleaved between Enterprise Name and Enterprise Status. */}
|
||
<th className="text-left px-4 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
{cols[0]}
|
||
</th>
|
||
<th className="text-left px-4 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
{cols[1]}
|
||
</th>
|
||
<th className="text-left px-4 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
In your collection
|
||
</th>
|
||
<th className="text-left px-4 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
{cols[2]}
|
||
</th>
|
||
<th className="text-left px-4 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
{cols[3]}
|
||
</th>
|
||
<th className="text-left px-4 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
Client
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{items.map((it, i) => (
|
||
<tr
|
||
key={it.entNo}
|
||
className={
|
||
"border-t border-border align-top " +
|
||
(i % 2 === 0 ? "bg-surface" : "bg-surface-warm")
|
||
}
|
||
>
|
||
{/* Selection checkbox — hidden once the company is saved,
|
||
because there is nothing left to add. */}
|
||
<td className="px-3 py-2 align-top">
|
||
{!it.match?.in_collection && (
|
||
<input
|
||
type="checkbox"
|
||
className="w-4 h-4 accent-amber-600 cursor-pointer"
|
||
checked={selected.has(`company:${it.entNo}`)}
|
||
onChange={() => onToggle(`company:${it.entNo}`)}
|
||
aria-label={`Select ${it.row[1] || it.entNo}`}
|
||
/>
|
||
)}
|
||
</td>
|
||
{/* K-number (col 0) — never wrap, mono. */}
|
||
<td className="px-4 py-2 text-fg align-top font-mono text-[13px] whitespace-nowrap">
|
||
{it.row[0]}
|
||
</td>
|
||
{/* Enterprise Name (col 1) — wraps naturally. */}
|
||
<td className="px-4 py-2 text-fg align-top">
|
||
{it.row[1]}
|
||
</td>
|
||
{/* In your collection — interleaved. */}
|
||
<td className="px-4 py-2 text-fg align-top">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<InCollectionPill match={it.match} />
|
||
<CollectionRowAction
|
||
entNo={it.entNo}
|
||
match={it.match}
|
||
onAdd={() => onAdd(it.row)}
|
||
/>
|
||
</div>
|
||
</td>
|
||
{/* Enterprise Status (col 2). */}
|
||
<td className="px-4 py-2 text-fg align-top">
|
||
{it.row[2]}
|
||
</td>
|
||
{/* Director Status (col 3). */}
|
||
<td className="px-4 py-2 text-fg align-top">
|
||
{it.row[3]}
|
||
</td>
|
||
<td className="px-4 py-2 align-top">
|
||
{selected.has(`company:${it.entNo}`) ? (
|
||
<label className="inline-flex items-center gap-1.5 cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
className="w-4 h-4 accent-accent cursor-pointer"
|
||
checked={clientTicks.has(`company:${it.entNo}`)}
|
||
onChange={() => onToggleClient(`company:${it.entNo}`)}
|
||
aria-label={`Mark ${it.row[1] || it.entNo} as a client`}
|
||
/>
|
||
<span className="text-[10px] uppercase tracking-wider font-semibold text-muted">
|
||
Client
|
||
</span>
|
||
</label>
|
||
) : (
|
||
<span className="text-[10px] text-muted/60">—</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ===================================================================
|
||
// COMPANY DETAILS WITH DIFF (Enterprise No., integrated into main report)
|
||
// ===================================================================
|
||
|
||
/**
|
||
* Renders any KV section (e.g. COMPANY DETAILS, OTHER DETAILS) as a
|
||
* side-by-side table where each row interleaves the CIPC value (left, from
|
||
* the parsed section), the current PB value (middle), and a status pill
|
||
* (right). When PB is empty or values differ, a per-row checkbox appears
|
||
* so the user can pick which to apply via the bottom Apply button. Falls
|
||
* back to the standard KvTable rendering when no PB record is available.
|
||
*
|
||
* Designers note: this used to be a separate "Field differences" block
|
||
* below the parsed sections — SJ asked for it to live IN the main result
|
||
* table so the match column is part of the main read.
|
||
*/
|
||
function KvSectionWithDiff({
|
||
section,
|
||
pbId,
|
||
pbRecord,
|
||
cipcKv,
|
||
cipcDirectors,
|
||
onApplied,
|
||
}: {
|
||
section: Section;
|
||
pbId: string | null;
|
||
pbRecord: Record<string, any> | null;
|
||
cipcKv: Record<string, string>;
|
||
cipcDirectors: Array<{ name: string; id: string }>;
|
||
onApplied: (rec: Record<string, any>) => void;
|
||
}) {
|
||
if (section.body.kind !== "kv") return null;
|
||
const kvRows = section.body.rows;
|
||
const hasPb = !!pbRecord && !!pbId;
|
||
|
||
// Build the lookup tables once per render. We iterate the CIPC KV rows
|
||
// directly and resolve each to a mapping entry (or "no PB field") so
|
||
// every row in the parsed section is shown with a status — including
|
||
// fields like "Enterprise Number" (updatable=false), "Compliance Notice"
|
||
// (no PB field), and "SARS Tax Number" (added by CIPC, missed before).
|
||
const mappingByKey = new Map(FULL_MAPPING.map((m) => [m.cipcKey, m]));
|
||
type RowState =
|
||
| { state: "no_pb_field"; cipcValue: string }
|
||
| { state: "readonly"; cipcValue: string; pbValue: string; differs: boolean }
|
||
| { state: "diff"; cipcValue: string; pbValue: string; field: string; differs: true }
|
||
| { state: "match"; cipcValue: string; pbValue: string; field: string; differs: false };
|
||
|
||
const rowStates: RowState[] = kvRows.map(([k, v]) => {
|
||
const cipcValue = (v ?? "").trim();
|
||
const m = mappingByKey.get(k);
|
||
if (!m) {
|
||
return { state: "no_pb_field", cipcValue };
|
||
}
|
||
const pbValue = (pbRecord?.[m.field] ?? "").toString().trim();
|
||
const cipcNorm = m.transform ? m.transform(cipcValue) : cipcValue;
|
||
const differs =
|
||
m.updatable &&
|
||
cipcNorm.toLowerCase() !== (pbValue || "").toLowerCase();
|
||
if (!m.updatable) {
|
||
return { state: "readonly", cipcValue, pbValue, differs };
|
||
}
|
||
// Split into two returns: the RowState union correlates `state` with
|
||
// `differs`, so a single `state: differs ? "diff" : "match"` infers a
|
||
// "diff" | "match" union that matches neither variant.
|
||
if (differs) {
|
||
return { state: "diff", cipcValue, pbValue, field: m.field, differs: true };
|
||
}
|
||
return { state: "match", cipcValue, pbValue, field: m.field, differs: false };
|
||
});
|
||
|
||
const selectableRowStates = rowStates.filter((r) => r.state === "diff");
|
||
const hasAnyDiff = selectableRowStates.length > 0;
|
||
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
const [applying, setApplying] = useState(false);
|
||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||
|
||
function toggle(field: string) {
|
||
setSelected((s) => {
|
||
const n = new Set(s);
|
||
if (n.has(field)) n.delete(field);
|
||
else n.add(field);
|
||
return n;
|
||
});
|
||
}
|
||
|
||
function toggleAll() {
|
||
setSelected((s) =>
|
||
s.size === selectableRowStates.length
|
||
? new Set()
|
||
: new Set(
|
||
selectableRowStates
|
||
.filter((r): r is Extract<RowState, { state: "diff" }> => r.state === "diff")
|
||
.map((r) => r.field)
|
||
)
|
||
);
|
||
}
|
||
|
||
async function apply() {
|
||
if (!pbId || selected.size === 0) return;
|
||
setErrorMsg(null);
|
||
setApplying(true);
|
||
try {
|
||
const fields: Record<string, string> = {};
|
||
for (const r of selectableRowStates) {
|
||
if (r.state !== "diff") continue;
|
||
if (!selected.has(r.field)) continue;
|
||
if (r.field === "registration_date") {
|
||
const m = r.cipcValue.match(/^(\d{4})[\/\-](\d{2})[\/\-](\d{2})$/);
|
||
fields[r.field] = m ? `${m[1]}-${m[2]}-${m[3]}` : r.cipcValue;
|
||
} else {
|
||
fields[r.field] = r.cipcValue;
|
||
}
|
||
}
|
||
const r = await fetch("/api/cipc/update-company", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: pbId, fields }),
|
||
});
|
||
const j = await r.json().catch(() => ({}));
|
||
if (!r.ok || !j.ok) {
|
||
setErrorMsg(j.error || `Update failed (HTTP ${r.status})`);
|
||
setApplying(false);
|
||
return;
|
||
}
|
||
onApplied(j.record);
|
||
setSelected(new Set());
|
||
} catch (e: any) {
|
||
setErrorMsg(e?.message || "Network error");
|
||
} finally {
|
||
setApplying(false);
|
||
}
|
||
}
|
||
|
||
// ── Render ─────────────────────────────────────────────────────────
|
||
// Without a PB record, fall back to the default KvTable — keeps the
|
||
// familiar "Company Details" section shape for first-time additions.
|
||
if (!hasPb) {
|
||
return <KvTable rows={kvRows} />;
|
||
}
|
||
|
||
return (
|
||
<section className="space-y-2.5">
|
||
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
||
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted">
|
||
{section.title}
|
||
<span className="ml-2 normal-case tracking-normal text-muted font-normal text-[10px]">
|
||
vs PocketBase{" "}
|
||
<span className="font-mono">
|
||
#{pbRecord?.id ? pbRecord.id.slice(0, 6) : ""}…
|
||
</span>
|
||
</span>
|
||
</h4>
|
||
<p className="text-[11px] text-muted">
|
||
{!hasAnyDiff ? (
|
||
<>
|
||
<span className="font-semibold text-emerald-700">All fields match</span>{" "}
|
||
your PocketBase record.
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="font-semibold text-amber-700">
|
||
{selectableRowStates.length}
|
||
</span>{" "}
|
||
of {kvRows.length} fields differ — tick to update.
|
||
</>
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-surface-warm border-b border-border">
|
||
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap w-8">
|
||
<input
|
||
type="checkbox"
|
||
aria-label="Select all differing fields"
|
||
disabled={!hasAnyDiff}
|
||
checked={hasAnyDiff && selected.size === selectableRowStates.length}
|
||
onChange={toggleAll}
|
||
/>
|
||
</th>
|
||
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
Field
|
||
</th>
|
||
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
CIPC value
|
||
</th>
|
||
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
PocketBase (current)
|
||
</th>
|
||
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
Status
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{kvRows.map(([k, v], i) => {
|
||
const rs = rowStates[i];
|
||
const iClass =
|
||
"border-t border-border align-top " +
|
||
(i % 2 === 0 ? "bg-surface" : "bg-surface-warm");
|
||
return (
|
||
<tr key={k} className={iClass}>
|
||
<td className="px-3 py-2 align-top">
|
||
{rs.state === "diff" ? (
|
||
<input
|
||
type="checkbox"
|
||
aria-label={`Update ${k}`}
|
||
checked={selected.has(rs.field)}
|
||
onChange={() => toggle(rs.field)}
|
||
/>
|
||
) : (
|
||
<span className="text-muted text-[11px]">—</span>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 align-top font-semibold text-fg">
|
||
{k}
|
||
</td>
|
||
<td className="px-3 py-2 align-top text-fg break-words">
|
||
{v ? v : <span className="text-muted">—</span>}
|
||
</td>
|
||
<td className="px-3 py-2 align-top text-fg break-words">
|
||
{rs.state === "no_pb_field" ? (
|
||
<span
|
||
className="text-muted italic text-[12px]"
|
||
title="No matching PocketBase field for this CIPC value"
|
||
>
|
||
— no PB equivalent
|
||
</span>
|
||
) : rs.pbValue ? (
|
||
rs.pbValue
|
||
) : (
|
||
<span className="text-muted">—</span>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 align-top">
|
||
{rs.state === "no_pb_field" ? (
|
||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border border-border bg-surface text-muted text-[10px] font-semibold uppercase tracking-wider">
|
||
Read-only
|
||
</span>
|
||
) : rs.state === "readonly" ? (
|
||
<span
|
||
className={
|
||
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border text-[10px] font-semibold uppercase tracking-wider " +
|
||
(rs.differs
|
||
? "border-amber-200 bg-amber-50 text-amber-800"
|
||
: "border-emerald-200 bg-emerald-50 text-emerald-700")
|
||
}
|
||
>
|
||
{rs.differs ? "Differs · locked" : <><CheckIcon /> Match</>}
|
||
</span>
|
||
) : rs.state === "diff" ? (
|
||
<DiffStatusPill status="differs" />
|
||
) : (
|
||
<DiffStatusPill status="match" />
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{cipcDirectors.length > 0 && (
|
||
<DirectorsList directors={cipcDirectors} />
|
||
)}
|
||
|
||
{hasAnyDiff && (
|
||
<div className="flex items-center justify-end gap-3 pt-1">
|
||
{errorMsg && (
|
||
<span className="text-[12px] text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1">
|
||
{errorMsg}
|
||
</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={apply}
|
||
disabled={selected.size === 0 || applying}
|
||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-accent text-accent-on text-xs font-bold hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{applying && <Spinner small />}
|
||
{applying
|
||
? "Applying…"
|
||
: `Apply ${selected.size} update${selected.size === 1 ? "" : "s"}`}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
|
||
|
||
interface FieldDiffRow {
|
||
/** PB field name (also the key in pb_record). */
|
||
field: string;
|
||
/** Human label for the column. */
|
||
label: string;
|
||
/** Value CIPC returned for this field (already stringified). */
|
||
cipcValue: string;
|
||
/** Value currently in PB for this field (already stringified). */
|
||
pbValue: string;
|
||
/** "match" — values are equal (case-insensitive after norm).
|
||
* "differs" — values differ and at least one side is non-empty.
|
||
* "empty_pb" — CIPC has a value, PB is empty. Flag and default-check. */
|
||
status: "match" | "differs" | "empty_pb";
|
||
}
|
||
|
||
/**
|
||
* Full mapping from CIPC field labels (as printed in COMPANY DETAILS and
|
||
* OTHER DETAILS) to PB field names. EVERY CIPC field is mapped — fields
|
||
* with no PB equivalent get their mapping omitted and render as
|
||
* "no PB equivalent" rows (visible but no checkbox).
|
||
*
|
||
* `updatable: false` means the field still shows but has no checkbox
|
||
* (e.g. enterprise_no is the PB unique key and must NOT be PATCHed via
|
||
* this flow — changing it would break the unique-index). Treat such rows
|
||
* as read-only context.
|
||
*/
|
||
interface CIPCFieldMapping {
|
||
field: string;
|
||
label: string;
|
||
cipcKey: string;
|
||
updatable: boolean;
|
||
transform?: (v: string) => string;
|
||
}
|
||
|
||
const FULL_MAPPING: CIPCFieldMapping[] = [
|
||
// COMPANY DETAILS block
|
||
{ field: "enterprise_no", label: "Enterprise Number", cipcKey: "Enterprise Number", updatable: false }, // unique key — never PATCH
|
||
{ field: "enterprise_name", label: "Enterprise Name", cipcKey: "Enterprise Name", updatable: true },
|
||
{ field: "enterprise_type", label: "Enterprise Type", cipcKey: "Enterprise Type", updatable: true },
|
||
{ field: "enterprise_status", label: "Status", cipcKey: "Enterprise Status", updatable: true },
|
||
{
|
||
field: "registration_date",
|
||
label: "Registration Date",
|
||
cipcKey: "Registration Date",
|
||
updatable: true,
|
||
// CIPC prints "YYYY/MM/DD"; PB stores "YYYY-MM-DD". Normalise CIPC
|
||
// before comparison so identical dates don't show as spurious diffs.
|
||
transform: (v) => {
|
||
const m = v.match(/^(\d{4})[\/\-](\d{2})[\/\-](\d{2})$/);
|
||
return m ? `${m[1]}-${m[2]}-${m[3]}` : v;
|
||
},
|
||
},
|
||
{ field: "physical_address", label: "Physical Address", cipcKey: "CIPC Physical address", updatable: true },
|
||
{ field: "postal_address", label: "Postal Address", cipcKey: "CIPC Postal address", updatable: true },
|
||
// Compliance Notice has no PB field — omitted on purpose; rendered
|
||
// as "no PB equivalent" by the renderer.
|
||
|
||
// OTHER DETAILS block
|
||
{ field: "sars_tax_number", label: "SARS Tax Number", cipcKey: "SARS Tax Number", updatable: true },
|
||
{ field: "uif_registration_number", label: "UIF Registration Number", cipcKey: "UIF Registration Number", updatable: true },
|
||
{ field: "compensation_fund_registration_number", label: "Compensation Fund Reg #", cipcKey: "Compensation Fund Registration Number", updatable: true },
|
||
];
|
||
|
||
/**
|
||
* One PocketBase field a table rule can write, plus how the section's
|
||
* rows are serialised for comparison against that field's current value.
|
||
*/
|
||
interface TableDiffTarget {
|
||
/** PB field name to overwrite when user clicks Apply. */
|
||
pbField: string;
|
||
/** Serialize the parsed table rows into a single string for comparison. */
|
||
toCipcValue: (rows: string[][], columns: string[]) => string;
|
||
}
|
||
|
||
/**
|
||
* Per-section rules for table-shaped CIPC blocks. Each rule maps a section
|
||
* title (case-insensitive) to one or more PB fields. The CIPC table rows
|
||
* are serialised per target (e.g. a CSV-ish string) so the diff against the
|
||
* PB field's existing text is meaningful.
|
||
*
|
||
* A rule with several targets writes all of them in a single PATCH — that
|
||
* is how the merged ANNUAL RETURNS section updates `ar_outstanding` and
|
||
* `enterprise_history` together instead of needing two Apply buttons.
|
||
*
|
||
* Sections without a rule here render as plain DataTable / DirectorsList.
|
||
*/
|
||
interface TableDiffRule {
|
||
targets: TableDiffTarget[];
|
||
/** Short human label for the apply button. */
|
||
applyLabel: string;
|
||
/** Optional subtitle shown next to the apply button. */
|
||
note?: string;
|
||
}
|
||
|
||
const TABLE_DIFF_RULES: Array<{ match: RegExp; rule: TableDiffRule }> = [
|
||
{
|
||
// The merged Filed + Outstanding list. Outstanding rows become
|
||
// `ar_outstanding` ("2023/AUGUST" joined by " | "); filed rows become
|
||
// `enterprise_history` (compact JSON array, preserving year / amount /
|
||
// date_filed for future audit). One Apply button, one PATCH, both
|
||
// fields, because the merged table is the single source for each.
|
||
match: /^annual returns$/i,
|
||
rule: {
|
||
targets: [
|
||
{
|
||
pbField: "ar_outstanding",
|
||
toCipcValue: (rows) =>
|
||
rows
|
||
.filter(isArOutstandingRow)
|
||
.map((r) =>
|
||
[r[AR_COL_YEAR] ?? "", r[AR_COL_PERIOD] ?? ""].filter(Boolean).join("/")
|
||
)
|
||
.filter(Boolean)
|
||
.join(" | "),
|
||
},
|
||
{
|
||
pbField: "enterprise_history",
|
||
toCipcValue: (rows) =>
|
||
JSON.stringify(
|
||
rows
|
||
.filter(isArFiledRow)
|
||
.map((r) => ({
|
||
year: r[AR_COL_YEAR] ?? "",
|
||
amount: r[AR_COL_AMOUNT] ?? "",
|
||
date_filed: stripArDatePrefix(r[AR_COL_DATE] ?? ""),
|
||
})),
|
||
null,
|
||
0
|
||
),
|
||
},
|
||
],
|
||
applyLabel: "Overwrite PocketBase `ar_outstanding` + `enterprise_history`",
|
||
note:
|
||
"Outstanding rows to `ar_outstanding` as `YYYY/MONTH` joined by ` | `. Filed rows to `enterprise_history` as a compact JSON array.",
|
||
},
|
||
},
|
||
];
|
||
// NOTE: DIRECTOR DETAILS is intentionally NOT in TABLE_DIFF_RULES. PB only
|
||
// has a single `director` relation (maxSelect: 1) and no multi-director
|
||
// field, so there's no clean place to PATCH a full director list. Until
|
||
// a `directors` (many) relation is added to PB the section falls through
|
||
// to the default DataTable + DirectorsList (informational context only).
|
||
|
||
/** Look up the diff rule for a section title (case-insensitive).
|
||
* Returns null when no rule applies — caller should render the default. */
|
||
function getTableDiffRule(title: string): TableDiffRule | null {
|
||
const key = title.trim().toLowerCase();
|
||
for (const { match, rule } of TABLE_DIFF_RULES) {
|
||
if (match.test(key)) return rule;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** True iff any FULL_MAPPING.cipcKey matches one of the kv labels — used
|
||
* by the renderer to decide whether a kv section is "diffable". */
|
||
function hasAnyMappingForKv(title: string): boolean {
|
||
// Title is e.g. "COMPANY DETAILS", "OTHER DETAILS". Always diffable — the
|
||
// mapping is on the row label, not on the section title. We just need to
|
||
// return true so all kv sections render through the diff path.
|
||
void title;
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Renders a multi-row CIPC table (DIRECTOR DETAILS, FILED ANNUAL RETURNS,
|
||
* OUTSTANDING ANNUAL RETURNS, etc.) with a SINGLE Apply action that
|
||
* overwrites or appends the designated PB field with a serialised version
|
||
* of the CIPC rows. Per-row diff would be impractical — the PB schema
|
||
* doesn't store per-director or per-AR records.
|
||
*
|
||
* Falls back to the standard DataTable when no PB record is available.
|
||
*/
|
||
function TableSectionWithDiff({
|
||
section,
|
||
pbId,
|
||
pbRecord,
|
||
rule,
|
||
onApplied,
|
||
}: {
|
||
section: Section;
|
||
pbId: string | null;
|
||
pbRecord: Record<string, any> | null;
|
||
rule: TableDiffRule;
|
||
onApplied: (rec: Record<string, any>) => void;
|
||
}) {
|
||
const [applying, setApplying] = useState(false);
|
||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||
const [includeInUpdate, setIncludeInUpdate] = useState(false);
|
||
|
||
if (section.body.kind !== "table") return null;
|
||
const columns = section.body.columns;
|
||
const rows = section.body.rows;
|
||
const hasPb = !!pbRecord && !!pbId;
|
||
const targets = rule.targets.map((t) => {
|
||
const cipcValue = t.toCipcValue(rows, columns);
|
||
const pbValue = hasPb ? (pbRecord?.[t.pbField] ?? "").toString().trim() : "";
|
||
return {
|
||
pbField: t.pbField,
|
||
cipcValue,
|
||
pbValue,
|
||
differs: hasPb && cipcValue !== pbValue,
|
||
};
|
||
});
|
||
const differs = hasPb && targets.some((t) => t.differs);
|
||
|
||
async function apply() {
|
||
if (!pbId) return;
|
||
setErrorMsg(null);
|
||
setApplying(true);
|
||
try {
|
||
// One PATCH carries every target field, so a merged section with
|
||
// two PB targets updates both atomically.
|
||
const fields: Record<string, string> = {};
|
||
for (const t of targets) fields[t.pbField] = t.cipcValue;
|
||
const r = await fetch("/api/cipc/update-company", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: pbId, fields }),
|
||
});
|
||
const j = await r.json().catch(() => ({}));
|
||
if (!r.ok || !j.ok) {
|
||
setErrorMsg(j.error || `Update failed (HTTP ${r.status})`);
|
||
setApplying(false);
|
||
return;
|
||
}
|
||
onApplied(j.record);
|
||
setIncludeInUpdate(false);
|
||
} catch (e: any) {
|
||
setErrorMsg(e?.message || "Network error");
|
||
} finally {
|
||
setApplying(false);
|
||
}
|
||
}
|
||
|
||
// Without a PB record we still want to show the CIPC table so the user
|
||
// has it in context, but with no diff control.
|
||
if (!hasPb) {
|
||
return <SectionBlock section={section} />;
|
||
}
|
||
|
||
return (
|
||
<section className="space-y-2.5">
|
||
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
||
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted">
|
||
{section.title}
|
||
<span className="ml-2 normal-case tracking-normal text-muted font-normal text-[10px]">
|
||
vs PocketBase{" "}
|
||
<span className="font-mono">
|
||
{targets.map((t) => t.pbField).join(" + ")}
|
||
</span>
|
||
<span className="font-mono">
|
||
{" "}#{pbRecord?.id ? pbRecord.id.slice(0, 6) : ""}…
|
||
</span>
|
||
</span>
|
||
</h4>
|
||
<p className="text-[11px] text-muted">
|
||
{differs ? (
|
||
<>
|
||
<span className="font-semibold text-amber-700">Differs</span>{" "}
|
||
from current PB value
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="font-semibold text-emerald-700">Match</span>{" "}
|
||
with current PB value
|
||
</>
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
{/* CIPC table — original rendering, full fidelity */}
|
||
<DataTable columns={columns} rows={rows} />
|
||
|
||
{/* PB-side comparison prefill — one block per target field so a
|
||
merged section shows both values before you commit anything. */}
|
||
<div className="rounded-md border border-border bg-surface-warm">
|
||
{targets.map((t, i) => (
|
||
<div key={t.pbField} className={i > 0 ? "border-t border-border" : ""}>
|
||
<div className="px-4 py-2 border-b border-border text-[10px] uppercase tracking-wider font-bold text-muted">
|
||
PocketBase{" "}
|
||
<span className="font-mono normal-case tracking-normal">
|
||
{t.pbField}
|
||
</span>{" "}
|
||
(current)
|
||
<span
|
||
className={
|
||
"ml-2 normal-case tracking-normal font-bold " +
|
||
(t.differs ? "text-amber-700" : "text-emerald-700")
|
||
}
|
||
>
|
||
{t.differs ? "· differs" : "· match"}
|
||
</span>
|
||
</div>
|
||
<pre className="px-4 py-2 text-[12px] text-fg font-mono whitespace-pre-wrap break-words max-h-40 overflow-auto">
|
||
{t.pbValue || <span className="text-muted italic">(empty)</span>}
|
||
</pre>
|
||
</div>
|
||
))}
|
||
<div className="px-4 py-2 border-t border-border flex items-center justify-between gap-3 flex-wrap">
|
||
<label className="flex items-center gap-2 text-[12px] text-fg">
|
||
<input
|
||
type="checkbox"
|
||
checked={includeInUpdate}
|
||
onChange={(e) => setIncludeInUpdate(e.target.checked)}
|
||
/>
|
||
<span>
|
||
<span className="font-semibold">Apply:</span>{" "}
|
||
{rule.applyLabel}
|
||
</span>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={apply}
|
||
disabled={!includeInUpdate || applying || !differs}
|
||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-accent text-accent-on text-[11px] font-bold hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
title={
|
||
!differs
|
||
? "Already in sync — nothing to apply"
|
||
: "Tick the checkbox above to confirm"
|
||
}
|
||
>
|
||
{applying && <Spinner small />}
|
||
{applying ? "Applying…" : "Apply"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{rule.note && (
|
||
<p className="text-[11px] text-muted italic">{rule.note}</p>
|
||
)}
|
||
{errorMsg && (
|
||
<p className="text-[12px] text-red-700 bg-red-50 border border-red-200 rounded px-2.5 py-2">
|
||
{errorMsg}
|
||
</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** Build the per-field diff rows from the CIPC KV map and a PB record.
|
||
* Emits one row per mapping entry — including fields where PB has a
|
||
* value and CIPC didn't return one (those show as "no CIPC value" and
|
||
* have nothing actionable from the user side). The renderer decides
|
||
* which rows show a checkbox. */
|
||
function computeDiffRows(
|
||
cipcKv: Record<string, string>,
|
||
pbRecord: Record<string, any> | null,
|
||
): FieldDiffRow[] {
|
||
if (!pbRecord) return [];
|
||
const rows: FieldDiffRow[] = [];
|
||
for (const m of FULL_MAPPING) {
|
||
const cipcRaw = (cipcKv[m.cipcKey] ?? "").trim();
|
||
const cipcNorm = m.transform ? m.transform(cipcRaw) : cipcRaw;
|
||
const pbRaw = (pbRecord?.[m.field] ?? "").toString().trim();
|
||
let status: FieldDiffRow["status"];
|
||
if (!cipcRaw && !pbRaw) status = "match"; // both empty — display only
|
||
else if (!pbRaw) status = "empty_pb";
|
||
else if (!cipcRaw) status = "differs"; // CIPC missing; row still surfaces
|
||
else if (cipcNorm.toLowerCase() === pbRaw.toLowerCase()) status = "match";
|
||
else status = "differs";
|
||
rows.push({
|
||
field: m.field,
|
||
label: m.label,
|
||
cipcValue: cipcRaw,
|
||
pbValue: pbRaw,
|
||
status,
|
||
});
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
/** Convert an iso-form date ("YYYY-MM-DD") or "" to the CIPC "YYYY/MM/DD"
|
||
* format used by the .py. The opposite of the transform above. Used when
|
||
* we need to compare against a page that may show one format or the other.
|
||
*/
|
||
function normaliseDateForCipc(v: string): string {
|
||
const m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
return m ? `${m[1]}/${m[2]}/${m[3]}` : v;
|
||
}
|
||
|
||
function FieldDiffTable({
|
||
pbId,
|
||
rows,
|
||
cipcDirectors,
|
||
onApplied,
|
||
}: {
|
||
pbId: string;
|
||
rows: FieldDiffRow[];
|
||
cipcDirectors: Array<{ name: string; id: string }>;
|
||
onApplied: (updatedRecord: Record<string, any>) => void;
|
||
}) {
|
||
const selectable = rows.filter((r) => r.status !== "match");
|
||
const allMatch = selectable.length === 0;
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
const [applying, setApplying] = useState(false);
|
||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||
|
||
function toggle(field: string) {
|
||
setSelected((s) => {
|
||
const n = new Set(s);
|
||
if (n.has(field)) n.delete(field);
|
||
else n.add(field);
|
||
return n;
|
||
});
|
||
}
|
||
|
||
function toggleAll() {
|
||
setSelected((s) =>
|
||
s.size === selectable.length ? new Set() : new Set(selectable.map((r) => r.field))
|
||
);
|
||
}
|
||
|
||
async function apply() {
|
||
if (!rows.length || selected.size === 0) return;
|
||
setErrorMsg(null);
|
||
setApplying(true);
|
||
try {
|
||
// Send the raw CIPC value for every field. For dates, convert
|
||
// "YYYY/MM/DD" to "YYYY-MM-DD" so PB stores ISO dates.
|
||
const fields: Record<string, string> = {};
|
||
for (const r of rows) {
|
||
if (!selected.has(r.field)) continue;
|
||
if (r.field === "registration_date") {
|
||
const m = r.cipcValue.match(/^(\d{4})[\/\-](\d{2})[\/\-](\d{2})$/);
|
||
fields[r.field] = m ? `${m[1]}-${m[2]}-${m[3]}` : r.cipcValue;
|
||
} else {
|
||
fields[r.field] = r.cipcValue;
|
||
}
|
||
}
|
||
const r = await fetch("/api/cipc/update-company", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: pbId, fields }),
|
||
});
|
||
const j = await r.json().catch(() => ({}));
|
||
if (!r.ok || !j.ok) {
|
||
setErrorMsg(j.error || `Update failed (HTTP ${r.status})`);
|
||
setApplying(false);
|
||
return;
|
||
}
|
||
// Optimistic UI: hand the new full record back to the parent so the
|
||
// report re-renders with the new PB values (rows that just updated
|
||
// now show ✓ match).
|
||
onApplied(j.record);
|
||
setSelected(new Set());
|
||
} catch (e: any) {
|
||
setErrorMsg(e?.message || "Network error");
|
||
} finally {
|
||
setApplying(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section data-cipc-diff className="space-y-2.5">
|
||
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
||
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted">
|
||
Field differences
|
||
</h4>
|
||
{allMatch ? (
|
||
<p className="text-[11px] text-muted">
|
||
<span className="font-semibold text-emerald-700">All fields match</span>{" "}
|
||
your PocketBase record.
|
||
</p>
|
||
) : (
|
||
<p className="text-[11px] text-muted">
|
||
<span className="font-semibold text-amber-700">{selectable.length}</span>{" "}
|
||
of {rows.length + (rows.length === selectable.length ? 0 : 0)} fields differ{" "}
|
||
— tick the ones you want to update.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-surface-warm border-b border-border">
|
||
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap w-8">
|
||
<input
|
||
type="checkbox"
|
||
aria-label="Select all differing fields"
|
||
disabled={allMatch}
|
||
checked={!allMatch && selected.size === selectable.length}
|
||
onChange={toggleAll}
|
||
/>
|
||
</th>
|
||
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
Field
|
||
</th>
|
||
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
CIPC value
|
||
</th>
|
||
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
PocketBase (current)
|
||
</th>
|
||
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
||
Status
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r, i) => {
|
||
return (
|
||
<tr
|
||
key={r.field}
|
||
className={
|
||
"border-t border-border align-top " +
|
||
(i % 2 === 0 ? "bg-surface" : "bg-surface-warm")
|
||
}
|
||
>
|
||
<td className="px-3 py-2 align-top">
|
||
{r.status === "match" ? (
|
||
<span className="text-muted text-[11px]">—</span>
|
||
) : (
|
||
<input
|
||
type="checkbox"
|
||
aria-label={`Update ${r.label}`}
|
||
checked={selected.has(r.field)}
|
||
onChange={() => toggle(r.field)}
|
||
/>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 align-top font-semibold text-fg">
|
||
{r.label}
|
||
</td>
|
||
<td className="px-3 py-2 align-top text-fg break-words">
|
||
{r.cipcValue || <span className="text-muted">—</span>}
|
||
</td>
|
||
<td className="px-3 py-2 align-top text-fg break-words">
|
||
{r.pbValue || <span className="text-muted">—</span>}
|
||
</td>
|
||
<td className="px-3 py-2 align-top">
|
||
<DiffStatusPill status={r.status} />
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{cipcDirectors.length > 0 && (
|
||
<DirectorsList directors={cipcDirectors} />
|
||
)}
|
||
|
||
{!allMatch && (
|
||
<div className="flex items-center justify-end gap-3 pt-1">
|
||
{errorMsg && (
|
||
<span className="text-[12px] text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1">
|
||
{errorMsg}
|
||
</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={apply}
|
||
disabled={selected.size === 0 || applying}
|
||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-accent text-accent-on text-xs font-bold hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{applying && <Spinner small />}
|
||
{applying
|
||
? "Applying…"
|
||
: `Apply ${selected.size} update${selected.size === 1 ? "" : "s"}`}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function DiffStatusPill({
|
||
status,
|
||
}: {
|
||
status: FieldDiffRow["status"];
|
||
}) {
|
||
if (status === "match") {
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border border-emerald-200 bg-emerald-50 text-emerald-700 text-[10px] font-semibold uppercase tracking-wider">
|
||
<CheckIcon /> Match
|
||
</span>
|
||
);
|
||
}
|
||
if (status === "empty_pb") {
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border border-blue-200 bg-blue-50 text-blue-700 text-[10px] font-semibold uppercase tracking-wider">
|
||
Empty in PB
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border border-amber-200 bg-amber-50 text-amber-800 text-[10px] font-semibold uppercase tracking-wider">
|
||
Differs
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ===================================================================
|
||
// DIRECTORS TABLE (BizPortal-style)
|
||
// ===================================================================
|
||
|
||
/** Compact director appendix used by the two diff panels (KV and table
|
||
* views). Deliberately left as the simple list it always was — the
|
||
* interactive table below is the main Directors section. */
|
||
function DirectorsList({
|
||
directors,
|
||
}: {
|
||
directors: Array<{ name: string; id: string }>;
|
||
}) {
|
||
return (
|
||
<section data-cipc-directors-appendix className="pt-2">
|
||
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted mb-1.5">
|
||
Directors per CIPC ({directors.length})
|
||
</h4>
|
||
<ul className="space-y-1 text-sm">
|
||
{directors.map((d, i) => (
|
||
<li
|
||
key={i}
|
||
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded border border-border bg-surface-warm"
|
||
>
|
||
<span className="font-mono text-[12px] text-fg">{d.name}</span>
|
||
<span className="font-mono text-[12px] text-muted">{d.id}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<p className="text-[10px] text-muted italic mt-1.5">
|
||
PocketBase currently stores only a single director relation per
|
||
company; per-director updates aren't supported in this view.
|
||
</p>
|
||
</section>
|
||
);
|
||
}
|
||
//
|
||
// Replaces the old flat <ul> list. Each CIPC director becomes a row with
|
||
// a selection checkbox and a PocketBase state pill, so you can see at a
|
||
// glance who is already saved and tick the ones you want.
|
||
//
|
||
// ID DISPLAY RULE: the ID column shows exactly what the registry
|
||
// returned — a masked ID stays masked, a full ID stays full. We never
|
||
// mask a number the app holds.
|
||
//
|
||
// A "Needs ID" row means the person IS in PocketBase but the stored
|
||
// number fails its SA ID checksum — the X→0 placeholder bug. Those are
|
||
// the rows worth fixing by hand, and this view tells you which ones.
|
||
|
||
function selectAllMissingDirectors(
|
||
rows: string[][],
|
||
byKey: Map<string, DirectorDbRow>
|
||
): Set<string> {
|
||
const next = new Set<string>();
|
||
for (const row of rows) {
|
||
const id = (row[0] ?? "").trim();
|
||
const key = maskForm(id);
|
||
if (!key) continue;
|
||
const hit = findDirector(byKey, id);
|
||
// Skip anyone already saved WITH a trustworthy number. Damaged rows
|
||
// stay selectable — you may well want to correct them.
|
||
if (hit?.hasFullId) continue;
|
||
next.add(`director:${key}`);
|
||
}
|
||
return next;
|
||
}
|
||
|
||
function selectAllMissingCompanies(
|
||
rows: string[][],
|
||
matches: Record<string, CollectionMatch>
|
||
): Set<string> {
|
||
const next = new Set<string>();
|
||
for (const row of rows) {
|
||
const ent = (row[0] ?? "").replace(/\s/g, "").toUpperCase();
|
||
if (!ent) continue;
|
||
if (matches[ent]?.in_collection) continue;
|
||
next.add(`company:${ent}`);
|
||
}
|
||
return next;
|
||
}
|
||
|
||
function DirectorsTable({
|
||
rows,
|
||
db,
|
||
selected,
|
||
clientTicks,
|
||
onToggle,
|
||
onToggleClient,
|
||
}: {
|
||
rows: string[][];
|
||
db: { loaded: boolean; byKey: Map<string, DirectorDbRow> };
|
||
selected: Set<string>;
|
||
clientTicks: Set<string>;
|
||
onToggle: (key: string) => void;
|
||
onToggleClient: (key: string) => void;
|
||
}) {
|
||
if (rows.length === 0) {
|
||
return (
|
||
<section data-cipc-directors className="pt-2">
|
||
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted mb-1.5">
|
||
Directors
|
||
</h4>
|
||
<p className="text-sm text-muted italic">No directors returned by CIPC.</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const cell = (r: string[], i: number) => (r[i] ?? "").trim();
|
||
|
||
return (
|
||
<section data-cipc-directors className="pt-2">
|
||
<div className="flex items-baseline justify-between gap-3 flex-wrap mb-2">
|
||
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted">
|
||
Directors ({rows.length})
|
||
</h4>
|
||
<p className="text-[11px] text-muted">
|
||
{db.loaded
|
||
? "Tick anyone not yet saved — saving comes in the next step."
|
||
: "Checking PocketBase…"}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-surface-warm border-b border-border text-left text-[11px] uppercase tracking-wider font-bold text-muted">
|
||
<th className="px-3 py-2 w-10" />
|
||
<th className="px-3 py-2 whitespace-nowrap">ID Number</th>
|
||
<th className="px-3 py-2 whitespace-nowrap">Name(s)</th>
|
||
<th className="px-3 py-2 whitespace-nowrap">Surname</th>
|
||
<th className="px-3 py-2 whitespace-nowrap">Type</th>
|
||
<th className="px-3 py-2 whitespace-nowrap">Status</th>
|
||
<th className="px-3 py-2 whitespace-nowrap">Saved</th>
|
||
<th className="px-3 py-2 whitespace-nowrap">Client</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r, i) => {
|
||
const id = cell(r, 0);
|
||
const hit = findDirector(db.byKey, id);
|
||
const key = `director:${maskForm(id)}`;
|
||
const isSelected = selected.has(key);
|
||
const isClient = clientTicks.has(key);
|
||
const needsId = !!hit && !hit.hasFullId;
|
||
const damaged = !!hit?.isDamaged;
|
||
|
||
return (
|
||
<tr
|
||
key={`${id}-${i}`}
|
||
className={
|
||
"border-t border-border " +
|
||
// Selection highlight uses theme tokens + an accent
|
||
// ring, NOT a fixed amber background — this app drives
|
||
// dark mode through CSS variables, so a hardcoded
|
||
// `bg-amber-50` stays pale in dark theme and swallows
|
||
// the row text.
|
||
(isSelected
|
||
? "bg-surface-warm ring-1 ring-inset ring-accent/40"
|
||
: damaged
|
||
? "bg-red-50/40"
|
||
: i % 2 === 0
|
||
? "bg-surface"
|
||
: "bg-surface-warm")
|
||
}
|
||
>
|
||
<td className="px-3 py-2 align-top">
|
||
{!hit?.hasFullId && (
|
||
<input
|
||
type="checkbox"
|
||
className="w-4 h-4 accent-amber-600 cursor-pointer"
|
||
checked={isSelected}
|
||
onChange={() => onToggle(key)}
|
||
aria-label={`Select director ${cell(r, 1)} ${cell(r, 2)}`}
|
||
/>
|
||
)}
|
||
</td>
|
||
{/* ID shown verbatim — never masked by us. */}
|
||
<td className="px-3 py-2 align-top font-mono text-[12px] whitespace-nowrap text-fg">
|
||
{id || "—"}
|
||
</td>
|
||
<td className="px-3 py-2 align-top text-fg">{cell(r, 1) || "—"}</td>
|
||
<td className="px-3 py-2 align-top text-fg">{cell(r, 2) || "—"}</td>
|
||
<td className="px-3 py-2 align-top text-muted">{cell(r, 3) || "—"}</td>
|
||
<td className="px-3 py-2 align-top">
|
||
<span
|
||
className={
|
||
"text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded " +
|
||
(cell(r, 4).toLowerCase() === "active"
|
||
? "bg-emerald-50 text-emerald-700"
|
||
: "bg-surface text-muted")
|
||
}
|
||
>
|
||
{cell(r, 4) || "—"}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2 align-top">
|
||
{!db.loaded ? (
|
||
<span className="text-[10px] text-muted italic">checking…</span>
|
||
) : hit?.hasFullId ? (
|
||
<span className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded bg-emerald-50 text-emerald-700">
|
||
In your records
|
||
</span>
|
||
) : damaged ? (
|
||
<span
|
||
title="Saved, but the stored ID number fails its SA ID checksum — this looks like a placeholder. Correct it by hand."
|
||
className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded bg-red-50 text-red-700 border border-red-200"
|
||
>
|
||
Needs ID
|
||
</span>
|
||
) : hit ? (
|
||
<span
|
||
title="Saved, but only with a masked ID number."
|
||
className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded bg-amber-50 text-amber-800 border border-amber-200"
|
||
>
|
||
Masked only
|
||
</span>
|
||
) : (
|
||
<span className="text-[10px] text-amber-700 italic">not saved</span>
|
||
)}
|
||
</td>
|
||
{/* Client flag — SJ decides per row. Only meaningful
|
||
while the row is ticked for saving. */}
|
||
<td className="px-3 py-2 align-top">
|
||
{isSelected ? (
|
||
<label className="inline-flex items-center gap-1.5 cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
className="w-4 h-4 accent-accent cursor-pointer"
|
||
checked={isClient}
|
||
onChange={() => onToggleClient(key)}
|
||
aria-label={`Mark ${cell(r, 1)} ${cell(r, 2)} as a client`}
|
||
/>
|
||
<span className="text-[10px] uppercase tracking-wider font-semibold text-muted">
|
||
Client
|
||
</span>
|
||
</label>
|
||
) : (
|
||
<span className="text-[10px] text-muted/60">—</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-[10px] text-muted italic mt-1.5">
|
||
ID numbers are shown exactly as CIPC returned them. Masked IDs stay
|
||
masked because the registry masks them; the app never masks a number
|
||
it holds in full.
|
||
</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ===================================================================
|
||
// SELECTION BAR
|
||
// ===================================================================
|
||
//
|
||
// Summary + selection controls. Deliberately inert for now: the counts and
|
||
// checkboxes are live, but the PROCESS button is disabled and says so.
|
||
// Nothing in this component writes to PocketBase.
|
||
// ===================================================================
|
||
|
||
function SelectionBar({
|
||
counts,
|
||
kind,
|
||
dbLoaded,
|
||
selectedCount,
|
||
clientCount,
|
||
cleared,
|
||
saving,
|
||
saveMsg,
|
||
onSelectAllMissing,
|
||
onClear,
|
||
onSave,
|
||
}: {
|
||
counts: {
|
||
companiesIn: number;
|
||
companiesMissing: number;
|
||
directorsIn: number;
|
||
directorsMissing: number;
|
||
directorsDamaged: number;
|
||
};
|
||
kind: SearchKind;
|
||
dbLoaded: boolean;
|
||
selectedCount: number;
|
||
clientCount: number;
|
||
cleared: boolean;
|
||
saving: boolean;
|
||
saveMsg: SaveSummary | null;
|
||
onSelectAllMissing: () => void;
|
||
onClear: () => void;
|
||
onSave: () => void;
|
||
}) {
|
||
const nothingMissing =
|
||
counts.companiesMissing === 0 && counts.directorsMissing === 0;
|
||
|
||
return (
|
||
<div className="rounded-md border border-border bg-surface-warm px-4 py-3">
|
||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||
<p className="text-xs text-muted">
|
||
{dbLoaded ? (
|
||
<>
|
||
{kind === "enterprise_no" && (
|
||
<>
|
||
<span className="font-semibold text-fg">Company:</span>{" "}
|
||
<span
|
||
className={
|
||
counts.companiesMissing > 0 ? "text-amber-700 font-semibold" : "text-emerald-700 font-semibold"
|
||
}
|
||
>
|
||
{counts.companiesMissing > 0 ? "not saved" : "already saved"}
|
||
</span>
|
||
</>
|
||
)}
|
||
{kind === "director_id_no" && (
|
||
<>
|
||
<span className="font-semibold text-fg">Companies:</span>{" "}
|
||
<span
|
||
className={
|
||
counts.companiesMissing > 0 ? "text-amber-700 font-semibold" : "text-emerald-700 font-semibold"
|
||
}
|
||
>
|
||
{counts.companiesIn}/{counts.companiesIn + counts.companiesMissing} saved
|
||
</span>
|
||
</>
|
||
)}
|
||
{kind === "enterprise_no" && (
|
||
<>
|
||
{" · "}
|
||
<span className="font-semibold text-fg">Directors:</span>{" "}
|
||
<span
|
||
className={
|
||
counts.directorsMissing > 0 ? "text-amber-700 font-semibold" : "text-emerald-700 font-semibold"
|
||
}
|
||
>
|
||
{counts.directorsIn}/{counts.directorsIn + counts.directorsMissing} saved
|
||
</span>
|
||
</>
|
||
)}
|
||
{counts.directorsDamaged > 0 && (
|
||
<>
|
||
{" · "}
|
||
<span className="text-red-700 font-semibold">
|
||
{counts.directorsDamaged} need a real ID
|
||
</span>
|
||
</>
|
||
)}
|
||
</>
|
||
) : (
|
||
"Checking what's already saved…"
|
||
)}
|
||
</p>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{selectedCount > 0 && (
|
||
<span className="text-xs font-semibold text-fg">
|
||
{selectedCount} selected
|
||
</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={onSelectAllMissing}
|
||
disabled={!dbLoaded || nothingMissing}
|
||
className="text-xs px-3 py-1.5 rounded border border-border bg-surface text-fg hover:border-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
Select all not saved
|
||
</button>
|
||
{selectedCount > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={onClear}
|
||
className="text-xs px-3 py-1.5 rounded border border-border bg-surface text-muted hover:text-fg transition-colors"
|
||
>
|
||
Clear
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={onSave}
|
||
disabled={saving || selectedCount === 0}
|
||
title={
|
||
selectedCount === 0
|
||
? "Tick the rows you want to save first."
|
||
: saving
|
||
? "Saving…"
|
||
: "Save the ticked rows to PocketBase."
|
||
}
|
||
className="text-xs px-4 py-1.5 rounded bg-accent text-accent-on font-semibold hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{saving
|
||
? "Saving…"
|
||
: `Save to PocketBase${selectedCount > 0 ? ` (${selectedCount})` : ""}`}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedCount > 0 && (
|
||
<p className="text-[11px] text-muted mt-2">
|
||
{selectedCount} ticked
|
||
{clientCount > 0 ? ` · ${clientCount} marked as client` : " · none marked as client"}
|
||
. Directors with a masked ID are saved with the ID field left empty
|
||
so the real number can be filled in later.
|
||
</p>
|
||
)}
|
||
{saveMsg && (
|
||
<p
|
||
className={
|
||
"text-[11px] mt-2 px-2.5 py-1.5 rounded border " +
|
||
(saveMsg.ok
|
||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||
: "border-red-200 bg-red-50 text-red-800")
|
||
}
|
||
>
|
||
{saveMsg.ok ? "✓ " : "✗ "}
|
||
{saveMsg.text}
|
||
</p>
|
||
)}
|
||
{cleared && selectedCount === 0 && !saveMsg && (
|
||
<p className="text-[11px] text-muted mt-2">Selection cleared.</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function InCollectionPill({ match }: { match: CollectionMatch | undefined }) {
|
||
if (match === undefined) {
|
||
return (
|
||
<span
|
||
title="PocketBase lookup skipped or failed"
|
||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill border border-border bg-surface text-muted text-[10px] font-semibold uppercase tracking-wider"
|
||
>
|
||
Unknown
|
||
</span>
|
||
);
|
||
}
|
||
if (match.in_collection) {
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border border-emerald-200 bg-emerald-50 text-emerald-700 text-[10px] font-semibold uppercase tracking-wider">
|
||
<CheckIcon /> In collection
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border border-amber-200 bg-amber-50 text-amber-800 text-[10px] font-semibold uppercase tracking-wider">
|
||
Not in collection
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function CollectionRowAction({
|
||
entNo,
|
||
match,
|
||
onAdd,
|
||
}: {
|
||
entNo: string;
|
||
match: CollectionMatch | undefined;
|
||
onAdd: () => void;
|
||
}) {
|
||
// Defensive: if PB enrichment didn't return for this row, don't offer a
|
||
// misleading "+ Add" — clicking it could create a duplicate entry. Show
|
||
// a muted dash so the user knows there's no decision to make here.
|
||
if (match === undefined) {
|
||
return (
|
||
<span
|
||
title="PocketBase lookup did not return a result for this row"
|
||
className="inline-flex items-center px-2.5 py-1 text-[11px] text-muted"
|
||
>
|
||
—
|
||
</span>
|
||
);
|
||
}
|
||
if (match.in_collection && match.pb_id) {
|
||
return (
|
||
<a
|
||
href={`https://pb.agent.apphub.co.za/_/#/collections?collection=pbc_3748838150&recordId=${match.pb_id}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-border bg-surface text-fg text-[11px] font-semibold hover:bg-accent hover:text-accent-on hover:border-accent transition-colors"
|
||
>
|
||
Open in PB ↗
|
||
</a>
|
||
);
|
||
}
|
||
// Already added optimistically this session — no PB id known yet, but show
|
||
// a closed / non-actionable pill instead of letting the user add twice.
|
||
if (match.in_collection) {
|
||
return (
|
||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-emerald-200 bg-emerald-50 text-emerald-700 text-[11px] font-semibold">
|
||
Added ✓
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onAdd}
|
||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-accent bg-accent text-accent-on text-[11px] font-semibold hover:bg-accent-hover transition-colors"
|
||
title={`Add ${entNo} to your PocketBase collection`}
|
||
>
|
||
+ Add
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ===================================================================
|
||
// ADD COMPANY MODAL
|
||
// ===================================================================
|
||
|
||
interface AddCompanyPreFill {
|
||
enterprise_no: string;
|
||
enterprise_name: string;
|
||
enterprise_status?: string;
|
||
director_status?: string;
|
||
enterprise_type?: string;
|
||
registration_date?: string;
|
||
physical_address?: string;
|
||
postal_address?: string;
|
||
source: string;
|
||
}
|
||
|
||
function AddCompanyModal({
|
||
prefill,
|
||
onClose,
|
||
onSaved,
|
||
}: {
|
||
prefill: AddCompanyPreFill;
|
||
onClose: () => void;
|
||
onSaved: (saved: { id: string; enterprise_no: string; enterprise_name: string }) => void;
|
||
}) {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const defaultNotes = `Added via CIPC search from the Workflow CIPC tab on ${today}.`;
|
||
|
||
const [displayName, setDisplayName] = useState(prefill.enterprise_name);
|
||
const [enterpriseType, setEnterpriseType] = useState(prefill.enterprise_type ?? "");
|
||
const [clientStatus, setClientStatus] = useState("non_client");
|
||
const [registrationDate, setRegistrationDate] = useState(prefill.registration_date ?? "");
|
||
const [physicalAddress, setPhysicalAddress] = useState(prefill.physical_address ?? "");
|
||
const [postalAddress, setPostalAddress] = useState(prefill.postal_address ?? "");
|
||
const [notes, setNotes] = useState(defaultNotes);
|
||
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||
|
||
// Close on Escape
|
||
useEffect(() => {
|
||
function onKey(e: KeyboardEvent) {
|
||
if (e.key === "Escape" && !submitting) onClose();
|
||
}
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [onClose, submitting]);
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setSubmitting(true);
|
||
setErrorMsg(null);
|
||
try {
|
||
const r = await fetch("/api/cipc/add-company", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
enterprise_no: prefill.enterprise_no,
|
||
enterprise_name: displayName.trim() || prefill.enterprise_name,
|
||
enterprise_status: prefill.enterprise_status,
|
||
director_status: prefill.director_status,
|
||
enterprise_type: enterpriseType.trim() || undefined,
|
||
registration_date: registrationDate.trim() || undefined,
|
||
physical_address: physicalAddress.trim() || undefined,
|
||
postal_address: postalAddress.trim() || undefined,
|
||
client_status: clientStatus,
|
||
notes: notes.trim() || undefined,
|
||
source: prefill.source,
|
||
}),
|
||
});
|
||
const j = await r.json().catch(() => ({}));
|
||
if (r.status === 201 && j.ok) {
|
||
onSaved({
|
||
id: j.id,
|
||
enterprise_no: j.enterprise_no ?? prefill.enterprise_no,
|
||
enterprise_name: j.enterprise_name ?? displayName,
|
||
});
|
||
return;
|
||
}
|
||
if (r.status === 409) {
|
||
setErrorMsg(
|
||
(j.existing_name
|
||
? `"${j.existing_name}" is already in your collection. `
|
||
: `${prefill.enterprise_no} is already in your collection. `) +
|
||
`Refreshing report…`
|
||
);
|
||
// Treat 409 as an optimistic success — close + flip row.
|
||
onSaved({
|
||
id: j.existing_id ?? "unknown",
|
||
enterprise_no: prefill.enterprise_no,
|
||
enterprise_name: j.existing_name ?? displayName,
|
||
});
|
||
return;
|
||
}
|
||
setErrorMsg(j.error || `Save failed (HTTP ${r.status})`);
|
||
} catch (err: any) {
|
||
setErrorMsg(err?.message || "Network error");
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={`Add ${prefill.enterprise_no} to your collection`}
|
||
className="fixed inset-0 z-50 no-print flex items-center justify-center p-4 bg-black/40"
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget && !submitting) onClose();
|
||
}}
|
||
>
|
||
<form
|
||
onSubmit={submit}
|
||
className="w-full max-w-lg rounded-lg border border-border bg-surface shadow-2xl overflow-hidden"
|
||
>
|
||
<header className="px-5 py-3 bg-surface-warm border-b border-border">
|
||
<h3 className="text-sm font-bold tracking-tight">
|
||
Add {prefill.enterprise_no} to collection
|
||
</h3>
|
||
<p className="text-[11px] text-muted mt-0.5">
|
||
New PocketBase record in <code className="font-mono">ysterbaard_companies</code>.
|
||
</p>
|
||
</header>
|
||
|
||
<div className="px-5 py-4 space-y-3 text-sm">
|
||
<ModalField label="Enterprise Number" locked>
|
||
<input
|
||
value={prefill.enterprise_no}
|
||
readOnly
|
||
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface-warm text-muted text-[13px] font-mono"
|
||
/>
|
||
</ModalField>
|
||
<ModalField label="Registered Name" locked>
|
||
<input
|
||
value={prefill.enterprise_name}
|
||
readOnly
|
||
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface-warm text-muted text-[13px]"
|
||
/>
|
||
</ModalField>
|
||
{(prefill.enterprise_status || prefill.director_status) && (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<ModalField label="Enterprise Status" locked>
|
||
<input
|
||
value={prefill.enterprise_status ?? ""}
|
||
readOnly
|
||
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface-warm text-muted text-[13px]"
|
||
/>
|
||
</ModalField>
|
||
<ModalField label="Director Status" locked>
|
||
<input
|
||
value={prefill.director_status ?? ""}
|
||
readOnly
|
||
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface-warm text-muted text-[13px]"
|
||
/>
|
||
</ModalField>
|
||
</div>
|
||
)}
|
||
|
||
<ModalField label="Display Name">
|
||
<input
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
||
placeholder="The name you use in your records"
|
||
/>
|
||
</ModalField>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<ModalField label="Type">
|
||
<select
|
||
value={enterpriseType}
|
||
onChange={(e) => setEnterpriseType(e.target.value)}
|
||
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
||
>
|
||
<option value="">— select —</option>
|
||
<option value="Private Company">Private Company</option>
|
||
<option value="Public Company">Public Company</option>
|
||
<option value="Close Corporation">Close Corporation</option>
|
||
<option value="Non-Profit Company">Non-Profit Company</option>
|
||
<option value="Co-operative">Co-operative</option>
|
||
<option value="Sole Proprietor">Sole Proprietor</option>
|
||
<option value="Partnership">Partnership</option>
|
||
<option value="Other">Other</option>
|
||
</select>
|
||
</ModalField>
|
||
<ModalField label="Client Status">
|
||
<select
|
||
value={clientStatus}
|
||
onChange={(e) => setClientStatus(e.target.value)}
|
||
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
||
>
|
||
<option value="non_client">Non-client</option>
|
||
<option value="active_client">Active client</option>
|
||
<option value="dormant_client">Dormant client</option>
|
||
<option value="deregistered">Deregistered</option>
|
||
</select>
|
||
</ModalField>
|
||
</div>
|
||
|
||
{/* Pre-filled from CIPC when the + Add was triggered from the
|
||
Enterprise No. search. User can edit before saving. */}
|
||
{prefill.registration_date !== undefined && prefill.registration_date !== "" && (
|
||
<ModalField label="Registration Date">
|
||
<input
|
||
type="date"
|
||
value={prefill.registration_date}
|
||
onChange={(e) => setRegistrationDate(e.target.value)}
|
||
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
||
/>
|
||
</ModalField>
|
||
)}
|
||
{prefill.physical_address !== undefined && prefill.physical_address !== "" && (
|
||
<ModalField label="Physical Address">
|
||
<input
|
||
value={physicalAddress}
|
||
onChange={(e) => setPhysicalAddress(e.target.value)}
|
||
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
||
/>
|
||
</ModalField>
|
||
)}
|
||
{prefill.postal_address !== undefined && prefill.postal_address !== "" && (
|
||
<ModalField label="Postal Address">
|
||
<input
|
||
value={postalAddress}
|
||
onChange={(e) => setPostalAddress(e.target.value)}
|
||
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
||
/>
|
||
</ModalField>
|
||
)}
|
||
|
||
<ModalField label="Notes">
|
||
<textarea
|
||
value={notes}
|
||
onChange={(e) => setNotes(e.target.value)}
|
||
rows={3}
|
||
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent resize-none"
|
||
/>
|
||
</ModalField>
|
||
|
||
{errorMsg && (
|
||
<p className="text-[12px] text-red-700 bg-red-50 border border-red-200 rounded px-2.5 py-2">
|
||
{errorMsg}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<footer className="px-5 py-3 border-t border-border bg-surface-warm flex items-center justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={submitting}
|
||
onClick={onClose}
|
||
className="px-3 py-1.5 rounded-md border border-border bg-surface text-fg text-xs font-semibold hover:bg-surface-warm disabled:opacity-50"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={submitting}
|
||
className="px-4 py-1.5 rounded-md bg-accent text-accent-on text-xs font-bold hover:bg-accent-hover disabled:opacity-60 inline-flex items-center gap-2"
|
||
>
|
||
{submitting && <Spinner small />}
|
||
{submitting ? "Saving…" : "Save to PocketBase"}
|
||
</button>
|
||
</footer>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ModalField({
|
||
label,
|
||
locked,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
locked?: boolean;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<label className="block space-y-1">
|
||
<span className="flex items-center justify-between text-[10px] uppercase tracking-wider font-semibold text-muted">
|
||
{label}
|
||
{locked && (
|
||
<span className="text-[9px] font-mono text-muted normal-case tracking-normal">
|
||
from CIPC
|
||
</span>
|
||
)}
|
||
</span>
|
||
{children}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function SectionBlock({ section }: { section: Section }) {
|
||
return (
|
||
<section>
|
||
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted mb-2">
|
||
{section.title}
|
||
</h4>
|
||
{section.body.kind === "kv" && <KvTable rows={section.body.rows} />}
|
||
{section.body.kind === "table" && (
|
||
<DataTable columns={section.body.columns} rows={section.body.rows} />
|
||
)}
|
||
{section.body.kind === "text" && (
|
||
<ul className="text-sm space-y-1">
|
||
{section.body.lines.filter((l) => l.trim()).map((l, i) => (
|
||
<li key={i}>{l}</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function KvTable({ rows }: { rows: [string, string][] }) {
|
||
if (rows.length === 0) return null;
|
||
return (
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<tbody>
|
||
{rows.map(([k, v], i) => (
|
||
<tr
|
||
key={i}
|
||
className={
|
||
i % 2 === 0
|
||
? "bg-surface-warm"
|
||
: "bg-surface"
|
||
}
|
||
>
|
||
<th className="text-left px-4 py-2 font-semibold text-fg w-1/3 align-top border-r border-border">
|
||
{k}
|
||
</th>
|
||
<td className="px-4 py-2 text-fg font-mono text-[13px]">{v}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Status cell for the merged ANNUAL RETURNS table. Filed returns read as
|
||
* done (emerald), outstanding ones as work still owed (red) so the
|
||
* actionable rows are obvious at a glance. Anything unrecognised falls
|
||
* back to muted text rather than guessing a colour.
|
||
*/
|
||
function ArStatusCell({ value }: { value: string }) {
|
||
const v = value.trim();
|
||
if (isArFiledRow([v])) return <StatusPill tone="ok">Filed</StatusPill>;
|
||
if (isArOutstandingRow([v])) return <StatusPill tone="err">Outstanding</StatusPill>;
|
||
return <span className="text-muted">{v || "—"}</span>;
|
||
}
|
||
|
||
function DataTable({ columns, rows }: { columns: string[]; rows: string[][] }) {
|
||
if (rows.length === 0) {
|
||
return <p className="text-sm text-muted italic">No rows.</p>;
|
||
}
|
||
// A column literally headed "Status" renders as a coloured pill rather
|
||
// than plain text. Currently only the merged ANNUAL RETURNS table has
|
||
// one; every other section keeps the original plain-text rendering.
|
||
const statusIdx = columns.findIndex((c) => /^status$/i.test(c.trim()));
|
||
return (
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-surface-warm border-b border-border">
|
||
{columns.map((c, i) => (
|
||
<th
|
||
key={i}
|
||
className="text-left px-4 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap"
|
||
>
|
||
{c}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, i) => (
|
||
<tr
|
||
key={i}
|
||
className={
|
||
"border-t border-border " +
|
||
(i % 2 === 0 ? "bg-surface" : "bg-surface-warm")
|
||
}
|
||
>
|
||
{columns.map((_, j) => (
|
||
<td
|
||
key={j}
|
||
className={
|
||
"px-4 py-2 text-fg whitespace-nowrap " +
|
||
(j === 0 ? "font-mono text-[13px]" : "")
|
||
}
|
||
>
|
||
{j === statusIdx ? (
|
||
<ArStatusCell value={row[j] ?? ""} />
|
||
) : (
|
||
row[j] ?? ""
|
||
)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ===================================================================
|
||
// ERROR VIEW
|
||
// ===================================================================
|
||
|
||
function ErrorView({ result }: { result: SearchResponse }) {
|
||
return (
|
||
<div className="rounded-md border border-red-200 bg-red-50/60 px-4 py-3 text-sm space-y-1.5">
|
||
<div className="flex items-center gap-2">
|
||
<ErrorIcon />
|
||
<p className="font-semibold text-red-900">Search failed</p>
|
||
</div>
|
||
<p className="font-mono text-xs text-red-900/90">{result.error}</p>
|
||
{result.resolved_query && result.resolved_query !== result.query && (
|
||
<p className="font-mono text-xs text-muted">
|
||
Tried resolved query{" "}
|
||
<code className="bg-surface px-1.5 py-0.5 rounded border border-border">
|
||
{result.resolved_query}
|
||
</code>
|
||
</p>
|
||
)}
|
||
{result.raw && <RawOutputToggle raw={result.raw} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RawOutputToggle({ raw }: { raw: string }) {
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<div className="text-xs pt-1">
|
||
<button
|
||
onClick={() => setOpen((o) => !o)}
|
||
className="text-muted hover:text-fg underline no-print"
|
||
>
|
||
{open ? "Hide" : "Show"} raw stdout
|
||
</button>
|
||
{open && (
|
||
<pre className="mt-2 p-3 rounded bg-surface text-fg font-mono text-[11px] overflow-x-auto whitespace-pre-wrap max-h-96 border border-border">
|
||
{raw}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ===================================================================
|
||
// SHARED COMPONENTS
|
||
// ===================================================================
|
||
|
||
function MetaCell({
|
||
label,
|
||
value,
|
||
mono,
|
||
accent,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
mono?: boolean;
|
||
accent?: boolean;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<dt className="text-[10px] uppercase tracking-wider font-semibold text-muted">
|
||
{label}
|
||
</dt>
|
||
<dd
|
||
className={
|
||
"mt-0.5 truncate " +
|
||
(mono ? "font-mono " : "") +
|
||
(accent ? "text-accent font-semibold" : "text-fg")
|
||
}
|
||
title={value}
|
||
>
|
||
{value}
|
||
</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatusPill({
|
||
tone,
|
||
children,
|
||
}: {
|
||
tone: "ok" | "err" | "busy" | "idle";
|
||
children: React.ReactNode;
|
||
}) {
|
||
const styles = {
|
||
ok: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||
err: "bg-red-50 text-red-700 border-red-200",
|
||
busy: "bg-amber-50 text-amber-700 border-amber-200",
|
||
idle: "bg-surface-warm text-muted border-border",
|
||
} as const;
|
||
return (
|
||
<span
|
||
className={
|
||
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-pill border text-[11px] font-semibold uppercase tracking-wider " +
|
||
styles[tone]
|
||
}
|
||
>
|
||
{tone === "ok" && <CheckIcon />}
|
||
{tone === "err" && <ErrorIcon small />}
|
||
{tone === "busy" && <Spinner small />}
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function Spinner({ small }: { small?: boolean }) {
|
||
return (
|
||
<svg
|
||
className={"animate-spin " + (small ? "w-3 h-3" : "w-3.5 h-3.5")}
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
aria-hidden="true"
|
||
>
|
||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" opacity="0.25" />
|
||
<path
|
||
d="M22 12a10 10 0 0 0-10-10"
|
||
stroke="currentColor"
|
||
strokeWidth="3"
|
||
strokeLinecap="round"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function CheckIcon() {
|
||
return (
|
||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="20 6 9 17 4 12" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function ErrorIcon({ small }: { small?: boolean }) {
|
||
return (
|
||
<svg
|
||
width={small ? "11" : "14"}
|
||
height={small ? "11" : "14"}
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2.4"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<circle cx="12" cy="12" r="10" />
|
||
<line x1="12" y1="8" x2="12" y2="12" />
|
||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function PrintIcon() {
|
||
return (
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="6 9 6 2 18 2 18 9" />
|
||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||
<rect x="6" y="14" width="12" height="8" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function ClearIcon() {
|
||
return (
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="3 6 5 6 21 6" />
|
||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||
<path d="M10 11v6M14 11v6" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function PinIcon() {
|
||
return (
|
||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M12 2v6" />
|
||
<path d="M9 8h6l-1 6h-4z" />
|
||
<path d="M12 14v8" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function useNow(): string {
|
||
// Stable timestamp captured on first render of the report.
|
||
const [stamp] = useState(() =>
|
||
new Date().toLocaleString("en-ZA", {
|
||
year: "numeric",
|
||
month: "short",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
hour12: false,
|
||
})
|
||
);
|
||
return stamp;
|
||
}
|
||
|
||
// ===================================================================
|
||
// PRINT STYLES — applied only when printing.
|
||
// ===================================================================
|
||
|
||
function PrintStyles() {
|
||
// Use dangerouslySetInnerHTML so React 18 / Next 14 don't escape the raw
|
||
// `"` characters inside the CSS body — escaping causes a hydration
|
||
// mismatch ("does not match server-rendered HTML"). The CSS itself is
|
||
// static and authored by us, so `dangerouslySetInnerHTML` is safe here.
|
||
return (
|
||
<style
|
||
dangerouslySetInnerHTML={{
|
||
__html: `
|
||
@media print {
|
||
body { background: #ffffff !important; }
|
||
header.sticky, nav, [data-od-id="navbar"], .no-print { display: none !important; }
|
||
section.container-x { padding: 0 !important; }
|
||
[data-cipc-card] { box-shadow: none !important; border: 1px solid #e5e7eb !important; page-break-inside: avoid; }
|
||
[data-cipc-report] { box-shadow: none !important; border: 0 !important; }
|
||
[data-cipc-card] header { background: #f8fafc !important; }
|
||
[data-cipc-report] header { background: #f8fafc !important; }
|
||
[data-cipc-report] footer { background: #f8fafc !important; }
|
||
table { page-break-inside: auto; }
|
||
tr { page-break-inside: avoid; page-break-after: auto; }
|
||
thead { display: table-header-group; }
|
||
@page { margin: 14mm 12mm; }
|
||
}
|
||
`,
|
||
}}
|
||
/>
|
||
);
|
||
}
|