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>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user