WORKFLOW-APP/components/LinkEntityModal.tsx

196 lines
7 KiB
TypeScript
Raw Permalink Normal View History

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