// app/bizportal-2/BizPortal2Explorer.tsx // Client component — same UX as the existing /bizportal explorer // but fetches synchronously from the Python sidecar. No polling, // no task queue, no LLM agent. Times the round-trip so the user // can see the speed difference. "use client"; import { useState, useRef, useEffect } from "react"; import { useSearchParams } from "next/navigation"; type SearchType = "id" | "ck"; interface SearchResponse { ok: boolean; results?: any[]; error?: string; log?: string[]; query?: string; type?: string; duration_ms?: number; } export default function BizPortal2Explorer() { const searchParams = useSearchParams(); const initialType = (searchParams.get("type") as SearchType) === "ck" ? "ck" : "id"; const initialQuery = searchParams.get("q") || ""; const [activeTab, setActiveTab] = useState(initialType); const [query, setQuery] = useState(initialQuery); const [loading, setLoading] = useState(false); const [serviceUp, setServiceUp] = useState(null); const [data, setData] = useState(null); const abortRef = useRef(null); // Health check on mount so the user can see immediately if the // Python sidecar is running. useEffect(() => { fetch("/api/bizportal-2/health") .then((r) => r.json()) .then((d) => setServiceUp(!!d.ok && !!d.playwright_ready)) .catch(() => setServiceUp(false)); }, []); // Auto-fire search when arriving from a director/company deep-link. const autoFiredRef = useRef(false); useEffect(() => { if (initialQuery && !autoFiredRef.current) { autoFiredRef.current = true; setTimeout(() => { const form = document.getElementById("bizportal-2-search-form") as HTMLFormElement | null; if (form) form.requestSubmit(); }, 100); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function handleSearch(e: React.FormEvent) { e.preventDefault(); if (!query.trim()) return; setLoading(true); setData(null); const controller = new AbortController(); abortRef.current = controller; try { const res = await fetch("/api/bizportal-2/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: activeTab, query: query.trim() }), signal: controller.signal, }); const json: SearchResponse = await res.json(); setData(json); } catch (err: any) { if (err.name !== "AbortError") { setData({ ok: false, error: err.message || "Network error" }); } } finally { setLoading(false); } } function cancelSearch() { abortRef.current?.abort(); setLoading(false); } return (
{/* ── Tab switcher ──────────────────────────────────────────────── */}
{([["id", "Director ID No."], ["ck", "CK / Enterprise No."]] as [SearchType, string][]).map(([val, label]) => ( ))}
{/* ── Search form ──────────────────────────────────────────────── */}
setQuery(e.target.value)} className="flex-1 px-4 py-3 rounded-md border border-border bg-surface text-fg text-sm placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent" required /> {loading && ( )}
{/* ── Status / Result ──────────────────────────────────────────── */} {data && (
{!data.ok ? (

Search failed

{data.error}

{data.log && data.log.length > 0 && }
) : data.results && data.results.length > 0 ? ( ) : (

No results found.

{data.log && data.log.length > 0 && }
)}
)}
); } function ServiceStatusBanner({ up }: { up: boolean | null }) { if (up === null) { return (
Checking Python service…
); } if (up) { return (
✅ Python sidecar is up on 127.0.0.1:7001 — search will be synchronous.
); } return (

Python sidecar is not reachable.

Start it with:{" "} cd scripts && source .venv/bin/activate && uvicorn bizportal_service:app --port 7001

See scripts/README.md for the first-time setup.

); } function LogBlock({ log }: { log: string[] }) { const [open, setOpen] = useState(false); return (
{open && (
          {log.join("\n")}
        
)}
); } // ── Result display ─────────────────────────────────────────────────────── interface DoneViewProps { results: any[]; activeTab: SearchType; query: string; durationMs?: number; log?: string[]; } function DoneView({ results, activeTab, query, durationMs, log }: DoneViewProps) { const [dbCompanies, setDbCompanies] = useState>(new Map()); const [dbDirectors, setDbDirectors] = useState>(new Map()); const [links, setLinks] = useState>(new Set()); const [loaded, setLoaded] = useState(false); const [selected, setSelected] = useState>(new Set()); const [bulkProcessing, setBulkProcessing] = useState(false); const [bulkMsg, setBulkMsg] = useState(""); function maskId(id: string | undefined): string { if (!id) return ""; const digits = String(id).replace(/\s/g, ""); if (/X/i.test(digits)) return digits; if (digits.length === 13) return digits.slice(0, 6) + "XXXX" + digits.slice(10, 12) + "X"; return digits; } useEffect(() => { if (results.length === 0) return; async function load() { try { const [compRes, dirRes] = await Promise.all([ fetch("/api/pb/records/list?collection=ysterbaard_companies&perPage=5000"), fetch("/api/pb/records/list?collection=ysterbaard_directors&perPage=5000"), ]); const compData = await compRes.json(); const dirData = await dirRes.json(); const cmap = new Map(); (compData.items || []).forEach((c: any) => { if (c.enterprise_no) cmap.set(String(c.enterprise_no).replace(/\s/g, ""), c); }); const dmap = new Map(); (dirData.items || []).forEach((d: any) => { if (d.id_no_masked) dmap.set(d.id_no_masked, d); if (d.id_no) dmap.set(d.id_no, d); }); setDbCompanies(cmap); setDbDirectors(dmap); const linkSet = new Set(); for (const c of results) { const compEntNo = String(c.enterprise_no || "").replace(/\s/g, ""); const compRecord = cmap.get(compEntNo); if (!compRecord) continue; for (const d of (c.directors || [])) { const masked = maskId(d.id_number); const dirRecord = dmap.get(masked) || dmap.get(d.id_number); if (dirRecord) { try { const jres = await fetch(`/api/pb/records/list?collection=ysterbaard_company_directors&perPage=500&filter=${encodeURIComponent(`director='${dirRecord.id}'`)}`); const jdata = await jres.json(); for (const j of (jdata.items || [])) { if (j.company === compRecord.id) { linkSet.add(`${compRecord.id}-${dirRecord.id}`); } } } catch {} } } } setLinks(linkSet); } finally { setLoaded(true); } } load(); }, [results]); function getCompanyRecord(c: any) { return dbCompanies.get(String(c.enterprise_no || "").replace(/\s/g, "")); } function getDirectorRecord(d: any) { return dbDirectors.get(maskId(d.id_number)) || dbDirectors.get(d.id_number); } function isLinked(compRec: any, dirRec: any) { return compRec && dirRec && links.has(`${compRec.id}-${dirRec.id}`); } function toggle(key: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); } function selectAllMissing() { const next = new Set(); for (const c of results) { if (!getCompanyRecord(c)) next.add(`company:${c.enterprise_no}`); for (const d of (c.directors || [])) { if (!getDirectorRecord(d)) next.add(`director:${maskId(d.id_number)}`); } } setSelected(next); } function clearSelection() { setSelected(new Set()); } async function processSelected() { if (selected.size === 0) return; setBulkProcessing(true); setBulkMsg(""); try { const items: any[] = []; for (const c of results) { if (selected.has(`company:${c.enterprise_no}`)) { items.push({ collection: "ysterbaard_companies", fields: { enterprise_no: c.enterprise_no || "", enterprise_name: c.enterprise_name || "", enterprise_type: c.enterprise_type || "", source: "bizportal-2", }, }); } } for (const c of results) { for (const d of (c.directors || [])) { const masked = maskId(d.id_number); if (selected.has(`director:${masked}`)) { items.push({ collection: "ysterbaard_directors", fields: { id_no: masked.replace(/X/gi, "0"), surname: d.surname || "", first_names: d.names || "", director_type: (d.type || "Director").toLowerCase(), source: "bizportal-2", }, }); } } } const res = await fetch("/api/pb/records/bulk-create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items }), }); const data = await res.json(); const [compRes2, dirRes2] = await Promise.all([ fetch("/api/pb/records/list?collection=ysterbaard_companies&perPage=5000"), fetch("/api/pb/records/list?collection=ysterbaard_directors&perPage=5000"), ]); const compData2 = await compRes2.json(); const dirData2 = await dirRes2.json(); const cmap = new Map(); (compData2.items || []).forEach((c: any) => { if (c.enterprise_no) cmap.set(String(c.enterprise_no).replace(/\s/g, ""), c); }); const dmap = new Map(); (dirData2.items || []).forEach((d: any) => { if (d.id_no_masked) dmap.set(d.id_no_masked, d); if (d.id_no) dmap.set(d.id_no, d); }); setDbCompanies(cmap); setDbDirectors(dmap); const linkItems: any[] = []; for (const c of results) { const compRec = cmap.get(String(c.enterprise_no || "").replace(/\s/g, "")); if (!compRec) continue; for (const d of (c.directors || [])) { const masked = maskId(d.id_number); const dirRec = dmap.get(masked); if (!dirRec) continue; if (links.has(`${compRec.id}-${dirRec.id}`)) continue; linkItems.push({ company: compRec.id, director: dirRec.id, director_type: d.type || "Director" }); } } let linkSummary = ""; if (linkItems.length > 0) { const lres = await fetch("/api/pb/records/bulk-link", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: linkItems }), }); const ldata = await lres.json(); const nextLinks = new Set(links); for (const li of linkItems) nextLinks.add(`${li.company}-${li.director}`); setLinks(nextLinks); linkSummary = `, linked ${ldata.linked_count} (${ldata.already_count} already)`; } const failMsg = data.fail_count > 0 ? `, ${data.fail_count} failed` : ""; setBulkMsg(`✅ Added ${data.success_count}${failMsg}${linkSummary}`); setSelected(new Set()); } catch (err: any) { setBulkMsg(`❌ Error: ${err.message}`); } finally { setBulkProcessing(false); } } async function linkOne(compRec: any, dirRec: any, d: any) { const res = await fetch("/api/pb/records/link", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ company: compRec.id, director: dirRec.id, director_type: d.type || "Director" }), }); if (res.ok) { setLinks((prev) => new Set(prev).add(`${compRec.id}-${dirRec.id}`)); setBulkMsg("✅ Linked"); } else { const data = await res.json().catch(() => ({})); setBulkMsg(`❌ Link failed: ${data.error || res.status}`); } } const selectedCount = selected.size; const missingCompanies = results.filter((c) => !getCompanyRecord(c)).length; const missingDirectors = results.flatMap((c) => c.directors || []).filter((d) => !getDirectorRecord(d)).length; const linkableCount = results.reduce((acc, c) => { const compRec = getCompanyRecord(c); if (!compRec) return acc; return acc + (c.directors || []).filter((d: any) => { const dirRec = getDirectorRecord(d); return dirRec && !links.has(`${compRec.id}-${dirRec.id}`); }).length; }, 0); return (
{loaded ? ( <> {results.length} result{results.length === 1 ? "" : "s"} ·{" "} 0 ? "text-yellow-700" : "text-green-700"}> {results.length - missingCompanies}/{results.length} companies in DB {" · "} 0 ? "text-yellow-700" : "text-green-700"}> {missingDirectors} missing director{missingDirectors === 1 ? "" : "s"} {linkableCount > 0 && ( <> {" · "} {linkableCount} linkable pair{linkableCount === 1 ? "" : "s"} )} ) : ( "Loading DB state…" )} {durationMs !== undefined && ( · {durationMs}ms round-trip )}
{selectedCount > 0 ? ( <> {selectedCount} selected ) : ( )}
{bulkMsg && (
{bulkMsg}
)} {log && log.length > 0 && }
{results.map((c, idx) => ( ))}
); } function CompanyResult({ company, maskId, dbCompany, getDirectorRecord, isLinked, links, selected, toggle, onLink, }: { company: any; maskId: (s: string) => string; dbCompany: any; getDirectorRecord: (d: any) => any; isLinked: (compRec: any, dirRec: any) => boolean; links: Set; selected: Set; toggle: (key: string) => void; onLink: (compRec: any, dirRec: any, d: any) => void; }) { const entNo = (company.enterprise_no || "").replace(/\s/g, ""); const entName = company.enterprise_name || "—"; const entStatus = company.enterprise_status || "—"; const directors: any[] = company.directors || []; const inDb = !!dbCompany; const compSelectKey = `company:${company.enterprise_no}`; return (

{entName}

{entStatus}

{entNo}

{inDb ? ( ✅ In DB ) : ( )}

Director Details ({directors.length} director{directors.length === 1 ? "" : "s"})

{directors.length === 0 ? (

No directors found in registry.

) : (
{directors.map((d, i) => { const masked = maskId(d.id_number); const dirRec = getDirectorRecord(d); const inDirDb = !!dirRec; const dirSelectKey = `director:${masked}`; const linked = inDirDb && inDb && isLinked(dbCompany, dirRec); return ( ); })}
ID Number Name(s) Surname Status DB Status Link
{!inDirDb && ( toggle(dirSelectKey)} className="w-4 h-4" /> )} {masked || "—"} {d.names || "—"} {d.surname || "—"} {d.status || "—"} {inDirDb ? ( ✅ In DB ) : ( select to add )} {!inDb ? ( add company first ) : !inDirDb ? ( add director first ) : linked ? ( 🔗 Linked ) : ( )}
)}
); }