Replaces the brittle agent-driven /api/bizportal/search queue with a
deterministic Python FastAPI service that drives BizPortal via a real
headless Chromium. The old flow is untouched.
What this adds:
- scripts/bizportal_service.py — FastAPI + Playwright service with
/search, /reserve, /health endpoints. Persistent login session
via storage_state.json. Locator-based selectors (vs eval JS).
- app/bizportal-2/ — new /bizportal-2 page, synchronous fetch
(no polling), shows round-trip duration, mirrors the existing
bulk-add + link UX.
- app/api/bizportal-2/{search,reserve,health}/route.ts — thin
proxies to the Python sidecar.
- scripts/README.md, .env.example, .gitignore, requirements.txt.
- .gitignore: ignore tsconfig.tsbuildinfo (build artefact).
Wiring:
- components/NavBar.tsx: 'BizPortal 2.0' nav entry with TEST badge.
- app/bizportal/page.tsx: 'Try BizPortal 2.0' action link in
header so the new option is discoverable from the existing page.
Run: cd scripts && source .venv/bin/activate && uvicorn
bizportal_service:app --port 7001
700 lines
26 KiB
TypeScript
700 lines
26 KiB
TypeScript
// 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 && source .venv/bin/activate && 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>
|
|
);
|
|
}
|