Workflow-App/Workflow/app/cipc/CipcExplorer.tsx
sjcoetzeesj-coder 21db4d0e64 fix: resolve all 7 type errors that broke the production build
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.
2026-10-18 15:48:50 +02:00

2555 lines
93 KiB
TypeScript

// app/cipc/CipcExplorer.tsx
// Client component — two side-by-side search cards. Each action POSTs to
// /api/cipc/search which runs the matching Python script and returns the
// captured markdown. The markdown is parsed into structured sections and
// rendered as a corporate-style report (proper headings, tables, key-value
// pairs) with a Print / Save PDF button.
//
// FROZEN-BY-POLICY: this file owns all visual treatment. The two .py files
// it dispatches to ("Bizportal Enterprise No. Search.py" and
// "Bizportal Directopr id no search.py") are co-authored and untouched.
"use client";
import { 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.
// ===================================================================
const CACHE_PREFIX = "cipc: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 = "cipc: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);
if (k && k.startsWith(LEGACY_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`;
}
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));
return { title, 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 };
}
// ===================================================================
// 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);
}
return (
<div className="space-y-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<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")}
/>
</div>
<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;
}
function SearchCard({
kind,
loading,
result,
cachedAt,
input,
onInputChange,
onSubmit,
onClear,
}: SearchCardProps) {
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>
{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";
// 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
: [];
// For Enterprise No. reports we render KV sections (COMPANY DETAILS,
// OTHER DETAILS) as inline diff tables, and the 3 multi-row tables
// (DIRECTOR DETAILS, FILED ANNUAL RETURNS, OUTSTANDING ANNUAL RETURNS) as
// table diffs. Match them by title prefix 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]);
// 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 ?? ""}`,
})
}
/>
</dd>
</div>
)}
</dl>
{/* ── 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>
)}
{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}
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
// (FILED ANNUAL RETURNS, OUTSTANDING ANNUAL RETURNS) render the
// CIPC table + a single Apply button to overwrite the designated
// PB field. 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 — prettier list view (no diff rule today).
if (
kind === "enterprise_no" &&
section.body.kind === "table" &&
/^director details$/i.test(section.title)
) {
return (
<DirectorsList
key={idx}
directors={result.cipc_directors ?? []}
/>
);
}
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,
}: {
section: Section;
matches: Record<string, CollectionMatch>;
onAdd: (row: 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">
{/* 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>
</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")
}
>
{/* 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>
</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 },
];
/**
* Per-section rules for table-shaped CIPC blocks. Each rule maps a section
* title (case-insensitive prefix match) to a single PB field. The CIPC
* table rows are serialised via `toCipcValue` (e.g. a CSV-ish string) so
* the diff against the PB field's existing text is meaningful.
*
* Sections without a rule here render as plain DataTable / DirectorsList.
*/
interface TableDiffRule {
/** 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;
/** 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 }> = [
{
// e.g. "2026/AUGUST" joined by " | "
match: /^outstanding annual returns$/i,
rule: {
pbField: "ar_outstanding",
toCipcValue: (rows) =>
rows
.map((r) => [r[0] ?? "", r[1] ?? ""].filter(Boolean).join("/"))
.filter(Boolean)
.join(" | "),
applyLabel: "Overwrite PocketBase `ar_outstanding`",
note: "CIPC paragraphs rendered as `YYYY/MONTH` joined by ` | `.",
},
},
{
// e.g. JSON array — preserves year/amount/date for future audit.
match: /^filed annual returns$/i,
rule: {
pbField: "enterprise_history",
toCipcValue: (rows) =>
JSON.stringify(
rows.map((r) => ({
year: r[0] ?? "",
amount: r[1] ?? "",
date_filed: r[2] ?? "",
})),
null,
0
),
applyLabel: "Overwrite PocketBase `enterprise_history`",
note: "Stored as compact JSON array so future runs can compare.",
},
},
];
// 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 cipcValue = rule.toCipcValue(rows, columns);
const hasPb = !!pbRecord && !!pbId;
const pbValue = hasPb
? (pbRecord?.[rule.pbField] ?? "").toString().trim()
: "";
const differs = hasPb && cipcValue !== pbValue;
async function apply() {
if (!pbId) return;
setErrorMsg(null);
setApplying(true);
try {
const fields: Record<string, string> = { [rule.pbField]: 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">{rule.pbField}</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 */}
<div className="rounded-md border border-border bg-surface-warm">
<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">
{rule.pbField}
</span>{" "}
(current)
</div>
<pre className="px-4 py-2 text-[12px] text-fg font-mono whitespace-pre-wrap break-words max-h-40 overflow-auto">
{pbValue || <span className="text-muted italic">(empty)</span>}
</pre>
<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>
);
}
function DirectorsList({
directors,
}: {
directors: Array<{ name: string; id: string }>;
}) {
return (
<section data-cipc-directors 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>
);
}
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>
);
}
function DataTable({ columns, rows }: { columns: string[]; rows: string[][] }) {
if (rows.length === 0) {
return <p className="text-sm text-muted italic">No rows.</p>;
}
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]" : "")
}
>
{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; }
}
`,
}}
/>
);
}