85e1bcc06f
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>
114 lines
3.7 KiB
TypeScript
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>
|
|
);
|
|
}
|