Files
wace_rps/frontend/components/common/SmartSelect.tsx
T
hjjeong 66e2a63dfa 영업관리 G1 수주확정→프로젝트 자동생성 + 견적요청등록 wace 1:1 이식
- G1: salesOrderMgmtService.updateStatus 트랜잭션화 + project_mgmt 자동생성 (project_no 채번/Machine 분기/contract_item 라인별 INSERT)
- 수주확정 다이얼로그(상태 select 팝업) + 수주취소 다이얼로그(라인별 cancel_qty)·POST /sales/order-mgmt/:id/cancel-qty 신설
- 견적요청등록 폼: estimate_template 분리, 헤더 8(주문유형/국내해외/고객사/유무상/접수일/견적환종/견적환율/결재여부) + 라인 8(제품구분/품번/품명/S/N/견적수량/요청납기/반납사유/고객요청사항) wace 운영 화면과 1:1
- S/N 관리 다이얼로그(테이블+연속번호생성), PartSelect/CommCodeSelect/CustomerSelect 셀렉트박스 + ✕(선택해제), 수주확정된 행 라인 추가/삭제 차단
- DataGrid 체크박스 모드 (영업번호 No → 체크박스, 행 어디 클릭이나 단일 선택)
- 식별자 정합성: contract_mgmt.customer_objid를 customer_mng.customer_code 기반(C_xxxx)으로 통일, contract_no 채번 {YY}C-{NNNN} 운영 패턴 일치, contract_item.quantity ::integer 캐스트

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:48:25 +09:00

217 lines
7.3 KiB
TypeScript

"use client";
/**
* SmartSelect
*
* 옵션 개수에 따라 자동으로 검색 기능을 제공하는 셀렉트 컴포넌트.
* - 옵션 5개 미만: 기본 Select
* - 옵션 5개 이상: 검색 + 가상 스크롤 Combobox (대용량 옵션도 빠르게 처리)
*/
import React, { useState, useMemo, useEffect, useRef } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown, Search as SearchIcon, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useVirtualizer } from "@tanstack/react-virtual";
const SEARCH_THRESHOLD = 5;
const ITEM_HEIGHT = 36;
const LIST_HEIGHT = 280;
export interface SmartSelectOption {
code: string;
label: string;
}
interface SmartSelectProps {
options: SmartSelectOption[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
/** 값이 있을 때 ✕(선택 해제) 버튼 노출 (기본 true). 필수 필드는 false로 둘 것. */
clearable?: boolean;
}
export function SmartSelect({
options,
value,
onValueChange,
placeholder = "선택",
disabled = false,
className,
clearable = true,
}: SmartSelectProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const scrollRef = useRef<HTMLDivElement | null>(null);
// code가 비어있는 옵션은 자동 제외 (Radix Select value 제약 + key 중복 방지)
const safeOptions = useMemo(
() => options.filter((o) => o.code !== null && o.code !== undefined && o.code !== ""),
[options],
);
const selectedLabel = useMemo(
() => safeOptions.find((o) => o.code === value)?.label,
[safeOptions, value],
);
// 팝오버 닫힐 때 검색어 리셋
useEffect(() => {
if (!open) setSearch("");
}, [open]);
// 검색어로 옵션 필터 (대소문자 무시)
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return safeOptions;
return safeOptions.filter((o) => o.label.toLowerCase().includes(q));
}, [safeOptions, search]);
const virtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ITEM_HEIGHT,
overscan: 10,
});
// 팝오버 열릴 때 측정 강제 (Portal 렌더 타이밍 대응)
useEffect(() => {
if (!open) return;
const id = requestAnimationFrame(() => virtualizer.measure());
return () => cancelAnimationFrame(id);
}, [open, virtualizer, filtered.length]);
const showClear = clearable && !disabled && !!value;
// Radix Select/Popover trigger는 onPointerDown으로 열린다 → 같은 단계에서 차단해야 X 클릭이 trigger를 안 깨움
const stopAndClear = (e: React.PointerEvent | React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onValueChange("");
};
const blockTrigger = (e: React.PointerEvent | React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
const ClearBtn = (
<button
type="button"
tabIndex={-1}
aria-label="선택 해제"
onPointerDown={blockTrigger}
onMouseDown={blockTrigger}
onClick={stopAndClear}
className="absolute right-8 top-1/2 -translate-y-1/2 z-10 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
>
<X className="h-3.5 w-3.5" />
</button>
);
if (safeOptions.length < SEARCH_THRESHOLD) {
return (
<div className={cn("relative", className)}>
{/* key: 빈값↔값 전환 시 Radix Select remount — controlled value=undefined 시 selection 미해제 우회 */}
<Select key={value ? "filled" : "empty"} value={value || undefined} onValueChange={onValueChange} disabled={disabled}>
<SelectTrigger className={cn("h-9", showClear && "pr-12")}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{safeOptions.map((o, idx) => (
<SelectItem key={`${o.code}-${idx}`} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
{showClear && ClearBtn}
</div>
);
}
return (
<div className="relative">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("h-9 w-full justify-between font-normal", showClear && "pr-12", className)}
>
<span className="truncate">
{selectedLabel || <span className="text-muted-foreground">{placeholder}</span>}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<div className="flex items-center border-b px-2">
<SearchIcon className="h-4 w-4 text-muted-foreground mr-1 shrink-0" />
<Input
autoFocus
placeholder="검색..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
{filtered.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground"> .</div>
) : (
<div
ref={scrollRef}
className="overflow-auto py-1"
style={{ height: LIST_HEIGHT }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const o = filtered[vItem.index];
const isSelected = value === o.code;
return (
<button
key={`${o.code}-${vItem.index}`}
type="button"
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",
)}
style={{
height: `${vItem.size}px`,
transform: `translateY(${vItem.start}px)`,
}}
>
<Check className={cn("mr-2 h-4 w-4 shrink-0", isSelected ? "opacity-100" : "opacity-0")} />
<span className="truncate">{o.label}</span>
</button>
);
})}
</div>
</div>
)}
</PopoverContent>
</Popover>
{showClear && ClearBtn}
</div>
);
}