Adds the full Next.js app (app/, components/, lib/, scripts/, assets/, docs/, Dockerfile, compose, configs) to version control. Security: - CIPC BizPortal customer-code logins (Login 1 + Login 3) now read CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE / CIPC_LOGIN3_PASSWORD from .env.local instead of source. - BizPortal personal login in apply-bizportal route reads BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD. - Added .env.example as the committed template. .gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright storage_state.json (live CIPC cookies), scratch dirs, client PDFs and debug screenshots.
245 lines
9.5 KiB
TypeScript
245 lines
9.5 KiB
TypeScript
"use client";
|
|
|
|
// components/OverviewSearch.tsx
|
|
// Global tax-number / CK-number search for the Overview tab.
|
|
//
|
|
// Searches ONLY the company registry — every field we search is a tax
|
|
// or registration number, per SJ's rule (verified 2026-09-11).
|
|
// Substring, case-insensitive. In-memory; the data ships from the
|
|
// server with the rest of the dashboard props.
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import Link from "next/link";
|
|
import { text } from "@/lib/format";
|
|
import type { PBRecord } from "@/lib/pb";
|
|
|
|
export interface OverviewSearchItem extends PBRecord {
|
|
id: string;
|
|
enterprise_no: string;
|
|
enterprise_name: string;
|
|
sars_tax_number: string | null;
|
|
sars_paye_no: string | null;
|
|
sars_vat_no: string | null;
|
|
uif_registration_number: string | null;
|
|
compensation_fund_registration_number: string | null;
|
|
}
|
|
|
|
interface Props {
|
|
items: OverviewSearchItem[];
|
|
}
|
|
|
|
interface Hit {
|
|
id: string;
|
|
enterprise_no: string;
|
|
enterprise_name: string;
|
|
/** Human label, e.g. "SARS PAYE no". */
|
|
fieldLabel: string;
|
|
/** Actual value that matched (preserves original casing/spacing). */
|
|
fieldValue: string;
|
|
/** Path the row should link to. */
|
|
href: string;
|
|
}
|
|
|
|
// Field definitions: (data key, human label) — order is intentional,
|
|
// most-common first.
|
|
const SEARCH_FIELDS: Array<{ key: keyof OverviewSearchItem; label: string }> = [
|
|
{ key: "enterprise_no", label: "CK number" },
|
|
{ key: "sars_tax_number", label: "SARS Income Tax no" },
|
|
{ key: "sars_paye_no", label: "SARS PAYE no" },
|
|
{ key: "sars_vat_no", label: "SARS VAT no" },
|
|
{ key: "uif_registration_number", label: "UIF no" },
|
|
{ key: "compensation_fund_registration_number", label: "COID no" },
|
|
];
|
|
|
|
const MAX_RESULTS = 50;
|
|
const MIN_QUERY_LEN = 2;
|
|
|
|
function search(items: OverviewSearchItem[], q: string): Hit[] {
|
|
const needle = q.trim().toLowerCase();
|
|
if (needle.length < MIN_QUERY_LEN) return [];
|
|
const hits: Hit[] = [];
|
|
for (const item of items) {
|
|
for (const { key, label } of SEARCH_FIELDS) {
|
|
const raw = item[key];
|
|
if (typeof raw !== "string" || raw === "") continue;
|
|
if (raw.toLowerCase().includes(needle)) {
|
|
hits.push({
|
|
id: item.id,
|
|
enterprise_no: item.enterprise_no,
|
|
enterprise_name: item.enterprise_name,
|
|
fieldLabel: label,
|
|
fieldValue: raw,
|
|
href: `/companies/${item.id}`,
|
|
});
|
|
// One hit per company is enough — don't double-list the same
|
|
// record under multiple matching fields. If the user wants
|
|
// every match, the CK number itself usually disambiguates.
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return hits.slice(0, MAX_RESULTS);
|
|
}
|
|
|
|
export default function OverviewSearch({ items }: Props) {
|
|
const [query, setQuery] = useState("");
|
|
const [debounced, setDebounced] = useState("");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Debounce 150ms so each keystroke doesn't re-render the list.
|
|
useEffect(() => {
|
|
const handle = setTimeout(() => setDebounced(query), 150);
|
|
return () => clearTimeout(handle);
|
|
}, [query]);
|
|
|
|
// Cmd/Ctrl+K focuses the search bar from anywhere on the page.
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
const isMod = e.metaKey || e.ctrlKey;
|
|
if (isMod && e.key.toLowerCase() === "k") {
|
|
e.preventDefault();
|
|
inputRef.current?.focus();
|
|
inputRef.current?.select();
|
|
} else if (e.key === "Escape" && document.activeElement === inputRef.current) {
|
|
setQuery("");
|
|
inputRef.current?.blur();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, []);
|
|
|
|
const hits = useMemo(() => search(items, debounced), [items, debounced]);
|
|
const showPanel = debounced.trim().length >= MIN_QUERY_LEN;
|
|
const totalCompanies = items.length;
|
|
|
|
const clear = () => {
|
|
setQuery("");
|
|
setDebounced("");
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
return (
|
|
<section className="container-x pt-10 pb-2">
|
|
<div className="bg-surface border border-border rounded-lg overflow-hidden">
|
|
{/* Header / input row */}
|
|
<div className="px-5 py-4 border-b border-border-soft">
|
|
<div className="flex items-baseline justify-between gap-3 mb-2">
|
|
<h2 className="text-base font-semibold text-fg">
|
|
Quick search
|
|
</h2>
|
|
<span className="text-xs text-muted">
|
|
{totalCompanies.toLocaleString()} companies · CK no · SARS Income Tax · PAYE · VAT · UIF · COID
|
|
</span>
|
|
</div>
|
|
<div className="relative">
|
|
<span
|
|
aria-hidden="true"
|
|
className="absolute inset-y-0 left-0 flex items-center pl-3 text-muted pointer-events-none"
|
|
>
|
|
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="11" cy="11" r="7" />
|
|
<path d="m21 21-4.3-4.3" strokeLinecap="round" />
|
|
</svg>
|
|
</span>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
inputMode="search"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Type a CK number or SARS tax number…"
|
|
aria-label="Search companies by CK number or tax number"
|
|
className="w-full bg-bg border border-border rounded-md pl-9 pr-20 py-2.5 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent transition-colors font-mono"
|
|
/>
|
|
<div className="absolute inset-y-0 right-0 flex items-center pr-2 gap-1">
|
|
{query && (
|
|
<button
|
|
type="button"
|
|
onClick={clear}
|
|
aria-label="Clear search"
|
|
className="text-muted hover:text-fg p-1 rounded transition-colors"
|
|
>
|
|
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-muted bg-surface-warm border border-border rounded">
|
|
Ctrl K
|
|
</kbd>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Results panel — only renders when query is long enough */}
|
|
{showPanel && (
|
|
<div>
|
|
<div className="px-5 py-2 text-xs text-muted border-b border-border-soft">
|
|
{hits.length === 0
|
|
? `No matches for "${debounced.trim()}"`
|
|
: `${hits.length}${hits.length === MAX_RESULTS ? "+" : ""} match${hits.length === 1 ? "" : "es"} for "${debounced.trim()}"`}
|
|
</div>
|
|
{hits.length === 0 ? (
|
|
<div className="px-5 py-8 text-center text-sm text-muted">
|
|
Nothing matched. Double-check the number — these are
|
|
the fields we search:{" "}
|
|
<span className="font-mono text-fg-2">
|
|
enterprise_no, sars_tax_number, sars_paye_no, sars_vat_no, uif_registration_number, compensation_fund_registration_number
|
|
</span>.
|
|
</div>
|
|
) : (
|
|
<ul>
|
|
{hits.map((hit, idx) => (
|
|
<li
|
|
key={`${hit.id}-${idx}`}
|
|
className="border-b border-border-soft last:border-b-0"
|
|
>
|
|
<Link
|
|
href={hit.href}
|
|
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-warm transition-colors no-underline"
|
|
>
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase bg-accent/10 text-accent border border-accent/20 flex-shrink-0">
|
|
Company
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-semibold text-fg truncate">
|
|
{text(hit.enterprise_name) || "—"}
|
|
</p>
|
|
<p className="text-xs text-muted mt-0.5">
|
|
<span className="font-mono">{hit.enterprise_no}</span>
|
|
<span className="mx-2 text-meta">·</span>
|
|
<span className="text-fg-2 font-medium">{hit.fieldLabel}:</span>{" "}
|
|
<span className="font-mono">{hit.fieldValue}</span>
|
|
</p>
|
|
</div>
|
|
<svg
|
|
aria-hidden="true"
|
|
viewBox="0 0 24 24"
|
|
className="w-4 h-4 text-muted flex-shrink-0"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="m9 18 6-6-6-6" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Idle hint when no query */}
|
|
{!showPanel && (
|
|
<div className="px-5 py-3 text-xs text-muted">
|
|
Type at least {MIN_QUERY_LEN} characters. Results open the company detail page in a click.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|