영업관리 구매요청 2메뉴 액션 완성 + SmartSelect 키보드 네비

- sales_request_part DDL 추출(운영 11133)→RPS(11134) 마이그레이션
- 백엔드 6 엔드포인트: 프로젝트 자동채움/M-BOM 품목/저장/품의서생성/SSO
  · 품의서 결재상신 Amaranth SSO (target_type=PROPOSAL, formId=1163)
- 프론트 다이얼로그 2개 (구매요청서작성 / 품의서생성 확인)
  · 프로젝트 선택→주문유형·제품구분·국내외·고객사·유무상 자동 채움
  · 행추가 시 M-BOM 품번 셀렉트→품명/공급업체/단가 자동 셋팅
- 공용 SmartSelect: ↑↓·Enter·Esc·Home·End·PageUp·Down 키보드 네비
- 그리드 delivery_request_date . → - 형식 정규화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-15 14:01:26 +09:00
parent 3db55d9fd9
commit 75f4ca8127
11 changed files with 1494 additions and 29 deletions
+76 -2
View File
@@ -48,6 +48,7 @@ export function SmartSelect({
}: SmartSelectProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const scrollRef = useRef<HTMLDivElement | null>(null);
// code가 비어있는 옵션은 자동 제외 (Radix Select value 제약 + key 중복 방지)
@@ -87,6 +88,73 @@ export function SmartSelect({
return () => cancelAnimationFrame(id);
}, [open, virtualizer, filtered.length]);
// 팝오버 열릴 때 현재 선택값 위치로 활성 인덱스 초기화 (없으면 0)
useEffect(() => {
if (!open) return;
const idx = filtered.findIndex((o) => o.code === value);
setActiveIndex(idx >= 0 ? idx : 0);
// 의도적으로 filtered.length 변화 시에도 재계산 안 함 (검색 입력 중 0번 유지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// 검색어가 바뀌면 첫 항목으로 리셋
useEffect(() => {
if (open) setActiveIndex(0);
}, [search, open]);
// 활성 인덱스가 바뀌면 가시 영역으로 스크롤
useEffect(() => {
if (!open) return;
if (activeIndex < 0 || activeIndex >= filtered.length) return;
virtualizer.scrollToIndex(activeIndex, { align: "auto" });
}, [activeIndex, open, virtualizer, filtered.length]);
const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (filtered.length === 0) {
if (e.key === "Escape") { e.preventDefault(); setOpen(false); }
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((i) => Math.min(filtered.length - 1, (i < 0 ? -1 : i) + 1));
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((i) => Math.max(0, (i < 0 ? 0 : i) - 1));
break;
case "Home":
e.preventDefault();
setActiveIndex(0);
break;
case "End":
e.preventDefault();
setActiveIndex(filtered.length - 1);
break;
case "PageDown":
e.preventDefault();
setActiveIndex((i) => Math.min(filtered.length - 1, (i < 0 ? 0 : i) + 8));
break;
case "PageUp":
e.preventDefault();
setActiveIndex((i) => Math.max(0, (i < 0 ? 0 : i) - 8));
break;
case "Enter": {
e.preventDefault();
const hit = filtered[activeIndex];
if (hit) {
onValueChange(hit.code);
setOpen(false);
}
break;
}
case "Escape":
e.preventDefault();
setOpen(false);
break;
}
};
const showClear = clearable && !disabled && !!value;
// Radix Select/Popover trigger는 onPointerDown으로 열린다 → 같은 단계에서 차단해야 X 클릭이 trigger를 안 깨움
const stopAndClear = (e: React.PointerEvent | React.MouseEvent) => {
@@ -162,6 +230,7 @@ export function SmartSelect({
placeholder="검색..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={onSearchKeyDown}
className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
@@ -183,17 +252,22 @@ export function SmartSelect({
{virtualizer.getVirtualItems().map((vItem) => {
const o = filtered[vItem.index];
const isSelected = value === o.code;
const isActive = activeIndex === vItem.index;
return (
<button
key={`${o.code}-${vItem.index}`}
type="button"
role="option"
aria-selected={isSelected}
onMouseEnter={() => setActiveIndex(vItem.index)}
onClick={() => {
onValueChange(o.code);
setOpen(false);
}}
className={cn(
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left hover:bg-accent",
isSelected && "bg-accent/60",
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left",
isActive ? "bg-accent" : "hover:bg-accent/40",
isSelected && !isActive && "bg-accent/60",
)}
style={{
height: `${vItem.size}px`,