Workflow-App/Workflow/components/OverviewSearch.tsx

246 lines
9.5 KiB
TypeScript
Raw Permalink Normal View History

"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>
);
}