Workflow-App/Workflow/app/cipc-2/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

3779 lines
141 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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; }
}
`,
}}
/>
);
}