WORKFLOW-APP/app/bizportal-2/BizPortal2Explorer.tsx

701 lines
26 KiB
TypeScript
Raw Normal View History

// 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<SearchType>(initialType);
const [query, setQuery] = useState(initialQuery);
const [loading, setLoading] = useState(false);
const [serviceUp, setServiceUp] = useState<boolean | null>(null);
const [data, setData] = useState<SearchResponse | null>(null);
const abortRef = useRef<AbortController | null>(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 (
<div className="max-w-2xl space-y-8">
<ServiceStatusBanner up={serviceUp} />
{/* ── Tab switcher ──────────────────────────────────────────────── */}
<div className="flex gap-1 rounded-md bg-surface-warm p-1 w-fit">
{([["id", "Director ID No."], ["ck", "CK / Enterprise No."]] as [SearchType, string][]).map(([val, label]) => (
<button
key={val}
onClick={() => { setActiveTab(val); setQuery(""); setData(null); }}
className={`px-5 py-2.5 rounded-sm text-sm font-semibold transition-all ${
activeTab === val
? "bg-surface text-fg shadow-sm"
: "text-muted hover:text-fg"
}`}
>
{label}
</button>
))}
</div>
{/* ── Search form ──────────────────────────────────────────────── */}
<form id="bizportal-2-search-form" onSubmit={handleSearch} className="flex flex-col gap-3">
<div className="flex gap-3">
<input
type="text"
inputMode={activeTab === "id" ? "numeric" : "text"}
pattern={activeTab === "id" ? "\\d{13}" : undefined}
placeholder={
activeTab === "id"
? "13-digit SA ID number (e.g. 8709225206083)"
: "CK / Enterprise No. (e.g. 2022/808158/08 or K2022808158)"
}
value={query}
onChange={(e) => 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
/>
<button
type="submit"
disabled={loading}
className="px-6 py-3 rounded-md bg-accent text-accent-on font-semibold text-sm hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? "Searching…" : "Search"}
</button>
{loading && (
<button
type="button"
onClick={cancelSearch}
className="px-4 py-3 rounded-md border border-border text-muted text-sm hover:text-fg transition-colors"
>
Cancel
</button>
)}
</div>
</form>
{/* ── Status / Result ──────────────────────────────────────────── */}
{data && (
<div className="rounded-md border border-border bg-surface p-5 space-y-4">
{!data.ok ? (
<div className="space-y-2">
<p className="text-red-500 text-sm font-semibold">Search failed</p>
<p className="text-sm text-muted">{data.error}</p>
{data.log && data.log.length > 0 && <LogBlock log={data.log} />}
</div>
) : data.results && data.results.length > 0 ? (
<DoneView
results={data.results}
activeTab={activeTab}
query={query}
durationMs={data.duration_ms}
log={data.log}
/>
) : (
<div className="space-y-2">
<p className="text-sm text-muted">No results found.</p>
{data.log && data.log.length > 0 && <LogBlock log={data.log} />}
</div>
)}
</div>
)}
</div>
);
}
function ServiceStatusBanner({ up }: { up: boolean | null }) {
if (up === null) {
return (
<div className="rounded-md border border-border bg-surface-warm p-3 text-xs text-muted">
Checking Python service…
</div>
);
}
if (up) {
return (
<div className="rounded-md border border-green-300 bg-green-50 p-3 text-xs text-green-800">
✅ Python sidecar is up on <span className="font-mono">127.0.0.1:7001</span> — search will be synchronous.
</div>
);
}
return (
<div className="rounded-md border border-red-300 bg-red-50 p-3 text-xs text-red-800 space-y-2">
<p className="font-semibold">Python sidecar is not reachable.</p>
<p>
Start it with:{" "}
<code className="font-mono bg-surface px-2 py-0.5 rounded">
cd scripts &amp;&amp; source .venv/bin/activate &amp;&amp; uvicorn bizportal_service:app --port 7001
</code>
</p>
<p>
See <span className="font-mono">scripts/README.md</span> for the first-time setup.
</p>
</div>
);
}
function LogBlock({ log }: { log: string[] }) {
const [open, setOpen] = useState(false);
return (
<div className="text-xs">
<button
onClick={() => setOpen((o) => !o)}
className="text-muted hover:text-fg underline"
>
{open ? "Hide" : "Show"} service log ({log.length} entries)
</button>
{open && (
<pre className="mt-2 p-3 rounded bg-surface-warm text-fg font-mono text-[11px] overflow-x-auto whitespace-pre-wrap">
{log.join("\n")}
</pre>
)}
</div>
);
}
// ── 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<Map<string, any>>(new Map());
const [dbDirectors, setDbDirectors] = useState<Map<string, any>>(new Map());
const [links, setLinks] = useState<Set<string>>(new Set());
const [loaded, setLoaded] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkProcessing, setBulkProcessing] = useState(false);
const [bulkMsg, setBulkMsg] = useState<string>("");
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<string, any>();
(compData.items || []).forEach((c: any) => {
if (c.enterprise_no) cmap.set(String(c.enterprise_no).replace(/\s/g, ""), c);
});
const dmap = new Map<string, any>();
(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<string>();
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<string>();
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<string, any>();
(compData2.items || []).forEach((c: any) => {
if (c.enterprise_no) cmap.set(String(c.enterprise_no).replace(/\s/g, ""), c);
});
const dmap = new Map<string, any>();
(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 (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 p-3 rounded-md bg-surface-warm border border-border flex-wrap">
<div className="text-xs text-muted">
{loaded ? (
<>
<span className="font-semibold">{results.length}</span> result{results.length === 1 ? "" : "s"} ·{" "}
<span className={missingCompanies > 0 ? "text-yellow-700" : "text-green-700"}>
{results.length - missingCompanies}/{results.length} companies in DB
</span>
{" · "}
<span className={missingDirectors > 0 ? "text-yellow-700" : "text-green-700"}>
{missingDirectors} missing director{missingDirectors === 1 ? "" : "s"}
</span>
{linkableCount > 0 && (
<>
{" · "}
<span className="text-blue-700">{linkableCount} linkable pair{linkableCount === 1 ? "" : "s"}</span>
</>
)}
</>
) : (
"Loading DB state…"
)}
{durationMs !== undefined && (
<span className="ml-2 text-meta">· {durationMs}ms round-trip</span>
)}
</div>
<div className="flex items-center gap-2">
{selectedCount > 0 ? (
<>
<span className="text-xs font-semibold text-fg">{selectedCount} selected</span>
<button
onClick={clearSelection}
className="text-xs px-3 py-1 rounded border border-border text-muted hover:text-fg"
>
Clear
</button>
<button
onClick={processSelected}
disabled={bulkProcessing}
className="text-xs px-4 py-1 rounded bg-accent text-accent-on font-semibold hover:bg-accent-hover disabled:opacity-60"
>
{bulkProcessing ? "Processing…" : `PROCESS (${selectedCount})`}
</button>
</>
) : (
<button
onClick={selectAllMissing}
disabled={!loaded || (missingCompanies === 0 && missingDirectors === 0)}
className="text-xs px-3 py-1 rounded border border-border text-fg hover:border-accent disabled:opacity-50"
>
Select all missing
</button>
)}
</div>
</div>
{bulkMsg && (
<div className="text-xs px-3 py-2 rounded bg-surface border border-border text-fg">{bulkMsg}</div>
)}
{log && log.length > 0 && <LogBlock log={log} />}
<div className="space-y-8">
{results.map((c, idx) => (
<CompanyResult
key={c.enterprise_no || idx}
company={c}
maskId={maskId}
dbCompany={getCompanyRecord(c)}
getDirectorRecord={getDirectorRecord}
isLinked={isLinked}
links={links}
selected={selected}
toggle={toggle}
onLink={linkOne}
/>
))}
</div>
</div>
);
}
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<string>;
selected: Set<string>;
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 (
<div className="border border-border rounded-lg bg-surface p-5 space-y-4">
<div className="border-b border-border pb-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-lg font-bold text-fg">{entName}</h3>
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${
(entStatus || "").toLowerCase().includes("business")
? "bg-green-100 text-green-700"
: "bg-yellow-100 text-yellow-700"
}`}>
{entStatus}
</span>
</div>
<p className="text-sm text-muted mt-1 font-mono">{entNo}</p>
</div>
<div className="flex items-center gap-2">
{inDb ? (
<span className="px-2 py-1 rounded bg-green-100 text-green-700 text-xs font-semibold">
✅ In DB
</span>
) : (
<label className="flex items-center gap-2 px-3 py-1 rounded bg-yellow-50 border border-yellow-300 cursor-pointer">
<input
type="checkbox"
checked={selected.has(compSelectKey)}
onChange={() => toggle(compSelectKey)}
className="w-4 h-4"
/>
<span className="text-xs font-semibold text-yellow-800">Not in DB — select to add</span>
</label>
)}
</div>
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-fg mb-2">
Director Details
<span className="ml-2 text-xs text-muted font-normal">
({directors.length} director{directors.length === 1 ? "" : "s"})
</span>
</h4>
{directors.length === 0 ? (
<p className="text-sm text-muted italic">No directors found in registry.</p>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead>
<tr className="text-left bg-surface-warm text-muted text-xs uppercase tracking-wide">
<th className="px-3 py-2 font-semibold w-10"></th>
<th className="px-3 py-2 font-semibold">ID Number</th>
<th className="px-3 py-2 font-semibold">Name(s)</th>
<th className="px-3 py-2 font-semibold">Surname</th>
<th className="px-3 py-2 font-semibold">Status</th>
<th className="px-3 py-2 font-semibold">DB Status</th>
<th className="px-3 py-2 font-semibold text-right">Link</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-surface">
{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 (
<tr key={i} className={linked ? "bg-green-50/30" : "hover:bg-surface-warm"}>
<td className="px-3 py-2">
{!inDirDb && (
<input
type="checkbox"
checked={selected.has(dirSelectKey)}
onChange={() => toggle(dirSelectKey)}
className="w-4 h-4"
/>
)}
</td>
<td className="px-3 py-2 font-mono text-xs">{masked || "—"}</td>
<td className="px-3 py-2">{d.names || "—"}</td>
<td className="px-3 py-2">{d.surname || "—"}</td>
<td className="px-3 py-2">
<span className={`text-xs px-2 py-0.5 rounded font-semibold ${
(d.status || "").toLowerCase() === "active"
? "bg-green-50 text-green-700"
: "bg-gray-100 text-gray-600"
}`}>
{d.status || "—"}
</span>
</td>
<td className="px-3 py-2">
{inDirDb ? (
<span className="text-xs px-2 py-0.5 rounded bg-green-100 text-green-700 font-semibold">
✅ In DB
</span>
) : (
<span className="text-xs text-yellow-700 italic">select to add</span>
)}
</td>
<td className="px-3 py-2 text-right">
{!inDb ? (
<span className="text-xs text-muted italic">add company first</span>
) : !inDirDb ? (
<span className="text-xs text-muted italic">add director first</span>
) : linked ? (
<span className="text-xs px-2 py-0.5 rounded bg-green-100 text-green-700 font-semibold">
🔗 Linked
</span>
) : (
<button
onClick={() => onLink(dbCompany, dirRec, d)}
className="text-xs px-3 py-1 rounded bg-blue-600 text-white font-semibold hover:bg-blue-700"
>
🔗 Link
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}