Files
CapaKraken/apps/web/src/components/projects/project-wizard/ResourcePersonPicker.tsx
T
Hartmut 85e1bcc06f refactor(web): decompose ProjectWizard into step components
Extract each wizard step into its own file under project-wizard/:
StepBar, DynamicFieldInput, Step1Identity, ResourcePersonPicker,
Step2Timeline, Step3Staffing, Step4Suggestions, Step5Review.
Main file reduced from 1,385 to 112 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 08:30:33 +02:00

114 lines
3.7 KiB
TypeScript

import { useState, useEffect, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { trpc } from "~/lib/trpc/client.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
export function ResourcePersonPicker({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [query, setQuery] = useState(value);
const [open, setOpen] = useState(false);
const [debouncedSearch, setDebouncedSearch] = useState("");
const [isConfirmed, setIsConfirmed] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(query), 200);
return () => clearTimeout(timer);
}, [query]);
const { data } = trpc.resource.directory.useQuery(
{ isActive: true, search: debouncedSearch || undefined, limit: 30 },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ staleTime: 15_000, placeholderData: (prev: any) => prev },
);
const filtered = useMemo(
() =>
(data?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string }>,
[data],
);
useEffect(() => {
setQuery(value);
if (!value) setIsConfirmed(false);
}, [value]);
const { panelRef, position } = useAnchoredOverlay<HTMLInputElement>({
open,
onClose: () => setOpen(false),
align: "start",
matchTriggerWidth: true,
triggerRef: inputRef,
});
return (
<div className="relative">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
onChange(e.target.value);
setIsConfirmed(false);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder="Search by name or EID…"
className={`app-input ${isConfirmed ? "ring-2 ring-green-400 ring-offset-0" : ""}`}
/>
{isConfirmed && (
<span className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-green-500">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M5 13l4 4L19 7"
/>
</svg>
</span>
)}
{open && filtered.length > 0 && typeof document !== "undefined"
? createPortal(
<div
ref={panelRef}
className="fixed z-[9998] max-h-48 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg"
style={{
top: position.top,
left: position.left,
width: position.minWidth,
}}
onMouseDown={(e) => e.preventDefault()}
>
<ul>
{filtered.map((r) => (
<li key={r.id}>
<button
type="button"
onMouseDown={() => {
onChange(r.displayName);
setQuery(r.displayName);
setIsConfirmed(true);
setOpen(false);
}}
className="flex w-full items-baseline gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50"
>
<span className="truncate">{r.displayName}</span>
<span className="shrink-0 font-mono text-xs text-gray-400">{r.eid}</span>
</button>
</li>
))}
</ul>
</div>,
document.body,
)
: null}
</div>
);
}