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.
2555 lines
93 KiB
TypeScript
2555 lines
93 KiB
TypeScript
// app/cipc/CipcExplorer.tsx
|
|
// Client component — two side-by-side search cards. Each action POSTs to
|
|
// /api/cipc/search which runs the matching Python script and returns the
|
|
// captured markdown. The markdown is parsed into structured sections and
|
|
// rendered as a corporate-style report (proper headings, tables, key-value
|
|
// pairs) with a Print / Save PDF button.
|
|
//
|
|
// FROZEN-BY-POLICY: this file owns all visual treatment. The two .py files
|
|
// it dispatches to ("Bizportal Enterprise No. Search.py" and
|
|
// "Bizportal Directopr id no search.py") are co-authored and untouched.
|
|
|
|
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useSearchParams } from "next/navigation";
|
|
|
|
type SearchKind = "enterprise_no" | "director_id_no";
|
|
|
|
interface CollectionMatch {
|
|
in_collection: boolean;
|
|
pb_id?: string;
|
|
pb_name?: string;
|
|
pb_status?: string;
|
|
}
|
|
|
|
interface SearchResponse {
|
|
ok: boolean;
|
|
markdown?: string;
|
|
raw?: string;
|
|
duration_ms?: number;
|
|
error?: string;
|
|
counter?: number;
|
|
search_kind?: SearchKind;
|
|
query?: string;
|
|
resolved_query?: string;
|
|
resolved_via?: "k_number" | "company_name" | "director_id" | null;
|
|
/** Map of canonical Enterprise Number → whether it's already in your
|
|
* `ysterbaard_companies` PocketBase collection. */
|
|
collection_matches?: Record<string, CollectionMatch>;
|
|
/** Enterprise No. only — full PocketBase record if the company exists.
|
|
* Used by the diff UI to show side-by-side field comparisons. */
|
|
pb_record?: Record<string, any> | null;
|
|
/** Enterprise No. only — KV pairs extracted from CIPC's ENTERPRISE
|
|
* DETAILS section ("Enterprise Type: Private Company" etc.). */
|
|
cipc_fields?: Record<string, string>;
|
|
/** Enterprise No. only — directors from CIPC's DIRECTOR DETAILS table. */
|
|
cipc_directors?: Array<{ name: string; id: string }> | null;
|
|
}
|
|
|
|
// ===================================================================
|
|
// LOCALSTORAGE PERSISTENCE — last result per kind is cached across tab
|
|
// navigation, page reload, and browser restart (24h TTL). Hydrated on
|
|
// mount, written on successful search, cleared via the "Clear" button.
|
|
//
|
|
// CACHE KEY VERSIONING — bump the prefix version whenever the SearchResponse
|
|
// shape changes. v1 entries don't carry `collection_matches`, so re-using
|
|
// the v1 key after we added enrichment would silently break the
|
|
// "In your collection" pills.
|
|
// ===================================================================
|
|
|
|
const CACHE_PREFIX = "cipc:lastSearch:v3:";
|
|
// v1 (pre-collection-enrichment). Hydration sweeps this once per mount so
|
|
// stale rows don't haunt the cache forever.
|
|
const LEGACY_CACHE_PREFIX = "cipc:lastSearch:";
|
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
function purgeLegacyCache() {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
const toRemove: string[] = [];
|
|
for (let i = 0; i < window.localStorage.length; i++) {
|
|
const k = window.localStorage.key(i);
|
|
if (k && k.startsWith(LEGACY_CACHE_PREFIX)) toRemove.push(k);
|
|
}
|
|
for (const k of toRemove) window.localStorage.removeItem(k);
|
|
} catch {}
|
|
}
|
|
|
|
interface CachedEntry {
|
|
savedAt: number; // epoch ms
|
|
data: SearchResponse;
|
|
}
|
|
|
|
function cacheKey(kind: SearchKind): string {
|
|
return CACHE_PREFIX + kind;
|
|
}
|
|
|
|
function readCache(kind: SearchKind): CachedEntry | null {
|
|
if (typeof window === "undefined") return null;
|
|
try {
|
|
const raw = window.localStorage.getItem(cacheKey(kind));
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw) as CachedEntry;
|
|
if (
|
|
!parsed ||
|
|
typeof parsed.savedAt !== "number" ||
|
|
Date.now() - parsed.savedAt > CACHE_TTL_MS
|
|
) {
|
|
window.localStorage.removeItem(cacheKey(kind));
|
|
return null;
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeCache(kind: SearchKind, data: SearchResponse): void {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
const entry: CachedEntry = { savedAt: Date.now(), data };
|
|
window.localStorage.setItem(cacheKey(kind), JSON.stringify(entry));
|
|
} catch {
|
|
// QuotaExceededError or serialisation issue — fail silently.
|
|
}
|
|
}
|
|
|
|
function clearCache(kind: SearchKind): void {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
window.localStorage.removeItem(cacheKey(kind));
|
|
} catch {}
|
|
}
|
|
|
|
function ageLabel(savedAt: number): string {
|
|
const seconds = Math.max(0, Math.floor((Date.now() - savedAt) / 1000));
|
|
if (seconds < 60) return `${seconds}s ago`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes} min${minutes === 1 ? "" : "s"} ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
}
|
|
|
|
const PLACEHOLDERS: Record<SearchKind, string> = {
|
|
enterprise_no: "K-number, company name, or legacy CK",
|
|
director_id_no: "13-digit SA ID number",
|
|
};
|
|
|
|
const ACTION_LABELS: Record<SearchKind, string> = {
|
|
enterprise_no: "Search by Enterprise No.",
|
|
director_id_no: "Search by Director ID No.",
|
|
};
|
|
|
|
const ACCENT: Record<SearchKind, string> = {
|
|
enterprise_no: "Search CIPC for a company by its Enterprise Number, legacy CK number, or registered name.",
|
|
director_id_no: "Search CIPC for every company where a given 13-digit SA ID is currently a registered director.",
|
|
};
|
|
|
|
// ===================================================================
|
|
// MARKDOWN PARSER — converts the .py's stdout into structured sections.
|
|
// ===================================================================
|
|
|
|
type SectionContent =
|
|
| { kind: "kv"; rows: [string, string][] } // "### TITLE\n- K: V"
|
|
| { kind: "table"; columns: string[]; rows: string[][] }
|
|
| { kind: "text"; lines: string[] };
|
|
|
|
interface Section {
|
|
title: string;
|
|
body: SectionContent;
|
|
}
|
|
|
|
interface ParsedReport {
|
|
title: string;
|
|
subtitle?: string;
|
|
caption?: string;
|
|
sections: Section[];
|
|
unmatched: string[]; // any body text we couldn't classify
|
|
}
|
|
|
|
function parseReport(md: string): ParsedReport {
|
|
const lines = md.split(/\r?\n/);
|
|
const titleLine = lines.find((l) => l.startsWith("## "));
|
|
const title = titleLine ? titleLine.replace(/^##\s+/, "") : "CIPC Report";
|
|
const titleIdx = titleLine ? lines.indexOf(titleLine) : -1;
|
|
|
|
// Find section starts ("### ").
|
|
const sectionStarts: { idx: number; title: string }[] = [];
|
|
lines.forEach((l, i) => {
|
|
const m = l.match(/^###\s+(.*)$/);
|
|
if (m) sectionStarts.push({ idx: i, title: m[1].trim() });
|
|
});
|
|
|
|
// Root block: the slice of the body AFTER the title and BEFORE the first
|
|
// "### " header. The Enterprise No. script leaves this empty (everything is
|
|
// wrapped in `### ` blocks). The Director ID script prints the title, an
|
|
// italic summary, and a single table with no `### ` headers — so the root
|
|
// block carries the entire result set. We promote it to a "Results"
|
|
// section only when there are no other sections; otherwise it's just
|
|
// pre-section prose (and lands in `unmatched`).
|
|
const rootStart = titleIdx >= 0 ? titleIdx + 1 : 0;
|
|
const rootEnd = sectionStarts.length > 0 ? sectionStarts[0].idx : lines.length;
|
|
const rootBlock = lines.slice(rootStart, rootEnd);
|
|
|
|
// Build sections.
|
|
const sections: Section[] = [];
|
|
if (sectionStarts.length === 0 && rootBlockHasContent(rootBlock)) {
|
|
sections.push({ title: "Results", body: classifyBlock(rootBlock) });
|
|
}
|
|
for (let s = 0; s < sectionStarts.length; s++) {
|
|
const start = sectionStarts[s].idx + 1;
|
|
const end = s + 1 < sectionStarts.length ? sectionStarts[s + 1].idx : lines.length;
|
|
const block = lines.slice(start, end);
|
|
|
|
sections.push({
|
|
title: sectionStarts[s].title,
|
|
body: classifyBlock(block),
|
|
});
|
|
}
|
|
|
|
// Unmatched: any line outside the sections that we ignored. We don't render
|
|
// this in the report UI (the script prints a small "Done." footer etc.)
|
|
// — but keeping it accessible helps if the parser ever needs debugging.
|
|
const used = new Set<number>();
|
|
sectionStarts.forEach((s, i) => {
|
|
const next = i + 1 < sectionStarts.length ? sectionStarts[i + 1].idx : lines.length;
|
|
for (let j = s.idx; j < next; j++) used.add(j);
|
|
});
|
|
// When we promoted the root block into a "Results" section, also mark its
|
|
// lines as used so they don't double-count in `unmatched`.
|
|
if (sections.length > 0 && sections[0].title === "Results") {
|
|
for (let j = rootStart; j < rootEnd; j++) used.add(j);
|
|
}
|
|
const unmatched = lines.filter((_, i) => !used.has(i));
|
|
|
|
return { title, sections, unmatched };
|
|
}
|
|
|
|
function rootBlockHasContent(block: string[]): boolean {
|
|
// At least one non-blank line that isn't a pure metadata footer (e.g. the
|
|
// .py's "Done." marker) — classifyBlock will decide what shape it is.
|
|
return block.some((l) => l.trim() !== "");
|
|
}
|
|
|
|
function classifyBlock(block: string[]): SectionContent {
|
|
// Find the first significant line, skipping blanks AND leading italic-style
|
|
// summary lines ("_..._") that the .py uses for metadata like "Searches
|
|
// Conducted Today: 23 · 4 matches". Without this, a table preceded by a
|
|
// single italic line would fall through to the "text" branch and be lost.
|
|
let i = 0;
|
|
while (i < block.length) {
|
|
const t = block[i].trim();
|
|
if (t === "") { i++; continue; }
|
|
if (/^_[^_].*[^_]_$|^_[^_]+_$/.test(t)) { i++; continue; }
|
|
break;
|
|
}
|
|
if (i >= block.length) return { kind: "text", lines: [] };
|
|
|
|
// Detect table: line starts with "|"
|
|
if (block[i].trim().startsWith("|")) {
|
|
return parseTable(block);
|
|
}
|
|
|
|
// Detect key-value list: lines start with "- Label:"
|
|
if (block[i].trim().startsWith("- ")) {
|
|
return parseKvList(block);
|
|
}
|
|
|
|
// Fallback: plain text block.
|
|
return { kind: "text", lines: block };
|
|
}
|
|
|
|
function parseTable(block: string[]): SectionContent {
|
|
const rows: string[][] = [];
|
|
let header: string[] | null = null;
|
|
let bodyStartIdx = 0;
|
|
for (let i = 0; i < block.length; i++) {
|
|
const raw = block[i].trim();
|
|
if (!raw.startsWith("|")) continue;
|
|
const cells = raw.split("|").slice(1, -1).map((c) => c.trim());
|
|
// Skip the alignment row (all dashes / colons)
|
|
if (cells.every((c) => /^[-:]+$/.test(c))) {
|
|
bodyStartIdx = i + 1;
|
|
continue;
|
|
}
|
|
if (header === null) {
|
|
header = cells;
|
|
} else {
|
|
rows.push(cells);
|
|
}
|
|
}
|
|
return {
|
|
kind: "table",
|
|
columns: header ?? [],
|
|
rows,
|
|
};
|
|
}
|
|
|
|
function parseKvList(block: string[]): SectionContent {
|
|
const rows: [string, string][] = [];
|
|
let i = 0;
|
|
while (i < block.length) {
|
|
const line = block[i].trim();
|
|
if (!line.startsWith("- ")) {
|
|
i++;
|
|
continue;
|
|
}
|
|
const m = line.match(/^-\s+([^:]+?):\s*(.*)$/);
|
|
if (m) {
|
|
rows.push([m[1].trim(), m[2].trim()]);
|
|
}
|
|
i++;
|
|
}
|
|
return { kind: "kv", rows };
|
|
}
|
|
|
|
// ===================================================================
|
|
// MAIN COMPONENT
|
|
// ===================================================================
|
|
|
|
export default function CipcExplorer() {
|
|
const params = useSearchParams();
|
|
const initialKind: SearchKind =
|
|
params.get("kind") === "director_id_no" ? "director_id_no" : "enterprise_no";
|
|
const initialQuery = params.get("q") || "";
|
|
|
|
const [data, setData] = useState<Record<SearchKind, SearchResponse | null>>({
|
|
enterprise_no: null,
|
|
director_id_no: null,
|
|
});
|
|
// When the cached entry was saved — used to show a "cached Xh ago" pill.
|
|
// null when the report is fresh from this session.
|
|
const [savedAt, setSavedAt] = useState<Record<SearchKind, number | null>>({
|
|
enterprise_no: null,
|
|
director_id_no: null,
|
|
});
|
|
const [inputs, setInputs] = useState<Record<SearchKind, string>>({
|
|
enterprise_no: initialKind === "enterprise_no" ? initialQuery : "",
|
|
director_id_no: initialKind === "director_id_no" ? initialQuery : "",
|
|
});
|
|
const [loading, setLoading] = useState<Record<SearchKind, boolean>>({
|
|
enterprise_no: false,
|
|
director_id_no: false,
|
|
});
|
|
|
|
// Hydrate from localStorage on mount. Also drop any pre-v2 (missing
|
|
// collection_matches) entries so we never re-display the stale "UNKNOWN"
|
|
// pills the user just hit.
|
|
useEffect(() => {
|
|
purgeLegacyCache();
|
|
(["enterprise_no", "director_id_no"] as SearchKind[]).forEach((kind) => {
|
|
const cached = readCache(kind);
|
|
if (cached) {
|
|
setData((d) => ({ ...d, [kind]: cached.data }));
|
|
setSavedAt((s) => ({ ...s, [kind]: cached.savedAt }));
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
// Auto-fire deep-link if URL has ?q=&kind=
|
|
useEffect(() => {
|
|
if (!initialQuery) return;
|
|
const form = document.getElementById(`cipc-form-${initialKind}`) as HTMLFormElement | null;
|
|
if (form) form.requestSubmit();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
async function runSearch(kind: SearchKind) {
|
|
const q = (inputs[kind] || "").trim();
|
|
if (!q) return;
|
|
setLoading((s) => ({ ...s, [kind]: true }));
|
|
setData((d) => ({ ...d, [kind]: null }));
|
|
setSavedAt((s) => ({ ...s, [kind]: null }));
|
|
try {
|
|
const res = await fetch("/api/cipc/search", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ kind, query: q }),
|
|
});
|
|
const json: SearchResponse = await res.json();
|
|
setData((d) => ({ ...d, [kind]: json }));
|
|
// Persist successful results so the report survives tab navigation.
|
|
// Failed searches are intentionally NOT cached — re-runs may succeed.
|
|
if (json.ok) {
|
|
const now = Date.now();
|
|
writeCache(kind, json);
|
|
setSavedAt((s) => ({ ...s, [kind]: now }));
|
|
} else {
|
|
clearCache(kind);
|
|
}
|
|
} catch (err: any) {
|
|
setData((d) => ({
|
|
...d,
|
|
[kind]: { ok: false, error: err?.message || "Network error" },
|
|
}));
|
|
clearCache(kind);
|
|
} finally {
|
|
setLoading((s) => ({ ...s, [kind]: false }));
|
|
}
|
|
}
|
|
|
|
function onSubmit(kind: SearchKind, e: React.FormEvent) {
|
|
e.preventDefault();
|
|
runSearch(kind);
|
|
}
|
|
|
|
function onClear(kind: SearchKind) {
|
|
setData((d) => ({ ...d, [kind]: null }));
|
|
setSavedAt((s) => ({ ...s, [kind]: null }));
|
|
clearCache(kind);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<SearchCard
|
|
kind="enterprise_no"
|
|
loading={loading.enterprise_no}
|
|
result={data.enterprise_no}
|
|
cachedAt={savedAt.enterprise_no}
|
|
input={inputs.enterprise_no}
|
|
onInputChange={(v) => setInputs((s) => ({ ...s, enterprise_no: v }))}
|
|
onSubmit={(e) => onSubmit("enterprise_no", e)}
|
|
onClear={() => onClear("enterprise_no")}
|
|
/>
|
|
<SearchCard
|
|
kind="director_id_no"
|
|
loading={loading.director_id_no}
|
|
result={data.director_id_no}
|
|
cachedAt={savedAt.director_id_no}
|
|
input={inputs.director_id_no}
|
|
onInputChange={(v) => setInputs((s) => ({ ...s, director_id_no: v }))}
|
|
onSubmit={(e) => onSubmit("director_id_no", e)}
|
|
onClear={() => onClear("director_id_no")}
|
|
/>
|
|
</div>
|
|
|
|
<PrintStyles />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===================================================================
|
|
// SEARCH CARD
|
|
// ===================================================================
|
|
|
|
interface SearchCardProps {
|
|
kind: SearchKind;
|
|
loading: boolean;
|
|
result: SearchResponse | null;
|
|
cachedAt: number | null;
|
|
input: string;
|
|
onInputChange: (v: string) => void;
|
|
onSubmit: (e: React.FormEvent) => void;
|
|
onClear: () => void;
|
|
}
|
|
|
|
function SearchCard({
|
|
kind,
|
|
loading,
|
|
result,
|
|
cachedAt,
|
|
input,
|
|
onInputChange,
|
|
onSubmit,
|
|
onClear,
|
|
}: SearchCardProps) {
|
|
return (
|
|
<section
|
|
data-cipc-card={kind}
|
|
className="rounded-md border border-border bg-surface shadow-sm overflow-hidden"
|
|
>
|
|
<header className="px-6 py-4 border-b border-border bg-surface-warm">
|
|
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
|
<h2 className="text-xl font-bold tracking-tight text-fg">
|
|
{ACTION_LABELS[kind]}
|
|
</h2>
|
|
{loading ? (
|
|
<StatusPill tone="busy">Searching CIPC…</StatusPill>
|
|
) : result ? (
|
|
<StatusPill tone={result.ok ? "ok" : "err"}>
|
|
{result.ok ? "Complete" : "Failed"}
|
|
</StatusPill>
|
|
) : (
|
|
<StatusPill tone="idle">Ready</StatusPill>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted mt-1.5 leading-relaxed">{ACCENT[kind]}</p>
|
|
</header>
|
|
|
|
<form
|
|
id={`cipc-form-${kind}`}
|
|
onSubmit={onSubmit}
|
|
className="px-6 py-5 space-y-3 border-b border-border"
|
|
>
|
|
<label
|
|
htmlFor={`input-${kind}`}
|
|
className="block text-[11px] uppercase tracking-wider font-semibold text-muted"
|
|
>
|
|
{kind === "enterprise_no" ? "Enterprise No. or company name" : "Director ID No."}
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
id={`input-${kind}`}
|
|
type="text"
|
|
value={input}
|
|
onChange={(e) => onInputChange(e.target.value)}
|
|
placeholder={PLACEHOLDERS[kind]}
|
|
className="flex-1 px-3.5 py-2.5 rounded-md border border-border bg-surface text-fg text-sm placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
|
required
|
|
disabled={loading}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="px-5 py-2.5 rounded-md bg-accent text-accent-on font-semibold text-sm hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
|
>
|
|
{loading ? (
|
|
<span className="inline-flex items-center gap-2">
|
|
<Spinner /> Working…
|
|
</span>
|
|
) : (
|
|
"Search"
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{result && (
|
|
<div className="px-6 py-5 bg-surface">
|
|
{result.ok ? (
|
|
<ReportView result={result} cachedAt={cachedAt} onClear={onClear} />
|
|
) : (
|
|
<ErrorView result={result} />
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ===================================================================
|
|
// REPORT VIEW (the polished output)
|
|
// ===================================================================
|
|
|
|
function ReportView({
|
|
result,
|
|
cachedAt,
|
|
onClear,
|
|
}: {
|
|
result: SearchResponse;
|
|
cachedAt: number | null;
|
|
onClear: () => void;
|
|
}) {
|
|
const parsed = useMemo(() => parseReport(result.markdown ?? ""), [result.markdown]);
|
|
const now = useNow();
|
|
const kind: SearchKind = result.search_kind ?? "enterprise_no";
|
|
|
|
// Backend-supplied enrichment (or empty when PB was unreachable).
|
|
const serverMatches = result.collection_matches ?? {};
|
|
// Local optimistic adds — added through this modal during the session,
|
|
// so the row flips to "In collection" without needing a re-search.
|
|
const [addedOverrides, setAddedOverrides] = useState<Record<string, CollectionMatch>>({});
|
|
const collectionMatches = useMemo(
|
|
() => ({ ...serverMatches, ...addedOverrides }),
|
|
[serverMatches, addedOverrides]
|
|
);
|
|
|
|
// Locate the table in the parsed report that lists director-search results.
|
|
const directorTableSection = useMemo(() => {
|
|
if (kind !== "director_id_no") return null;
|
|
return (
|
|
parsed.sections.find(
|
|
(s) =>
|
|
s.body.kind === "table" &&
|
|
(s.body.columns[0] ?? "").trim().toLowerCase() === "enterprise number"
|
|
) ?? null
|
|
);
|
|
}, [parsed, kind]);
|
|
const directorRows: string[][] =
|
|
directorTableSection?.body.kind === "table"
|
|
? directorTableSection.body.rows
|
|
: [];
|
|
|
|
// For Enterprise No. reports we render KV sections (COMPANY DETAILS,
|
|
// OTHER DETAILS) as inline diff tables, and the 3 multi-row tables
|
|
// (DIRECTOR DETAILS, FILED ANNUAL RETURNS, OUTSTANDING ANNUAL RETURNS) as
|
|
// table diffs. Match them by title prefix so the .py can evolve without
|
|
// us rewriting the renderer. Sections with no diff rule fall back to the
|
|
// default SectionBlock rendering.
|
|
const kvSections = useMemo(() => {
|
|
if (kind !== "enterprise_no") return [] as Section[];
|
|
return parsed.sections.filter(
|
|
(s) => s.body.kind === "kv" && hasAnyMappingForKv(s.title)
|
|
);
|
|
}, [parsed, kind]);
|
|
const tableSections = useMemo(() => {
|
|
if (kind !== "enterprise_no") return [] as Section[];
|
|
return parsed.sections.filter(
|
|
(s) => s.body.kind === "table" && getTableDiffRule(s.title)
|
|
);
|
|
}, [parsed, kind]);
|
|
|
|
// Enterprise No. reports: pull the canonical K-number from the title.
|
|
const enterpriseNo = useMemo(() => {
|
|
if (kind !== "enterprise_no") return null;
|
|
const m = parsed.title.match(/\(([KMB]\d{10,12})\)/);
|
|
return m ? m[1].toUpperCase() : null;
|
|
}, [parsed.title, kind]);
|
|
const enterpriseName = useMemo(() => {
|
|
if (kind !== "enterprise_no" || !parsed.title) return null;
|
|
// Title looks like "Company: <NAME> (Kxxxxx)" — strip both ends.
|
|
const stripped = parsed.title
|
|
.replace(/^Company:\s*/i, "")
|
|
.replace(/\s*\([KMB]\d{10,12}\)\s*$/, "")
|
|
.trim();
|
|
return stripped || null;
|
|
}, [parsed.title, kind]);
|
|
|
|
// Pre-fill payload for the Add Company modal. Director ID rows provide
|
|
// every column; Enterprise No. pulls name from the title.
|
|
const [adding, setAdding] = useState<AddCompanyPreFill | null>(null);
|
|
|
|
// Local override for the PB record. Set when the user applies a diff
|
|
// update via CompanyDetailsWithDiff; takes precedence over
|
|
// `result.pb_record` so the merged COMPANY DETAILS table re-renders with
|
|
// the new PB values without re-hitting CIPC.
|
|
const [pbRecordOverride, setPbRecordOverride] = useState<Record<string, any> | null>(
|
|
null
|
|
);
|
|
const livePbRecord = pbRecordOverride ?? result.pb_record ?? null;
|
|
|
|
return (
|
|
<article
|
|
data-cipc-report
|
|
className="rounded-md border border-border bg-surface text-fg overflow-hidden"
|
|
>
|
|
{/* ── Cover banner ──────────────────────────────────────────── */}
|
|
<header className="px-7 py-5 bg-surface-warm border-b border-border flex items-start justify-between gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<p className="text-[10px] uppercase tracking-[0.18em] font-bold text-accent">
|
|
CIPC BizProfile Report
|
|
</p>
|
|
{cachedAt !== null && (
|
|
<span
|
|
title={`Stored ${new Date(cachedAt).toLocaleString("en-ZA")} · cleared on Clear or new search`}
|
|
className="no-print inline-flex items-center gap-1 px-1.5 py-0.5 rounded-pill bg-amber-50 border border-amber-200 text-amber-800 text-[10px] font-semibold uppercase tracking-wider"
|
|
>
|
|
<PinIcon />
|
|
Cached {ageLabel(cachedAt)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<h3 className="text-lg font-bold tracking-tight mt-1">{parsed.title}</h3>
|
|
{parsed.subtitle && (
|
|
<p className="text-xs text-muted mt-0.5">{parsed.subtitle}</p>
|
|
)}
|
|
</div>
|
|
<div className="no-print shrink-0 flex items-center gap-2">
|
|
<button
|
|
onClick={onClear}
|
|
title="Clear cached report"
|
|
type="button"
|
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border border-border bg-surface text-muted text-xs font-semibold hover:text-red-700 hover:border-red-200 transition-colors"
|
|
>
|
|
<ClearIcon />
|
|
Clear
|
|
</button>
|
|
<button
|
|
onClick={() => window.print()}
|
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-surface text-fg text-xs font-semibold hover:bg-accent hover:text-accent-on hover:border-accent transition-colors"
|
|
type="button"
|
|
>
|
|
<PrintIcon />
|
|
Print / Save PDF
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* ── Metadata strip ────────────────────────────────────────── */}
|
|
<dl className="px-7 py-3 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-x-6 gap-y-2 text-xs border-b border-border bg-surface">
|
|
<MetaCell label="Query" value={result.query ?? "—"} mono />
|
|
{result.resolved_query && result.resolved_query !== result.query && (
|
|
<MetaCell
|
|
label="Resolved"
|
|
value={result.resolved_query}
|
|
mono
|
|
accent
|
|
/>
|
|
)}
|
|
<MetaCell
|
|
label="Duration"
|
|
value={
|
|
result.duration_ms !== undefined
|
|
? `${(result.duration_ms / 1000).toFixed(1)}s`
|
|
: "—"
|
|
}
|
|
/>
|
|
<MetaCell
|
|
label="Counter"
|
|
value={result.counter !== undefined ? `${result.counter} / 100` : "—"}
|
|
/>
|
|
<MetaCell label="Generated" value={now} />
|
|
{/* Enterprise No. only: collection status lives inline as a 6th cell. */}
|
|
{kind === "enterprise_no" && enterpriseNo && enterpriseName && (
|
|
<div className="col-span-2 sm:col-span-1">
|
|
<dt className="text-[10px] uppercase tracking-wider font-semibold text-muted">
|
|
Collection
|
|
</dt>
|
|
<dd className="mt-0.5 flex items-center gap-2 flex-wrap">
|
|
<InCollectionPill match={collectionMatches[enterpriseNo]} />
|
|
<CollectionRowAction
|
|
entNo={enterpriseNo}
|
|
match={collectionMatches[enterpriseNo]}
|
|
onAdd={() =>
|
|
setAdding({
|
|
enterprise_no: enterpriseNo,
|
|
enterprise_name: enterpriseName,
|
|
enterprise_status: result.cipc_fields?.["Status"] ?? "",
|
|
enterprise_type: result.cipc_fields?.["Enterprise Type"] ?? "",
|
|
registration_date: (() => {
|
|
const raw = result.cipc_fields?.["Registration Date"] ?? "";
|
|
const m = raw.match(/^(\d{4})[\/\-](\d{2})[\/\-](\d{2})$/);
|
|
return m ? `${m[1]}-${m[2]}-${m[3]}` : "";
|
|
})(),
|
|
physical_address: result.cipc_fields?.["Physical Address"] ?? "",
|
|
postal_address: result.cipc_fields?.["Postal Address"] ?? "",
|
|
source: `cipc_enterprise_search:${result.query ?? ""}`,
|
|
})
|
|
}
|
|
/>
|
|
</dd>
|
|
</div>
|
|
)}
|
|
</dl>
|
|
|
|
{/* ── Sections ─────────────────────────────────────────────── */}
|
|
<div className="px-7 py-5 space-y-5 bg-surface">
|
|
{parsed.sections.length === 0 && (
|
|
<p className="text-sm text-muted italic">No structured data returned.</p>
|
|
)}
|
|
{parsed.sections.map((section, idx) => {
|
|
// Director ID: the "Results" table becomes a 5-column table with
|
|
// an in-collection pill + action inline.
|
|
if (
|
|
kind === "director_id_no" &&
|
|
section === directorTableSection &&
|
|
section.body.kind === "table"
|
|
) {
|
|
return (
|
|
<DirectorResultsTableWithCollection
|
|
key={idx}
|
|
section={section}
|
|
matches={collectionMatches}
|
|
onAdd={(row) =>
|
|
setAdding({
|
|
enterprise_no: row[0],
|
|
enterprise_name: row[1] ?? "",
|
|
enterprise_status: row[2] ?? "",
|
|
director_status: row[3] ?? "",
|
|
source: `cipc_director_search:${result.query ?? ""}`,
|
|
})
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
// Enterprise No.: KV blocks become diff tables (COMPANY DETAILS,
|
|
// OTHER DETAILS, etc.). No match → fall through to default.
|
|
if (
|
|
kind === "enterprise_no" &&
|
|
section.body.kind === "kv" &&
|
|
hasAnyMappingForKv(section.title)
|
|
) {
|
|
return (
|
|
<KvSectionWithDiff
|
|
key={idx}
|
|
section={section}
|
|
pbId={livePbRecord?.id ?? null}
|
|
pbRecord={livePbRecord}
|
|
cipcKv={result.cipc_fields ?? {}}
|
|
cipcDirectors={result.cipc_directors ?? []}
|
|
onApplied={(rec) => setPbRecordOverride(rec)}
|
|
/>
|
|
);
|
|
}
|
|
// Enterprise No.: table-shaped sections that have a diff rule
|
|
// (FILED ANNUAL RETURNS, OUTSTANDING ANNUAL RETURNS) render the
|
|
// CIPC table + a single Apply button to overwrite the designated
|
|
// PB field. Falls through to default otherwise.
|
|
const tableRule =
|
|
kind === "enterprise_no" &&
|
|
section.body.kind === "table" &&
|
|
getTableDiffRule(section.title);
|
|
if (tableRule) {
|
|
return (
|
|
<TableSectionWithDiff
|
|
key={idx}
|
|
section={section}
|
|
pbId={livePbRecord?.id ?? null}
|
|
pbRecord={livePbRecord}
|
|
rule={tableRule}
|
|
onApplied={(rec) => setPbRecordOverride(rec)}
|
|
/>
|
|
);
|
|
}
|
|
// DIRECTOR DETAILS — prettier list view (no diff rule today).
|
|
if (
|
|
kind === "enterprise_no" &&
|
|
section.body.kind === "table" &&
|
|
/^director details$/i.test(section.title)
|
|
) {
|
|
return (
|
|
<DirectorsList
|
|
key={idx}
|
|
directors={result.cipc_directors ?? []}
|
|
/>
|
|
);
|
|
}
|
|
return <SectionBlock key={idx} section={section} />;
|
|
})}
|
|
</div>
|
|
|
|
{/* ── Footer ───────────────────────────────────────────────── */}
|
|
<footer className="px-7 py-3 border-t border-border bg-surface-warm flex items-center justify-between text-[11px] text-muted">
|
|
<span>Ysterbaard · Workflow · CIPC BizProfile</span>
|
|
<span className="font-mono">{now}</span>
|
|
</footer>
|
|
|
|
{/* ── Add modal (fixed-positioned overlay) ─────────────────── */}
|
|
{adding && (
|
|
<AddCompanyModal
|
|
prefill={adding}
|
|
onClose={() => setAdding(null)}
|
|
onSaved={(saved) => {
|
|
// Optimistic flip: the row now shows "in collection" without
|
|
// needing a re-search of the upstream CIPC API (which would burn
|
|
// another search counter credit).
|
|
setAddedOverrides((o) => ({
|
|
...o,
|
|
[saved.enterprise_no.toUpperCase()]: {
|
|
in_collection: true,
|
|
pb_id: saved.id,
|
|
pb_name: saved.enterprise_name,
|
|
pb_status: "",
|
|
},
|
|
}));
|
|
setAdding(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
// ===================================================================
|
|
// COLLECTION STATUS (per kind)
|
|
// ===================================================================
|
|
|
|
function summariseMatches(matches: CollectionMatch[]) {
|
|
let inCollection = 0;
|
|
let missing = 0;
|
|
for (const m of matches) {
|
|
if (m?.in_collection) inCollection++;
|
|
else missing++;
|
|
}
|
|
return { inCollection, missing };
|
|
}
|
|
|
|
/**
|
|
* Replaces the standard DataTable for the Director ID "Results" section.
|
|
* Renders the original 4 BizPortal columns PLUS a 5th "In your collection"
|
|
* column whose cells contain the in-collection pill and the per-row action
|
|
* (Open in PB ↗ for existing rows, + Add for missing ones, "—" when PB
|
|
* didn't return data). Keeps the summary line "X of Y already · Z new"
|
|
* immediately above the table so the user sees totals without scrolling.
|
|
*/
|
|
function DirectorResultsTableWithCollection({
|
|
section,
|
|
matches,
|
|
onAdd,
|
|
}: {
|
|
section: Section;
|
|
matches: Record<string, CollectionMatch>;
|
|
onAdd: (row: string[]) => void;
|
|
}) {
|
|
if (section.body.kind !== "table") return null;
|
|
const cols = section.body.columns;
|
|
const rows = section.body.rows;
|
|
|
|
const items = rows.map((r) => {
|
|
const entNo = (r[0] ?? "").trim().toUpperCase();
|
|
return { entNo, row: r, match: matches[entNo] };
|
|
});
|
|
const summary = summariseMatches(
|
|
items.map((it) => it.match ?? { in_collection: false })
|
|
);
|
|
|
|
return (
|
|
<section data-cipc-collection>
|
|
<div className="flex items-baseline justify-between gap-3 flex-wrap mb-2">
|
|
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted">
|
|
Results
|
|
</h4>
|
|
<p className="text-[11px] text-muted">
|
|
<span className="font-semibold text-emerald-700">
|
|
{summary.inCollection}
|
|
</span>{" "}
|
|
of {items.length} already in your collection ·{" "}
|
|
<span className="font-semibold text-amber-700">{summary.missing}</span>{" "}
|
|
new
|
|
</p>
|
|
</div>
|
|
<div className="rounded-md border border-border overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-surface-warm border-b border-border">
|
|
{/* Column order: BizPortal's 4 columns + "In your collection"
|
|
interleaved between Enterprise Name and Enterprise Status. */}
|
|
<th className="text-left px-4 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
|
{cols[0]}
|
|
</th>
|
|
<th className="text-left px-4 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
|
{cols[1]}
|
|
</th>
|
|
<th className="text-left px-4 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
|
In your collection
|
|
</th>
|
|
<th className="text-left px-4 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
|
{cols[2]}
|
|
</th>
|
|
<th className="text-left px-4 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
|
{cols[3]}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((it, i) => (
|
|
<tr
|
|
key={it.entNo}
|
|
className={
|
|
"border-t border-border align-top " +
|
|
(i % 2 === 0 ? "bg-surface" : "bg-surface-warm")
|
|
}
|
|
>
|
|
{/* K-number (col 0) — never wrap, mono. */}
|
|
<td className="px-4 py-2 text-fg align-top font-mono text-[13px] whitespace-nowrap">
|
|
{it.row[0]}
|
|
</td>
|
|
{/* Enterprise Name (col 1) — wraps naturally. */}
|
|
<td className="px-4 py-2 text-fg align-top">
|
|
{it.row[1]}
|
|
</td>
|
|
{/* In your collection — interleaved. */}
|
|
<td className="px-4 py-2 text-fg align-top">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<InCollectionPill match={it.match} />
|
|
<CollectionRowAction
|
|
entNo={it.entNo}
|
|
match={it.match}
|
|
onAdd={() => onAdd(it.row)}
|
|
/>
|
|
</div>
|
|
</td>
|
|
{/* Enterprise Status (col 2). */}
|
|
<td className="px-4 py-2 text-fg align-top">
|
|
{it.row[2]}
|
|
</td>
|
|
{/* Director Status (col 3). */}
|
|
<td className="px-4 py-2 text-fg align-top">
|
|
{it.row[3]}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ===================================================================
|
|
// COMPANY DETAILS WITH DIFF (Enterprise No., integrated into main report)
|
|
// ===================================================================
|
|
|
|
/**
|
|
* Renders any KV section (e.g. COMPANY DETAILS, OTHER DETAILS) as a
|
|
* side-by-side table where each row interleaves the CIPC value (left, from
|
|
* the parsed section), the current PB value (middle), and a status pill
|
|
* (right). When PB is empty or values differ, a per-row checkbox appears
|
|
* so the user can pick which to apply via the bottom Apply button. Falls
|
|
* back to the standard KvTable rendering when no PB record is available.
|
|
*
|
|
* Designers note: this used to be a separate "Field differences" block
|
|
* below the parsed sections — SJ asked for it to live IN the main result
|
|
* table so the match column is part of the main read.
|
|
*/
|
|
function KvSectionWithDiff({
|
|
section,
|
|
pbId,
|
|
pbRecord,
|
|
cipcKv,
|
|
cipcDirectors,
|
|
onApplied,
|
|
}: {
|
|
section: Section;
|
|
pbId: string | null;
|
|
pbRecord: Record<string, any> | null;
|
|
cipcKv: Record<string, string>;
|
|
cipcDirectors: Array<{ name: string; id: string }>;
|
|
onApplied: (rec: Record<string, any>) => void;
|
|
}) {
|
|
if (section.body.kind !== "kv") return null;
|
|
const kvRows = section.body.rows;
|
|
const hasPb = !!pbRecord && !!pbId;
|
|
|
|
// Build the lookup tables once per render. We iterate the CIPC KV rows
|
|
// directly and resolve each to a mapping entry (or "no PB field") so
|
|
// every row in the parsed section is shown with a status — including
|
|
// fields like "Enterprise Number" (updatable=false), "Compliance Notice"
|
|
// (no PB field), and "SARS Tax Number" (added by CIPC, missed before).
|
|
const mappingByKey = new Map(FULL_MAPPING.map((m) => [m.cipcKey, m]));
|
|
type RowState =
|
|
| { state: "no_pb_field"; cipcValue: string }
|
|
| { state: "readonly"; cipcValue: string; pbValue: string; differs: boolean }
|
|
| { state: "diff"; cipcValue: string; pbValue: string; field: string; differs: true }
|
|
| { state: "match"; cipcValue: string; pbValue: string; field: string; differs: false };
|
|
|
|
const rowStates: RowState[] = kvRows.map(([k, v]) => {
|
|
const cipcValue = (v ?? "").trim();
|
|
const m = mappingByKey.get(k);
|
|
if (!m) {
|
|
return { state: "no_pb_field", cipcValue };
|
|
}
|
|
const pbValue = (pbRecord?.[m.field] ?? "").toString().trim();
|
|
const cipcNorm = m.transform ? m.transform(cipcValue) : cipcValue;
|
|
const differs =
|
|
m.updatable &&
|
|
cipcNorm.toLowerCase() !== (pbValue || "").toLowerCase();
|
|
if (!m.updatable) {
|
|
return { state: "readonly", cipcValue, pbValue, differs };
|
|
}
|
|
// Split into two returns: the RowState union correlates `state` with
|
|
// `differs`, so a single `state: differs ? "diff" : "match"` infers a
|
|
// "diff" | "match" union that matches neither variant.
|
|
if (differs) {
|
|
return { state: "diff", cipcValue, pbValue, field: m.field, differs: true };
|
|
}
|
|
return { state: "match", cipcValue, pbValue, field: m.field, differs: false };
|
|
});
|
|
|
|
const selectableRowStates = rowStates.filter((r) => r.state === "diff");
|
|
const hasAnyDiff = selectableRowStates.length > 0;
|
|
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [applying, setApplying] = useState(false);
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
|
|
function toggle(field: string) {
|
|
setSelected((s) => {
|
|
const n = new Set(s);
|
|
if (n.has(field)) n.delete(field);
|
|
else n.add(field);
|
|
return n;
|
|
});
|
|
}
|
|
|
|
function toggleAll() {
|
|
setSelected((s) =>
|
|
s.size === selectableRowStates.length
|
|
? new Set()
|
|
: new Set(
|
|
selectableRowStates
|
|
.filter((r): r is Extract<RowState, { state: "diff" }> => r.state === "diff")
|
|
.map((r) => r.field)
|
|
)
|
|
);
|
|
}
|
|
|
|
async function apply() {
|
|
if (!pbId || selected.size === 0) return;
|
|
setErrorMsg(null);
|
|
setApplying(true);
|
|
try {
|
|
const fields: Record<string, string> = {};
|
|
for (const r of selectableRowStates) {
|
|
if (r.state !== "diff") continue;
|
|
if (!selected.has(r.field)) continue;
|
|
if (r.field === "registration_date") {
|
|
const m = r.cipcValue.match(/^(\d{4})[\/\-](\d{2})[\/\-](\d{2})$/);
|
|
fields[r.field] = m ? `${m[1]}-${m[2]}-${m[3]}` : r.cipcValue;
|
|
} else {
|
|
fields[r.field] = r.cipcValue;
|
|
}
|
|
}
|
|
const r = await fetch("/api/cipc/update-company", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ id: pbId, fields }),
|
|
});
|
|
const j = await r.json().catch(() => ({}));
|
|
if (!r.ok || !j.ok) {
|
|
setErrorMsg(j.error || `Update failed (HTTP ${r.status})`);
|
|
setApplying(false);
|
|
return;
|
|
}
|
|
onApplied(j.record);
|
|
setSelected(new Set());
|
|
} catch (e: any) {
|
|
setErrorMsg(e?.message || "Network error");
|
|
} finally {
|
|
setApplying(false);
|
|
}
|
|
}
|
|
|
|
// ── Render ─────────────────────────────────────────────────────────
|
|
// Without a PB record, fall back to the default KvTable — keeps the
|
|
// familiar "Company Details" section shape for first-time additions.
|
|
if (!hasPb) {
|
|
return <KvTable rows={kvRows} />;
|
|
}
|
|
|
|
return (
|
|
<section className="space-y-2.5">
|
|
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
|
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted">
|
|
{section.title}
|
|
<span className="ml-2 normal-case tracking-normal text-muted font-normal text-[10px]">
|
|
vs PocketBase{" "}
|
|
<span className="font-mono">
|
|
#{pbRecord?.id ? pbRecord.id.slice(0, 6) : ""}…
|
|
</span>
|
|
</span>
|
|
</h4>
|
|
<p className="text-[11px] text-muted">
|
|
{!hasAnyDiff ? (
|
|
<>
|
|
<span className="font-semibold text-emerald-700">All fields match</span>{" "}
|
|
your PocketBase record.
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="font-semibold text-amber-700">
|
|
{selectableRowStates.length}
|
|
</span>{" "}
|
|
of {kvRows.length} fields differ — tick to update.
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="rounded-md border border-border overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-surface-warm border-b border-border">
|
|
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap w-8">
|
|
<input
|
|
type="checkbox"
|
|
aria-label="Select all differing fields"
|
|
disabled={!hasAnyDiff}
|
|
checked={hasAnyDiff && selected.size === selectableRowStates.length}
|
|
onChange={toggleAll}
|
|
/>
|
|
</th>
|
|
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
|
Field
|
|
</th>
|
|
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
|
CIPC value
|
|
</th>
|
|
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
|
PocketBase (current)
|
|
</th>
|
|
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
|
Status
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{kvRows.map(([k, v], i) => {
|
|
const rs = rowStates[i];
|
|
const iClass =
|
|
"border-t border-border align-top " +
|
|
(i % 2 === 0 ? "bg-surface" : "bg-surface-warm");
|
|
return (
|
|
<tr key={k} className={iClass}>
|
|
<td className="px-3 py-2 align-top">
|
|
{rs.state === "diff" ? (
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`Update ${k}`}
|
|
checked={selected.has(rs.field)}
|
|
onChange={() => toggle(rs.field)}
|
|
/>
|
|
) : (
|
|
<span className="text-muted text-[11px]">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2 align-top font-semibold text-fg">
|
|
{k}
|
|
</td>
|
|
<td className="px-3 py-2 align-top text-fg break-words">
|
|
{v ? v : <span className="text-muted">—</span>}
|
|
</td>
|
|
<td className="px-3 py-2 align-top text-fg break-words">
|
|
{rs.state === "no_pb_field" ? (
|
|
<span
|
|
className="text-muted italic text-[12px]"
|
|
title="No matching PocketBase field for this CIPC value"
|
|
>
|
|
— no PB equivalent
|
|
</span>
|
|
) : rs.pbValue ? (
|
|
rs.pbValue
|
|
) : (
|
|
<span className="text-muted">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2 align-top">
|
|
{rs.state === "no_pb_field" ? (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border border-border bg-surface text-muted text-[10px] font-semibold uppercase tracking-wider">
|
|
Read-only
|
|
</span>
|
|
) : rs.state === "readonly" ? (
|
|
<span
|
|
className={
|
|
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border text-[10px] font-semibold uppercase tracking-wider " +
|
|
(rs.differs
|
|
? "border-amber-200 bg-amber-50 text-amber-800"
|
|
: "border-emerald-200 bg-emerald-50 text-emerald-700")
|
|
}
|
|
>
|
|
{rs.differs ? "Differs · locked" : <><CheckIcon /> Match</>}
|
|
</span>
|
|
) : rs.state === "diff" ? (
|
|
<DiffStatusPill status="differs" />
|
|
) : (
|
|
<DiffStatusPill status="match" />
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{cipcDirectors.length > 0 && (
|
|
<DirectorsList directors={cipcDirectors} />
|
|
)}
|
|
|
|
{hasAnyDiff && (
|
|
<div className="flex items-center justify-end gap-3 pt-1">
|
|
{errorMsg && (
|
|
<span className="text-[12px] text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1">
|
|
{errorMsg}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={apply}
|
|
disabled={selected.size === 0 || applying}
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-accent text-accent-on text-xs font-bold hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{applying && <Spinner small />}
|
|
{applying
|
|
? "Applying…"
|
|
: `Apply ${selected.size} update${selected.size === 1 ? "" : "s"}`}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
|
|
|
|
interface FieldDiffRow {
|
|
/** PB field name (also the key in pb_record). */
|
|
field: string;
|
|
/** Human label for the column. */
|
|
label: string;
|
|
/** Value CIPC returned for this field (already stringified). */
|
|
cipcValue: string;
|
|
/** Value currently in PB for this field (already stringified). */
|
|
pbValue: string;
|
|
/** "match" — values are equal (case-insensitive after norm).
|
|
* "differs" — values differ and at least one side is non-empty.
|
|
* "empty_pb" — CIPC has a value, PB is empty. Flag and default-check. */
|
|
status: "match" | "differs" | "empty_pb";
|
|
}
|
|
|
|
/**
|
|
* Full mapping from CIPC field labels (as printed in COMPANY DETAILS and
|
|
* OTHER DETAILS) to PB field names. EVERY CIPC field is mapped — fields
|
|
* with no PB equivalent get their mapping omitted and render as
|
|
* "no PB equivalent" rows (visible but no checkbox).
|
|
*
|
|
* `updatable: false` means the field still shows but has no checkbox
|
|
* (e.g. enterprise_no is the PB unique key and must NOT be PATCHed via
|
|
* this flow — changing it would break the unique-index). Treat such rows
|
|
* as read-only context.
|
|
*/
|
|
interface CIPCFieldMapping {
|
|
field: string;
|
|
label: string;
|
|
cipcKey: string;
|
|
updatable: boolean;
|
|
transform?: (v: string) => string;
|
|
}
|
|
|
|
const FULL_MAPPING: CIPCFieldMapping[] = [
|
|
// COMPANY DETAILS block
|
|
{ field: "enterprise_no", label: "Enterprise Number", cipcKey: "Enterprise Number", updatable: false }, // unique key — never PATCH
|
|
{ field: "enterprise_name", label: "Enterprise Name", cipcKey: "Enterprise Name", updatable: true },
|
|
{ field: "enterprise_type", label: "Enterprise Type", cipcKey: "Enterprise Type", updatable: true },
|
|
{ field: "enterprise_status", label: "Status", cipcKey: "Enterprise Status", updatable: true },
|
|
{
|
|
field: "registration_date",
|
|
label: "Registration Date",
|
|
cipcKey: "Registration Date",
|
|
updatable: true,
|
|
// CIPC prints "YYYY/MM/DD"; PB stores "YYYY-MM-DD". Normalise CIPC
|
|
// before comparison so identical dates don't show as spurious diffs.
|
|
transform: (v) => {
|
|
const m = v.match(/^(\d{4})[\/\-](\d{2})[\/\-](\d{2})$/);
|
|
return m ? `${m[1]}-${m[2]}-${m[3]}` : v;
|
|
},
|
|
},
|
|
{ field: "physical_address", label: "Physical Address", cipcKey: "CIPC Physical address", updatable: true },
|
|
{ field: "postal_address", label: "Postal Address", cipcKey: "CIPC Postal address", updatable: true },
|
|
// Compliance Notice has no PB field — omitted on purpose; rendered
|
|
// as "no PB equivalent" by the renderer.
|
|
|
|
// OTHER DETAILS block
|
|
{ field: "sars_tax_number", label: "SARS Tax Number", cipcKey: "SARS Tax Number", updatable: true },
|
|
{ field: "uif_registration_number", label: "UIF Registration Number", cipcKey: "UIF Registration Number", updatable: true },
|
|
{ field: "compensation_fund_registration_number", label: "Compensation Fund Reg #", cipcKey: "Compensation Fund Registration Number", updatable: true },
|
|
];
|
|
|
|
/**
|
|
* Per-section rules for table-shaped CIPC blocks. Each rule maps a section
|
|
* title (case-insensitive prefix match) to a single PB field. The CIPC
|
|
* table rows are serialised via `toCipcValue` (e.g. a CSV-ish string) so
|
|
* the diff against the PB field's existing text is meaningful.
|
|
*
|
|
* Sections without a rule here render as plain DataTable / DirectorsList.
|
|
*/
|
|
interface TableDiffRule {
|
|
/** PB field name to overwrite when user clicks Apply. */
|
|
pbField: string;
|
|
/** Serialize the parsed table rows into a single string for comparison. */
|
|
toCipcValue: (rows: string[][], columns: string[]) => string;
|
|
/** Short human label for the apply button. */
|
|
applyLabel: string;
|
|
/** Optional subtitle shown next to the apply button. */
|
|
note?: string;
|
|
}
|
|
|
|
const TABLE_DIFF_RULES: Array<{ match: RegExp; rule: TableDiffRule }> = [
|
|
{
|
|
// e.g. "2026/AUGUST" joined by " | "
|
|
match: /^outstanding annual returns$/i,
|
|
rule: {
|
|
pbField: "ar_outstanding",
|
|
toCipcValue: (rows) =>
|
|
rows
|
|
.map((r) => [r[0] ?? "", r[1] ?? ""].filter(Boolean).join("/"))
|
|
.filter(Boolean)
|
|
.join(" | "),
|
|
applyLabel: "Overwrite PocketBase `ar_outstanding`",
|
|
note: "CIPC paragraphs rendered as `YYYY/MONTH` joined by ` | `.",
|
|
},
|
|
},
|
|
{
|
|
// e.g. JSON array — preserves year/amount/date for future audit.
|
|
match: /^filed annual returns$/i,
|
|
rule: {
|
|
pbField: "enterprise_history",
|
|
toCipcValue: (rows) =>
|
|
JSON.stringify(
|
|
rows.map((r) => ({
|
|
year: r[0] ?? "",
|
|
amount: r[1] ?? "",
|
|
date_filed: r[2] ?? "",
|
|
})),
|
|
null,
|
|
0
|
|
),
|
|
applyLabel: "Overwrite PocketBase `enterprise_history`",
|
|
note: "Stored as compact JSON array so future runs can compare.",
|
|
},
|
|
},
|
|
];
|
|
// NOTE: DIRECTOR DETAILS is intentionally NOT in TABLE_DIFF_RULES. PB only
|
|
// has a single `director` relation (maxSelect: 1) and no multi-director
|
|
// field, so there's no clean place to PATCH a full director list. Until
|
|
// a `directors` (many) relation is added to PB the section falls through
|
|
// to the default DataTable + DirectorsList (informational context only).
|
|
|
|
/** Look up the diff rule for a section title (case-insensitive).
|
|
* Returns null when no rule applies — caller should render the default. */
|
|
function getTableDiffRule(title: string): TableDiffRule | null {
|
|
const key = title.trim().toLowerCase();
|
|
for (const { match, rule } of TABLE_DIFF_RULES) {
|
|
if (match.test(key)) return rule;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** True iff any FULL_MAPPING.cipcKey matches one of the kv labels — used
|
|
* by the renderer to decide whether a kv section is "diffable". */
|
|
function hasAnyMappingForKv(title: string): boolean {
|
|
// Title is e.g. "COMPANY DETAILS", "OTHER DETAILS". Always diffable — the
|
|
// mapping is on the row label, not on the section title. We just need to
|
|
// return true so all kv sections render through the diff path.
|
|
void title;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Renders a multi-row CIPC table (DIRECTOR DETAILS, FILED ANNUAL RETURNS,
|
|
* OUTSTANDING ANNUAL RETURNS, etc.) with a SINGLE Apply action that
|
|
* overwrites or appends the designated PB field with a serialised version
|
|
* of the CIPC rows. Per-row diff would be impractical — the PB schema
|
|
* doesn't store per-director or per-AR records.
|
|
*
|
|
* Falls back to the standard DataTable when no PB record is available.
|
|
*/
|
|
function TableSectionWithDiff({
|
|
section,
|
|
pbId,
|
|
pbRecord,
|
|
rule,
|
|
onApplied,
|
|
}: {
|
|
section: Section;
|
|
pbId: string | null;
|
|
pbRecord: Record<string, any> | null;
|
|
rule: TableDiffRule;
|
|
onApplied: (rec: Record<string, any>) => void;
|
|
}) {
|
|
const [applying, setApplying] = useState(false);
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
const [includeInUpdate, setIncludeInUpdate] = useState(false);
|
|
|
|
if (section.body.kind !== "table") return null;
|
|
const columns = section.body.columns;
|
|
const rows = section.body.rows;
|
|
const cipcValue = rule.toCipcValue(rows, columns);
|
|
const hasPb = !!pbRecord && !!pbId;
|
|
const pbValue = hasPb
|
|
? (pbRecord?.[rule.pbField] ?? "").toString().trim()
|
|
: "";
|
|
const differs = hasPb && cipcValue !== pbValue;
|
|
|
|
async function apply() {
|
|
if (!pbId) return;
|
|
setErrorMsg(null);
|
|
setApplying(true);
|
|
try {
|
|
const fields: Record<string, string> = { [rule.pbField]: cipcValue };
|
|
const r = await fetch("/api/cipc/update-company", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ id: pbId, fields }),
|
|
});
|
|
const j = await r.json().catch(() => ({}));
|
|
if (!r.ok || !j.ok) {
|
|
setErrorMsg(j.error || `Update failed (HTTP ${r.status})`);
|
|
setApplying(false);
|
|
return;
|
|
}
|
|
onApplied(j.record);
|
|
setIncludeInUpdate(false);
|
|
} catch (e: any) {
|
|
setErrorMsg(e?.message || "Network error");
|
|
} finally {
|
|
setApplying(false);
|
|
}
|
|
}
|
|
|
|
// Without a PB record we still want to show the CIPC table so the user
|
|
// has it in context, but with no diff control.
|
|
if (!hasPb) {
|
|
return <SectionBlock section={section} />;
|
|
}
|
|
|
|
return (
|
|
<section className="space-y-2.5">
|
|
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
|
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted">
|
|
{section.title}
|
|
<span className="ml-2 normal-case tracking-normal text-muted font-normal text-[10px]">
|
|
vs PocketBase{" "}
|
|
<span className="font-mono">{rule.pbField}</span>
|
|
<span className="font-mono">
|
|
{" "}#{pbRecord?.id ? pbRecord.id.slice(0, 6) : ""}…
|
|
</span>
|
|
</span>
|
|
</h4>
|
|
<p className="text-[11px] text-muted">
|
|
{differs ? (
|
|
<>
|
|
<span className="font-semibold text-amber-700">Differs</span>{" "}
|
|
from current PB value
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="font-semibold text-emerald-700">Match</span>{" "}
|
|
with current PB value
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* CIPC table — original rendering, full fidelity */}
|
|
<DataTable columns={columns} rows={rows} />
|
|
|
|
{/* PB-side comparison prefill */}
|
|
<div className="rounded-md border border-border bg-surface-warm">
|
|
<div className="px-4 py-2 border-b border-border text-[10px] uppercase tracking-wider font-bold text-muted">
|
|
PocketBase{" "}
|
|
<span className="font-mono normal-case tracking-normal">
|
|
{rule.pbField}
|
|
</span>{" "}
|
|
(current)
|
|
</div>
|
|
<pre className="px-4 py-2 text-[12px] text-fg font-mono whitespace-pre-wrap break-words max-h-40 overflow-auto">
|
|
{pbValue || <span className="text-muted italic">(empty)</span>}
|
|
</pre>
|
|
<div className="px-4 py-2 border-t border-border flex items-center justify-between gap-3 flex-wrap">
|
|
<label className="flex items-center gap-2 text-[12px] text-fg">
|
|
<input
|
|
type="checkbox"
|
|
checked={includeInUpdate}
|
|
onChange={(e) => setIncludeInUpdate(e.target.checked)}
|
|
/>
|
|
<span>
|
|
<span className="font-semibold">Apply:</span>{" "}
|
|
{rule.applyLabel}
|
|
</span>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={apply}
|
|
disabled={!includeInUpdate || applying || !differs}
|
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-accent text-accent-on text-[11px] font-bold hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
title={
|
|
!differs
|
|
? "Already in sync — nothing to apply"
|
|
: "Tick the checkbox above to confirm"
|
|
}
|
|
>
|
|
{applying && <Spinner small />}
|
|
{applying ? "Applying…" : "Apply"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{rule.note && (
|
|
<p className="text-[11px] text-muted italic">{rule.note}</p>
|
|
)}
|
|
{errorMsg && (
|
|
<p className="text-[12px] text-red-700 bg-red-50 border border-red-200 rounded px-2.5 py-2">
|
|
{errorMsg}
|
|
</p>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** Build the per-field diff rows from the CIPC KV map and a PB record.
|
|
* Emits one row per mapping entry — including fields where PB has a
|
|
* value and CIPC didn't return one (those show as "no CIPC value" and
|
|
* have nothing actionable from the user side). The renderer decides
|
|
* which rows show a checkbox. */
|
|
function computeDiffRows(
|
|
cipcKv: Record<string, string>,
|
|
pbRecord: Record<string, any> | null,
|
|
): FieldDiffRow[] {
|
|
if (!pbRecord) return [];
|
|
const rows: FieldDiffRow[] = [];
|
|
for (const m of FULL_MAPPING) {
|
|
const cipcRaw = (cipcKv[m.cipcKey] ?? "").trim();
|
|
const cipcNorm = m.transform ? m.transform(cipcRaw) : cipcRaw;
|
|
const pbRaw = (pbRecord?.[m.field] ?? "").toString().trim();
|
|
let status: FieldDiffRow["status"];
|
|
if (!cipcRaw && !pbRaw) status = "match"; // both empty — display only
|
|
else if (!pbRaw) status = "empty_pb";
|
|
else if (!cipcRaw) status = "differs"; // CIPC missing; row still surfaces
|
|
else if (cipcNorm.toLowerCase() === pbRaw.toLowerCase()) status = "match";
|
|
else status = "differs";
|
|
rows.push({
|
|
field: m.field,
|
|
label: m.label,
|
|
cipcValue: cipcRaw,
|
|
pbValue: pbRaw,
|
|
status,
|
|
});
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/** Convert an iso-form date ("YYYY-MM-DD") or "" to the CIPC "YYYY/MM/DD"
|
|
* format used by the .py. The opposite of the transform above. Used when
|
|
* we need to compare against a page that may show one format or the other.
|
|
*/
|
|
function normaliseDateForCipc(v: string): string {
|
|
const m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
return m ? `${m[1]}/${m[2]}/${m[3]}` : v;
|
|
}
|
|
|
|
function FieldDiffTable({
|
|
pbId,
|
|
rows,
|
|
cipcDirectors,
|
|
onApplied,
|
|
}: {
|
|
pbId: string;
|
|
rows: FieldDiffRow[];
|
|
cipcDirectors: Array<{ name: string; id: string }>;
|
|
onApplied: (updatedRecord: Record<string, any>) => void;
|
|
}) {
|
|
const selectable = rows.filter((r) => r.status !== "match");
|
|
const allMatch = selectable.length === 0;
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [applying, setApplying] = useState(false);
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
|
|
function toggle(field: string) {
|
|
setSelected((s) => {
|
|
const n = new Set(s);
|
|
if (n.has(field)) n.delete(field);
|
|
else n.add(field);
|
|
return n;
|
|
});
|
|
}
|
|
|
|
function toggleAll() {
|
|
setSelected((s) =>
|
|
s.size === selectable.length ? new Set() : new Set(selectable.map((r) => r.field))
|
|
);
|
|
}
|
|
|
|
async function apply() {
|
|
if (!rows.length || selected.size === 0) return;
|
|
setErrorMsg(null);
|
|
setApplying(true);
|
|
try {
|
|
// Send the raw CIPC value for every field. For dates, convert
|
|
// "YYYY/MM/DD" to "YYYY-MM-DD" so PB stores ISO dates.
|
|
const fields: Record<string, string> = {};
|
|
for (const r of rows) {
|
|
if (!selected.has(r.field)) continue;
|
|
if (r.field === "registration_date") {
|
|
const m = r.cipcValue.match(/^(\d{4})[\/\-](\d{2})[\/\-](\d{2})$/);
|
|
fields[r.field] = m ? `${m[1]}-${m[2]}-${m[3]}` : r.cipcValue;
|
|
} else {
|
|
fields[r.field] = r.cipcValue;
|
|
}
|
|
}
|
|
const r = await fetch("/api/cipc/update-company", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ id: pbId, fields }),
|
|
});
|
|
const j = await r.json().catch(() => ({}));
|
|
if (!r.ok || !j.ok) {
|
|
setErrorMsg(j.error || `Update failed (HTTP ${r.status})`);
|
|
setApplying(false);
|
|
return;
|
|
}
|
|
// Optimistic UI: hand the new full record back to the parent so the
|
|
// report re-renders with the new PB values (rows that just updated
|
|
// now show ✓ match).
|
|
onApplied(j.record);
|
|
setSelected(new Set());
|
|
} catch (e: any) {
|
|
setErrorMsg(e?.message || "Network error");
|
|
} finally {
|
|
setApplying(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section data-cipc-diff className="space-y-2.5">
|
|
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
|
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted">
|
|
Field differences
|
|
</h4>
|
|
{allMatch ? (
|
|
<p className="text-[11px] text-muted">
|
|
<span className="font-semibold text-emerald-700">All fields match</span>{" "}
|
|
your PocketBase record.
|
|
</p>
|
|
) : (
|
|
<p className="text-[11px] text-muted">
|
|
<span className="font-semibold text-amber-700">{selectable.length}</span>{" "}
|
|
of {rows.length + (rows.length === selectable.length ? 0 : 0)} fields differ{" "}
|
|
— tick the ones you want to update.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="rounded-md border border-border overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-surface-warm border-b border-border">
|
|
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap w-8">
|
|
<input
|
|
type="checkbox"
|
|
aria-label="Select all differing fields"
|
|
disabled={allMatch}
|
|
checked={!allMatch && selected.size === selectable.length}
|
|
onChange={toggleAll}
|
|
/>
|
|
</th>
|
|
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
|
Field
|
|
</th>
|
|
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
|
CIPC value
|
|
</th>
|
|
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
|
PocketBase (current)
|
|
</th>
|
|
<th className="text-left px-3 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap">
|
|
Status
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((r, i) => {
|
|
return (
|
|
<tr
|
|
key={r.field}
|
|
className={
|
|
"border-t border-border align-top " +
|
|
(i % 2 === 0 ? "bg-surface" : "bg-surface-warm")
|
|
}
|
|
>
|
|
<td className="px-3 py-2 align-top">
|
|
{r.status === "match" ? (
|
|
<span className="text-muted text-[11px]">—</span>
|
|
) : (
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`Update ${r.label}`}
|
|
checked={selected.has(r.field)}
|
|
onChange={() => toggle(r.field)}
|
|
/>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2 align-top font-semibold text-fg">
|
|
{r.label}
|
|
</td>
|
|
<td className="px-3 py-2 align-top text-fg break-words">
|
|
{r.cipcValue || <span className="text-muted">—</span>}
|
|
</td>
|
|
<td className="px-3 py-2 align-top text-fg break-words">
|
|
{r.pbValue || <span className="text-muted">—</span>}
|
|
</td>
|
|
<td className="px-3 py-2 align-top">
|
|
<DiffStatusPill status={r.status} />
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{cipcDirectors.length > 0 && (
|
|
<DirectorsList directors={cipcDirectors} />
|
|
)}
|
|
|
|
{!allMatch && (
|
|
<div className="flex items-center justify-end gap-3 pt-1">
|
|
{errorMsg && (
|
|
<span className="text-[12px] text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1">
|
|
{errorMsg}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={apply}
|
|
disabled={selected.size === 0 || applying}
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-accent text-accent-on text-xs font-bold hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{applying && <Spinner small />}
|
|
{applying
|
|
? "Applying…"
|
|
: `Apply ${selected.size} update${selected.size === 1 ? "" : "s"}`}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function DiffStatusPill({
|
|
status,
|
|
}: {
|
|
status: FieldDiffRow["status"];
|
|
}) {
|
|
if (status === "match") {
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border border-emerald-200 bg-emerald-50 text-emerald-700 text-[10px] font-semibold uppercase tracking-wider">
|
|
<CheckIcon /> Match
|
|
</span>
|
|
);
|
|
}
|
|
if (status === "empty_pb") {
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border border-blue-200 bg-blue-50 text-blue-700 text-[10px] font-semibold uppercase tracking-wider">
|
|
Empty in PB
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border border-amber-200 bg-amber-50 text-amber-800 text-[10px] font-semibold uppercase tracking-wider">
|
|
Differs
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function DirectorsList({
|
|
directors,
|
|
}: {
|
|
directors: Array<{ name: string; id: string }>;
|
|
}) {
|
|
return (
|
|
<section data-cipc-directors className="pt-2">
|
|
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted mb-1.5">
|
|
Directors per CIPC ({directors.length})
|
|
</h4>
|
|
<ul className="space-y-1 text-sm">
|
|
{directors.map((d, i) => (
|
|
<li
|
|
key={i}
|
|
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded border border-border bg-surface-warm"
|
|
>
|
|
<span className="font-mono text-[12px] text-fg">{d.name}</span>
|
|
<span className="font-mono text-[12px] text-muted">{d.id}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<p className="text-[10px] text-muted italic mt-1.5">
|
|
PocketBase currently stores only a single director relation per
|
|
company; per-director updates aren't supported in this view.
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function InCollectionPill({ match }: { match: CollectionMatch | undefined }) {
|
|
if (match === undefined) {
|
|
return (
|
|
<span
|
|
title="PocketBase lookup skipped or failed"
|
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill border border-border bg-surface text-muted text-[10px] font-semibold uppercase tracking-wider"
|
|
>
|
|
Unknown
|
|
</span>
|
|
);
|
|
}
|
|
if (match.in_collection) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border border-emerald-200 bg-emerald-50 text-emerald-700 text-[10px] font-semibold uppercase tracking-wider">
|
|
<CheckIcon /> In collection
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-pill border border-amber-200 bg-amber-50 text-amber-800 text-[10px] font-semibold uppercase tracking-wider">
|
|
Not in collection
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function CollectionRowAction({
|
|
entNo,
|
|
match,
|
|
onAdd,
|
|
}: {
|
|
entNo: string;
|
|
match: CollectionMatch | undefined;
|
|
onAdd: () => void;
|
|
}) {
|
|
// Defensive: if PB enrichment didn't return for this row, don't offer a
|
|
// misleading "+ Add" — clicking it could create a duplicate entry. Show
|
|
// a muted dash so the user knows there's no decision to make here.
|
|
if (match === undefined) {
|
|
return (
|
|
<span
|
|
title="PocketBase lookup did not return a result for this row"
|
|
className="inline-flex items-center px-2.5 py-1 text-[11px] text-muted"
|
|
>
|
|
—
|
|
</span>
|
|
);
|
|
}
|
|
if (match.in_collection && match.pb_id) {
|
|
return (
|
|
<a
|
|
href={`https://pb.agent.apphub.co.za/_/#/collections?collection=pbc_3748838150&recordId=${match.pb_id}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-border bg-surface text-fg text-[11px] font-semibold hover:bg-accent hover:text-accent-on hover:border-accent transition-colors"
|
|
>
|
|
Open in PB ↗
|
|
</a>
|
|
);
|
|
}
|
|
// Already added optimistically this session — no PB id known yet, but show
|
|
// a closed / non-actionable pill instead of letting the user add twice.
|
|
if (match.in_collection) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-emerald-200 bg-emerald-50 text-emerald-700 text-[11px] font-semibold">
|
|
Added ✓
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onAdd}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-accent bg-accent text-accent-on text-[11px] font-semibold hover:bg-accent-hover transition-colors"
|
|
title={`Add ${entNo} to your PocketBase collection`}
|
|
>
|
|
+ Add
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ===================================================================
|
|
// ADD COMPANY MODAL
|
|
// ===================================================================
|
|
|
|
interface AddCompanyPreFill {
|
|
enterprise_no: string;
|
|
enterprise_name: string;
|
|
enterprise_status?: string;
|
|
director_status?: string;
|
|
enterprise_type?: string;
|
|
registration_date?: string;
|
|
physical_address?: string;
|
|
postal_address?: string;
|
|
source: string;
|
|
}
|
|
|
|
function AddCompanyModal({
|
|
prefill,
|
|
onClose,
|
|
onSaved,
|
|
}: {
|
|
prefill: AddCompanyPreFill;
|
|
onClose: () => void;
|
|
onSaved: (saved: { id: string; enterprise_no: string; enterprise_name: string }) => void;
|
|
}) {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const defaultNotes = `Added via CIPC search from the Workflow CIPC tab on ${today}.`;
|
|
|
|
const [displayName, setDisplayName] = useState(prefill.enterprise_name);
|
|
const [enterpriseType, setEnterpriseType] = useState(prefill.enterprise_type ?? "");
|
|
const [clientStatus, setClientStatus] = useState("non_client");
|
|
const [registrationDate, setRegistrationDate] = useState(prefill.registration_date ?? "");
|
|
const [physicalAddress, setPhysicalAddress] = useState(prefill.physical_address ?? "");
|
|
const [postalAddress, setPostalAddress] = useState(prefill.postal_address ?? "");
|
|
const [notes, setNotes] = useState(defaultNotes);
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
|
|
// Close on Escape
|
|
useEffect(() => {
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === "Escape" && !submitting) onClose();
|
|
}
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [onClose, submitting]);
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSubmitting(true);
|
|
setErrorMsg(null);
|
|
try {
|
|
const r = await fetch("/api/cipc/add-company", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
enterprise_no: prefill.enterprise_no,
|
|
enterprise_name: displayName.trim() || prefill.enterprise_name,
|
|
enterprise_status: prefill.enterprise_status,
|
|
director_status: prefill.director_status,
|
|
enterprise_type: enterpriseType.trim() || undefined,
|
|
registration_date: registrationDate.trim() || undefined,
|
|
physical_address: physicalAddress.trim() || undefined,
|
|
postal_address: postalAddress.trim() || undefined,
|
|
client_status: clientStatus,
|
|
notes: notes.trim() || undefined,
|
|
source: prefill.source,
|
|
}),
|
|
});
|
|
const j = await r.json().catch(() => ({}));
|
|
if (r.status === 201 && j.ok) {
|
|
onSaved({
|
|
id: j.id,
|
|
enterprise_no: j.enterprise_no ?? prefill.enterprise_no,
|
|
enterprise_name: j.enterprise_name ?? displayName,
|
|
});
|
|
return;
|
|
}
|
|
if (r.status === 409) {
|
|
setErrorMsg(
|
|
(j.existing_name
|
|
? `"${j.existing_name}" is already in your collection. `
|
|
: `${prefill.enterprise_no} is already in your collection. `) +
|
|
`Refreshing report…`
|
|
);
|
|
// Treat 409 as an optimistic success — close + flip row.
|
|
onSaved({
|
|
id: j.existing_id ?? "unknown",
|
|
enterprise_no: prefill.enterprise_no,
|
|
enterprise_name: j.existing_name ?? displayName,
|
|
});
|
|
return;
|
|
}
|
|
setErrorMsg(j.error || `Save failed (HTTP ${r.status})`);
|
|
} catch (err: any) {
|
|
setErrorMsg(err?.message || "Network error");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={`Add ${prefill.enterprise_no} to your collection`}
|
|
className="fixed inset-0 z-50 no-print flex items-center justify-center p-4 bg-black/40"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget && !submitting) onClose();
|
|
}}
|
|
>
|
|
<form
|
|
onSubmit={submit}
|
|
className="w-full max-w-lg rounded-lg border border-border bg-surface shadow-2xl overflow-hidden"
|
|
>
|
|
<header className="px-5 py-3 bg-surface-warm border-b border-border">
|
|
<h3 className="text-sm font-bold tracking-tight">
|
|
Add {prefill.enterprise_no} to collection
|
|
</h3>
|
|
<p className="text-[11px] text-muted mt-0.5">
|
|
New PocketBase record in <code className="font-mono">ysterbaard_companies</code>.
|
|
</p>
|
|
</header>
|
|
|
|
<div className="px-5 py-4 space-y-3 text-sm">
|
|
<ModalField label="Enterprise Number" locked>
|
|
<input
|
|
value={prefill.enterprise_no}
|
|
readOnly
|
|
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface-warm text-muted text-[13px] font-mono"
|
|
/>
|
|
</ModalField>
|
|
<ModalField label="Registered Name" locked>
|
|
<input
|
|
value={prefill.enterprise_name}
|
|
readOnly
|
|
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface-warm text-muted text-[13px]"
|
|
/>
|
|
</ModalField>
|
|
{(prefill.enterprise_status || prefill.director_status) && (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<ModalField label="Enterprise Status" locked>
|
|
<input
|
|
value={prefill.enterprise_status ?? ""}
|
|
readOnly
|
|
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface-warm text-muted text-[13px]"
|
|
/>
|
|
</ModalField>
|
|
<ModalField label="Director Status" locked>
|
|
<input
|
|
value={prefill.director_status ?? ""}
|
|
readOnly
|
|
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface-warm text-muted text-[13px]"
|
|
/>
|
|
</ModalField>
|
|
</div>
|
|
)}
|
|
|
|
<ModalField label="Display Name">
|
|
<input
|
|
value={displayName}
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
|
placeholder="The name you use in your records"
|
|
/>
|
|
</ModalField>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<ModalField label="Type">
|
|
<select
|
|
value={enterpriseType}
|
|
onChange={(e) => setEnterpriseType(e.target.value)}
|
|
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
|
>
|
|
<option value="">— select —</option>
|
|
<option value="Private Company">Private Company</option>
|
|
<option value="Public Company">Public Company</option>
|
|
<option value="Close Corporation">Close Corporation</option>
|
|
<option value="Non-Profit Company">Non-Profit Company</option>
|
|
<option value="Co-operative">Co-operative</option>
|
|
<option value="Sole Proprietor">Sole Proprietor</option>
|
|
<option value="Partnership">Partnership</option>
|
|
<option value="Other">Other</option>
|
|
</select>
|
|
</ModalField>
|
|
<ModalField label="Client Status">
|
|
<select
|
|
value={clientStatus}
|
|
onChange={(e) => setClientStatus(e.target.value)}
|
|
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
|
>
|
|
<option value="non_client">Non-client</option>
|
|
<option value="active_client">Active client</option>
|
|
<option value="dormant_client">Dormant client</option>
|
|
<option value="deregistered">Deregistered</option>
|
|
</select>
|
|
</ModalField>
|
|
</div>
|
|
|
|
{/* Pre-filled from CIPC when the + Add was triggered from the
|
|
Enterprise No. search. User can edit before saving. */}
|
|
{prefill.registration_date !== undefined && prefill.registration_date !== "" && (
|
|
<ModalField label="Registration Date">
|
|
<input
|
|
type="date"
|
|
value={prefill.registration_date}
|
|
onChange={(e) => setRegistrationDate(e.target.value)}
|
|
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
|
/>
|
|
</ModalField>
|
|
)}
|
|
{prefill.physical_address !== undefined && prefill.physical_address !== "" && (
|
|
<ModalField label="Physical Address">
|
|
<input
|
|
value={physicalAddress}
|
|
onChange={(e) => setPhysicalAddress(e.target.value)}
|
|
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
|
/>
|
|
</ModalField>
|
|
)}
|
|
{prefill.postal_address !== undefined && prefill.postal_address !== "" && (
|
|
<ModalField label="Postal Address">
|
|
<input
|
|
value={postalAddress}
|
|
onChange={(e) => setPostalAddress(e.target.value)}
|
|
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
|
|
/>
|
|
</ModalField>
|
|
)}
|
|
|
|
<ModalField label="Notes">
|
|
<textarea
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
rows={3}
|
|
className="w-full px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-[13px] focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent resize-none"
|
|
/>
|
|
</ModalField>
|
|
|
|
{errorMsg && (
|
|
<p className="text-[12px] text-red-700 bg-red-50 border border-red-200 rounded px-2.5 py-2">
|
|
{errorMsg}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<footer className="px-5 py-3 border-t border-border bg-surface-warm flex items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
disabled={submitting}
|
|
onClick={onClose}
|
|
className="px-3 py-1.5 rounded-md border border-border bg-surface text-fg text-xs font-semibold hover:bg-surface-warm disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="px-4 py-1.5 rounded-md bg-accent text-accent-on text-xs font-bold hover:bg-accent-hover disabled:opacity-60 inline-flex items-center gap-2"
|
|
>
|
|
{submitting && <Spinner small />}
|
|
{submitting ? "Saving…" : "Save to PocketBase"}
|
|
</button>
|
|
</footer>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ModalField({
|
|
label,
|
|
locked,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
locked?: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<label className="block space-y-1">
|
|
<span className="flex items-center justify-between text-[10px] uppercase tracking-wider font-semibold text-muted">
|
|
{label}
|
|
{locked && (
|
|
<span className="text-[9px] font-mono text-muted normal-case tracking-normal">
|
|
from CIPC
|
|
</span>
|
|
)}
|
|
</span>
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function SectionBlock({ section }: { section: Section }) {
|
|
return (
|
|
<section>
|
|
<h4 className="text-[11px] uppercase tracking-[0.16em] font-bold text-muted mb-2">
|
|
{section.title}
|
|
</h4>
|
|
{section.body.kind === "kv" && <KvTable rows={section.body.rows} />}
|
|
{section.body.kind === "table" && (
|
|
<DataTable columns={section.body.columns} rows={section.body.rows} />
|
|
)}
|
|
{section.body.kind === "text" && (
|
|
<ul className="text-sm space-y-1">
|
|
{section.body.lines.filter((l) => l.trim()).map((l, i) => (
|
|
<li key={i}>{l}</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function KvTable({ rows }: { rows: [string, string][] }) {
|
|
if (rows.length === 0) return null;
|
|
return (
|
|
<div className="rounded-md border border-border overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<tbody>
|
|
{rows.map(([k, v], i) => (
|
|
<tr
|
|
key={i}
|
|
className={
|
|
i % 2 === 0
|
|
? "bg-surface-warm"
|
|
: "bg-surface"
|
|
}
|
|
>
|
|
<th className="text-left px-4 py-2 font-semibold text-fg w-1/3 align-top border-r border-border">
|
|
{k}
|
|
</th>
|
|
<td className="px-4 py-2 text-fg font-mono text-[13px]">{v}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DataTable({ columns, rows }: { columns: string[]; rows: string[][] }) {
|
|
if (rows.length === 0) {
|
|
return <p className="text-sm text-muted italic">No rows.</p>;
|
|
}
|
|
return (
|
|
<div className="rounded-md border border-border overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-surface-warm border-b border-border">
|
|
{columns.map((c, i) => (
|
|
<th
|
|
key={i}
|
|
className="text-left px-4 py-2 text-[11px] uppercase tracking-wider font-bold text-muted whitespace-nowrap"
|
|
>
|
|
{c}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, i) => (
|
|
<tr
|
|
key={i}
|
|
className={
|
|
"border-t border-border " +
|
|
(i % 2 === 0 ? "bg-surface" : "bg-surface-warm")
|
|
}
|
|
>
|
|
{columns.map((_, j) => (
|
|
<td
|
|
key={j}
|
|
className={
|
|
"px-4 py-2 text-fg whitespace-nowrap " +
|
|
(j === 0 ? "font-mono text-[13px]" : "")
|
|
}
|
|
>
|
|
{row[j] ?? ""}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===================================================================
|
|
// ERROR VIEW
|
|
// ===================================================================
|
|
|
|
function ErrorView({ result }: { result: SearchResponse }) {
|
|
return (
|
|
<div className="rounded-md border border-red-200 bg-red-50/60 px-4 py-3 text-sm space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<ErrorIcon />
|
|
<p className="font-semibold text-red-900">Search failed</p>
|
|
</div>
|
|
<p className="font-mono text-xs text-red-900/90">{result.error}</p>
|
|
{result.resolved_query && result.resolved_query !== result.query && (
|
|
<p className="font-mono text-xs text-muted">
|
|
Tried resolved query{" "}
|
|
<code className="bg-surface px-1.5 py-0.5 rounded border border-border">
|
|
{result.resolved_query}
|
|
</code>
|
|
</p>
|
|
)}
|
|
{result.raw && <RawOutputToggle raw={result.raw} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RawOutputToggle({ raw }: { raw: string }) {
|
|
const [open, setOpen] = useState(false);
|
|
return (
|
|
<div className="text-xs pt-1">
|
|
<button
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="text-muted hover:text-fg underline no-print"
|
|
>
|
|
{open ? "Hide" : "Show"} raw stdout
|
|
</button>
|
|
{open && (
|
|
<pre className="mt-2 p-3 rounded bg-surface text-fg font-mono text-[11px] overflow-x-auto whitespace-pre-wrap max-h-96 border border-border">
|
|
{raw}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===================================================================
|
|
// SHARED COMPONENTS
|
|
// ===================================================================
|
|
|
|
function MetaCell({
|
|
label,
|
|
value,
|
|
mono,
|
|
accent,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
mono?: boolean;
|
|
accent?: boolean;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<dt className="text-[10px] uppercase tracking-wider font-semibold text-muted">
|
|
{label}
|
|
</dt>
|
|
<dd
|
|
className={
|
|
"mt-0.5 truncate " +
|
|
(mono ? "font-mono " : "") +
|
|
(accent ? "text-accent font-semibold" : "text-fg")
|
|
}
|
|
title={value}
|
|
>
|
|
{value}
|
|
</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusPill({
|
|
tone,
|
|
children,
|
|
}: {
|
|
tone: "ok" | "err" | "busy" | "idle";
|
|
children: React.ReactNode;
|
|
}) {
|
|
const styles = {
|
|
ok: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
|
err: "bg-red-50 text-red-700 border-red-200",
|
|
busy: "bg-amber-50 text-amber-700 border-amber-200",
|
|
idle: "bg-surface-warm text-muted border-border",
|
|
} as const;
|
|
return (
|
|
<span
|
|
className={
|
|
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-pill border text-[11px] font-semibold uppercase tracking-wider " +
|
|
styles[tone]
|
|
}
|
|
>
|
|
{tone === "ok" && <CheckIcon />}
|
|
{tone === "err" && <ErrorIcon small />}
|
|
{tone === "busy" && <Spinner small />}
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function Spinner({ small }: { small?: boolean }) {
|
|
return (
|
|
<svg
|
|
className={"animate-spin " + (small ? "w-3 h-3" : "w-3.5 h-3.5")}
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" opacity="0.25" />
|
|
<path
|
|
d="M22 12a10 10 0 0 0-10-10"
|
|
stroke="currentColor"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function CheckIcon() {
|
|
return (
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="20 6 9 17 4 12" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ErrorIcon({ small }: { small?: boolean }) {
|
|
return (
|
|
<svg
|
|
width={small ? "11" : "14"}
|
|
height={small ? "11" : "14"}
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.4"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line x1="12" y1="8" x2="12" y2="12" />
|
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function PrintIcon() {
|
|
return (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="6 9 6 2 18 2 18 9" />
|
|
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
|
<rect x="6" y="14" width="12" height="8" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ClearIcon() {
|
|
return (
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
<path d="M10 11v6M14 11v6" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function PinIcon() {
|
|
return (
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M12 2v6" />
|
|
<path d="M9 8h6l-1 6h-4z" />
|
|
<path d="M12 14v8" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function useNow(): string {
|
|
// Stable timestamp captured on first render of the report.
|
|
const [stamp] = useState(() =>
|
|
new Date().toLocaleString("en-ZA", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hour12: false,
|
|
})
|
|
);
|
|
return stamp;
|
|
}
|
|
|
|
// ===================================================================
|
|
// PRINT STYLES — applied only when printing.
|
|
// ===================================================================
|
|
|
|
function PrintStyles() {
|
|
// Use dangerouslySetInnerHTML so React 18 / Next 14 don't escape the raw
|
|
// `"` characters inside the CSS body — escaping causes a hydration
|
|
// mismatch ("does not match server-rendered HTML"). The CSS itself is
|
|
// static and authored by us, so `dangerouslySetInnerHTML` is safe here.
|
|
return (
|
|
<style
|
|
dangerouslySetInnerHTML={{
|
|
__html: `
|
|
@media print {
|
|
body { background: #ffffff !important; }
|
|
header.sticky, nav, [data-od-id="navbar"], .no-print { display: none !important; }
|
|
section.container-x { padding: 0 !important; }
|
|
[data-cipc-card] { box-shadow: none !important; border: 1px solid #e5e7eb !important; page-break-inside: avoid; }
|
|
[data-cipc-report] { box-shadow: none !important; border: 0 !important; }
|
|
[data-cipc-card] header { background: #f8fafc !important; }
|
|
[data-cipc-report] header { background: #f8fafc !important; }
|
|
[data-cipc-report] footer { background: #f8fafc !important; }
|
|
table { page-break-inside: auto; }
|
|
tr { page-break-inside: avoid; page-break-after: auto; }
|
|
thead { display: table-header-group; }
|
|
@page { margin: 14mm 12mm; }
|
|
}
|
|
`,
|
|
}}
|
|
/>
|
|
);
|
|
}
|