196 lines
7 KiB
TypeScript
196 lines
7 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState, useEffect, useRef } from "react";
|
||
|
|
import { useRouter } from "next/navigation";
|
||
|
|
import { text } from "@/lib/format";
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
/** "link-director-to-company" | "link-company-to-director" */
|
||
|
|
mode: "link-director" | "link-company";
|
||
|
|
/** PB id of the parent entity (company for director-mode, director for company-mode) */
|
||
|
|
parentId: string;
|
||
|
|
/** Display name of the parent entity, shown in the modal heading */
|
||
|
|
parentName: string;
|
||
|
|
/** Ids of entities already linked — excluded from the dropdown */
|
||
|
|
alreadyLinkedIds: string[];
|
||
|
|
open: boolean;
|
||
|
|
onClose: () => void;
|
||
|
|
/** Called after a successful link, so the parent page can refresh */
|
||
|
|
onLinked: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface SearchResult {
|
||
|
|
id: string;
|
||
|
|
label: string;
|
||
|
|
sub: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function LinkEntityModal({
|
||
|
|
mode,
|
||
|
|
parentId,
|
||
|
|
parentName,
|
||
|
|
alreadyLinkedIds,
|
||
|
|
open,
|
||
|
|
onClose,
|
||
|
|
onLinked,
|
||
|
|
}: Props) {
|
||
|
|
const router = useRouter();
|
||
|
|
const [query, setQuery] = useState("");
|
||
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||
|
|
const [searching, setSearching] = useState(false);
|
||
|
|
const [linking, setLinking] = useState<string | null>(null);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||
|
|
|
||
|
|
// Search when query changes (debounced 300ms)
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open) return;
|
||
|
|
clearTimeout(debounceRef.current);
|
||
|
|
if (!query.trim()) { setResults([]); return; }
|
||
|
|
debounceRef.current = setTimeout(async () => {
|
||
|
|
setSearching(true);
|
||
|
|
setError(null);
|
||
|
|
try {
|
||
|
|
const collection = mode === "link-director" ? "ysterbaard_companies" : "ysterbaard_directors";
|
||
|
|
const q = encodeURIComponent(query.trim().replace(/'/g, "''"));
|
||
|
|
const filter = mode === "link-director"
|
||
|
|
? `(enterprise_name ~ '${q}' || enterprise_no ~ '${q}')`
|
||
|
|
: `(surname ~ '${q}' || first_names ~ '${q}' || id_no ~ '${q}')`;
|
||
|
|
const res = await fetch(
|
||
|
|
`/api/pb/records/list?collection=${collection}&filter=${encodeURIComponent(filter)}&perPage=20&fields=id,enterprise_name,enterprise_no,first_names,surname,id_no`
|
||
|
|
);
|
||
|
|
const data = await res.json();
|
||
|
|
const items: SearchResult[] = (data.items ?? []).map((item: Record<string, unknown>) => {
|
||
|
|
if (mode === "link-director") {
|
||
|
|
return {
|
||
|
|
id: item.id as string,
|
||
|
|
label: text(item.enterprise_name as string),
|
||
|
|
sub: text(item.enterprise_no as string),
|
||
|
|
};
|
||
|
|
} else {
|
||
|
|
return {
|
||
|
|
id: item.id as string,
|
||
|
|
label: `${text(item.first_names as string)} ${text(item.surname as string)}`.trim(),
|
||
|
|
sub: text(item.id_no as string),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}).filter((r: SearchResult) => !alreadyLinkedIds.includes(r.id));
|
||
|
|
setResults(items);
|
||
|
|
} catch {
|
||
|
|
setError("Search failed. Try again.");
|
||
|
|
} finally {
|
||
|
|
setSearching(false);
|
||
|
|
}
|
||
|
|
}, 300);
|
||
|
|
return () => clearTimeout(debounceRef.current);
|
||
|
|
}, [query, open, mode, alreadyLinkedIds]);
|
||
|
|
|
||
|
|
async function handleLink(targetId: string, targetLabel: string) {
|
||
|
|
setLinking(targetId);
|
||
|
|
setError(null);
|
||
|
|
try {
|
||
|
|
const body = mode === "link-director"
|
||
|
|
? { company: targetId, director: parentId, director_type: "Director" }
|
||
|
|
: { director: targetId, company: parentId, director_type: "Director" };
|
||
|
|
const res = await fetch("/api/pb/records/link", {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify(body),
|
||
|
|
});
|
||
|
|
if (!res.ok) {
|
||
|
|
const err = await res.json();
|
||
|
|
throw new Error(err.error ?? "Link failed");
|
||
|
|
}
|
||
|
|
setQuery("");
|
||
|
|
setResults([]);
|
||
|
|
onLinked();
|
||
|
|
onClose();
|
||
|
|
router.refresh();
|
||
|
|
} catch (e: unknown) {
|
||
|
|
setError((e instanceof Error ? e.message : "Link failed"));
|
||
|
|
} finally {
|
||
|
|
setLinking(null);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!open) return null;
|
||
|
|
|
||
|
|
const isDirectorMode = mode === "link-director";
|
||
|
|
const heading = isDirectorMode
|
||
|
|
? `Link company to ${parentName}`
|
||
|
|
: `Link director to ${parentName}`;
|
||
|
|
const placeholder = isDirectorMode
|
||
|
|
? "Search companies by name or number…"
|
||
|
|
: "Search directors by name or ID…";
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||
|
|
<div
|
||
|
|
className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-lg mx-4 flex flex-col"
|
||
|
|
style={{ maxHeight: "80vh" }}
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
>
|
||
|
|
{/* Header */}
|
||
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||
|
|
<h2 className="text-lg font-semibold text-fg">{heading}</h2>
|
||
|
|
<button
|
||
|
|
onClick={onClose}
|
||
|
|
className="text-muted hover:text-fg transition-colors p-1 rounded"
|
||
|
|
aria-label="Close"
|
||
|
|
>
|
||
|
|
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2">
|
||
|
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Search input */}
|
||
|
|
<div className="px-6 py-4 border-b border-border shrink-0">
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={query}
|
||
|
|
onChange={(e) => setQuery(e.target.value)}
|
||
|
|
placeholder={placeholder}
|
||
|
|
autoFocus
|
||
|
|
className="w-full border border-border rounded-lg bg-surface-warm px-4 py-2.5 text-fg placeholder:text-meta outline-none focus:border-accent transition-colors"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Results */}
|
||
|
|
<div className="flex-1 overflow-y-auto px-6 py-3">
|
||
|
|
{error && (
|
||
|
|
<div className="mb-3 px-4 py-2.5 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||
|
|
{error}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{searching && <p className="text-muted text-sm text-center py-4">Searching…</p>}
|
||
|
|
{!searching && query && results.length === 0 && (
|
||
|
|
<p className="text-muted text-sm text-center py-4">No matches found.</p>
|
||
|
|
)}
|
||
|
|
{!searching && !query && (
|
||
|
|
<p className="text-muted text-sm text-center py-4">Type to search…</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{results.map((r) => (
|
||
|
|
<button
|
||
|
|
key={r.id}
|
||
|
|
onClick={() => handleLink(r.id, r.label)}
|
||
|
|
disabled={linking !== null}
|
||
|
|
className="w-full flex items-center justify-between gap-3 px-4 py-3 rounded-lg hover:bg-surface-warm transition-colors text-left disabled:opacity-50"
|
||
|
|
>
|
||
|
|
<div className="min-w-0">
|
||
|
|
<p className="font-semibold text-fg truncate">{r.label}</p>
|
||
|
|
<p className="text-sm text-muted font-mono truncate">{r.sub}</p>
|
||
|
|
</div>
|
||
|
|
<span className="shrink-0 text-xs font-semibold text-accent">
|
||
|
|
{linking === r.id ? "…" : "Link →"}
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|