Workflow-App/Workflow/components/SearchableSelect.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
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.
2026-10-18 15:21:45 +02:00

362 lines
13 KiB
TypeScript

"use client";
// components/SearchableSelect.tsx
// Reusable typeahead combobox for picking a single record from a PB collection.
// Mirrors the search pattern used in LinkEntityModal (300ms debounce, PB filter
// syntax with `~`) but exposes the selected record to the parent via onChange
// instead of performing the link inline.
//
// Usage:
// <SearchableSelect
// label="Director"
// collection="ysterbaard_directors"
// fields={["id", "first_names", "surname", "email", "telephone"]}
// searchFields={["first_names", "surname", "id_no"]}
// placeholder="Search directors by name or ID..."
// value={selectedDirector}
// onChange={(record) => setSelectedDirector(record?.id ?? "")}
// renderLabel={(r) => `${r.first_names ?? ""} ${r.surname ?? ""}`.trim()}
// renderSub={(r) => r.email || r.id_no || ""}
// perPage={20}
// />
//
// The selected record is preserved when the user clears the search box, so the
// "Validate recipients" gate sees a real id even after the user refines the
// query. If `required` is true and nothing is selected, an inline error appears.
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { createPortal } from "react-dom";
import { text } from "@/lib/format";
export interface SearchableSelectRecord {
id: string;
[key: string]: unknown;
}
interface Props {
/** Field label shown above the input */
label: string;
/** PB collection id, e.g. "ysterbaard_directors" */
collection: string;
/** PB field names to fetch. Must include `id` and any field used by renderLabel/renderSub. */
fields: string[];
/** PB field names to OR-match against the query string. */
searchFields: string[];
/** Placeholder shown in the empty input */
placeholder?: string;
/** Currently selected id (controlled). Pass `selected?.id ?? ""`. */
value: string;
/** Called with the full record (or null on clear). Cast to your row type at the call site. */
onChange: (record: SearchableSelectRecord | null) => void;
/** Optional label override (defaults to concatenating fields listed in `fields` minus `id`) */
renderLabel?: (record: SearchableSelectRecord) => string;
/** Optional secondary line shown beneath the label */
renderSub?: (record: SearchableSelectRecord) => string;
/** Number of results per page (default 20) */
perPage?: number;
/** Show a required marker and inline error when value is empty after a blur */
required?: boolean;
/** Stable id used for aria attributes */
id?: string;
}
export default function SearchableSelect({
label,
collection,
fields,
searchFields,
placeholder = "Type to search...",
value,
onChange,
renderLabel,
renderSub,
perPage = 20,
required = false,
id,
}: Props) {
const inputId = id ?? `searchable-select-${collection}`;
const listboxId = `${inputId}-listbox`;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchableSelectRecord[]>([]);
const [selected, setSelected] = useState<SearchableSelectRecord | null>(null);
const [searching, setSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [touched, setTouched] = useState(false);
const [activeIdx, setActiveIdx] = useState<number>(-1);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLDivElement>(null);
/** Viewport coordinates for the portalled listbox. Positioned in `body`
* via createPortal so it escapes any ancestor with `overflow-y-auto`
* (the resignation/appointment modal scrolls, which used to clip the
* dropdown to a single visible row). Recomputed whenever the dropdown
* opens or the page scrolls/resizes under it. */
const [listboxStyle, setListboxStyle] = useState<CSSProperties>({});
// Default label/sub renderers: concatenate all non-id fields.
const labelOf = (r: SearchableSelectRecord): string => {
if (renderLabel) return renderLabel(r);
return fields
.filter((f) => f !== "id")
.map((f) => text(String(r[f] ?? "")))
.filter(Boolean)
.join(" ");
};
const subOf = (r: SearchableSelectRecord): string => (renderSub ? renderSub(r) : "");
// When `value` changes from the outside (e.g. parent clears the modal),
// sync the selected record. We do NOT fetch it again — the parent already
// has it or can call onChange(null) to reset.
useEffect(() => {
if (!value) {
setSelected(null);
setQuery("");
setActiveIdx(-1);
}
}, [value]);
// Click-outside to close. Must test the portalled listbox too — it is no
// longer a DOM descendant of containerRef, so a naive `contains()` check
// would close the dropdown the moment a result is clicked.
useEffect(() => {
const handler = (e: MouseEvent) => {
const t = e.target as Node;
const insideInput = containerRef.current?.contains(t);
const insideList = listboxRef.current?.contains(t);
if (!insideInput && !insideList) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
// Debounced search — mirrors LinkEntityModal
useEffect(() => {
if (!open) return;
clearTimeout(debounceRef.current);
const q = query.trim();
if (!q) {
setResults([]);
setSearching(false);
return;
}
setSearching(true);
setError(null);
debounceRef.current = setTimeout(async () => {
try {
const safe = q.replace(/'/g, "''");
const filter = searchFields
.map((f) => `${f} ~ '${safe}'`)
.join(" || ");
const url = `/api/pb/records/list?collection=${encodeURIComponent(collection)}&filter=${encodeURIComponent(filter)}&perPage=${perPage}&fields=${encodeURIComponent(fields.join(","))}`;
const res = await fetch(url);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
const data = await res.json();
setResults((data.items ?? []) as SearchableSelectRecord[]);
setActiveIdx((data.items ?? []).length > 0 ? 0 : -1);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Search failed");
setResults([]);
} finally {
setSearching(false);
}
}, 300);
return () => clearTimeout(debounceRef.current);
}, [query, open, collection, searchFields, fields, perPage]);
// Position the portalled listbox against the input. Drops downward when
// there is room, otherwise flips above the input — so it never gets cut
// off by the bottom of the viewport in the modal.
useEffect(() => {
if (!open) return;
const reposition = () => {
const el = containerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const below = window.innerHeight - r.bottom - 8;
const above = r.top - 8;
const flip = below < 240 && above > below;
const maxHeight = Math.max(160, Math.min(420, flip ? above : below));
setListboxStyle({
position: "fixed",
left: r.left,
width: r.width,
maxHeight,
...(flip
? { bottom: window.innerHeight - r.top + 4 }
: { top: r.bottom + 4 }),
});
};
reposition();
window.addEventListener("scroll", reposition, true);
window.addEventListener("resize", reposition);
return () => {
window.removeEventListener("scroll", reposition, true);
window.removeEventListener("resize", reposition);
};
}, [open, results.length]);
const choose = (record: SearchableSelectRecord) => {
setSelected(record);
setQuery("");
setResults([]);
setOpen(false);
setActiveIdx(-1);
setTouched(true);
onChange(record);
};
const clear = () => {
setSelected(null);
setQuery("");
setResults([]);
setTouched(true);
setActiveIdx(-1);
onChange(null);
inputRef.current?.focus();
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIdx((i) => Math.min(results.length - 1, i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIdx((i) => Math.max(0, i - 1));
} else if (e.key === "Enter") {
e.preventDefault();
const r = results[activeIdx];
if (r) choose(r);
} else if (e.key === "Escape") {
setOpen(false);
}
};
const showError = required && touched && !value;
const displayValue = open ? query : (selected ? labelOf(selected) : query);
const subDisplay = !open && selected ? subOf(selected) : "";
return (
<div ref={containerRef} className="relative">
<label htmlFor={inputId} className="block text-sm font-semibold text-fg mb-1.5">
{label}
{required && <span className="text-danger ml-0.5">*</span>}
</label>
<div className="relative">
<input
ref={inputRef}
id={inputId}
type="text"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeIdx >= 0 ? `${listboxId}-opt-${activeIdx}` : undefined}
value={displayValue}
placeholder={selected ? "" : placeholder}
onFocus={() => { setOpen(true); setTouched(true); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onKeyDown={onKeyDown}
className={`w-full bg-surface border rounded px-3 py-2.5 text-sm text-fg focus:outline-none focus:border-accent transition-colors pr-9 ${
showError ? "border-danger" : "border-border"
}`}
/>
{/* Clear / chevron affordance */}
<div className="absolute inset-y-0 right-0 flex items-center pr-2 gap-1">
{selected && !open && (
<button
type="button"
onClick={clear}
tabIndex={-1}
aria-label="Clear selection"
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" />
</svg>
</button>
)}
<svg
viewBox="0 0 24 24"
className={`w-4 h-4 text-muted transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</div>
{/* Sub-line under the input when a record is selected and dropdown closed */}
{!open && subDisplay && (
<p className="mt-1 text-xs text-muted font-mono truncate">{subDisplay}</p>
)}
{/* Inline error / required message */}
{showError && (
<p className="mt-1 text-xs text-danger">Please select a {label.toLowerCase()}.</p>
)}
{/* Dropdown listbox. Rendered into <body> so ancestor scroll
containers (the modal body) cannot clip it. */}
{open &&
typeof document !== "undefined" &&
createPortal(
<div
ref={listboxRef}
id={listboxId}
role="listbox"
style={listboxStyle}
className="z-[60] overflow-y-auto bg-surface border border-border rounded-lg shadow-raised"
>
{error && (
<div className="px-3 py-2 text-sm text-danger border-b border-border">
{error}
</div>
)}
{searching && (
<p className="px-3 py-3 text-sm text-muted text-center">Searching…</p>
)}
{!searching && !error && query.trim() && results.length === 0 && (
<p className="px-3 py-3 text-sm text-muted text-center">No matches found.</p>
)}
{!searching && !error && !query.trim() && (
<p className="px-3 py-3 text-sm text-muted text-center">Type to search…</p>
)}
{results.map((r, idx) => {
const active = idx === activeIdx;
return (
<button
key={r.id}
id={`${listboxId}-opt-${idx}`}
role="option"
aria-selected={active}
type="button"
onMouseEnter={() => setActiveIdx(idx)}
onMouseDown={(e) => { e.preventDefault(); choose(r); }}
onClick={() => choose(r)}
className={`w-full text-left px-3 py-2.5 transition-colors flex flex-col gap-0.5 ${
active ? "bg-surface-warm" : "hover:bg-surface-warm"
}`}
>
<span className="text-sm font-semibold text-fg truncate">{labelOf(r)}</span>
{subOf(r) && (
<span className="text-xs text-muted font-mono truncate">{subOf(r)}</span>
)}
</button>
);
})}
</div>,
document.body
)}
</div>
);
}