import React, { useState, useEffect, useRef, useMemo } from "react"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes"; import { cn } from "@/lib/registry/components/common/inputStyles"; interface Option { value: string; label: string; } export interface SelectBasicComponentProps { component: any; componentConfig: any; screenId?: number; onUpdate?: (field: string, value: any) => void; isSelected?: boolean; isDesignMode?: boolean; isInteractive?: boolean; onFormDataChange?: (fieldName: string, value: any) => void; className?: string; style?: React.CSSProperties; onClick?: () => void; onDragStart?: () => void; onDragEnd?: () => void; value?: any; // 외부에서 전달받는 값 menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용) [key: string]: any; } // ✅ React Query를 사용하여 중복 요청 방지 및 자동 캐싱 처리 // - 동일한 queryKey에 대해서는 자동으로 중복 요청 제거 // - 10분 staleTime으로 적절한 캐시 관리 // - 30분 gcTime으로 메모리 효율성 확보 const SelectBasicComponent: React.FC = ({ component, componentConfig, screenId, onUpdate, isSelected = false, isDesignMode = false, isInteractive = false, onFormDataChange, className, style, onClick, onDragStart, onDragEnd, value: externalValue, // 명시적으로 value prop 받기 menuObjid, // 🆕 메뉴 OBJID ...props }) => { // 🚨 최초 렌더링 확인용 (테스트 후 제거) console.log("🚨🚨🚨 [SelectBasicComponent] 렌더링됨!!!!", { componentId: component.id, componentType: (component as any).componentType, columnName: (component as any).columnName, "props.multiple": (props as any).multiple, "componentConfig.multiple": componentConfig?.multiple, }); const [isOpen, setIsOpen] = useState(false); // webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성) const config = (props as any).webTypeConfig || componentConfig || {}; // 🆕 multiple 값: props.multiple (spread된 값) > config.multiple 순서로 우선순위 const isMultiple = (props as any).multiple ?? config?.multiple ?? false; // 🔍 디버깅: config 및 multiple 확인 useEffect(() => { console.log("🔍 [SelectBasicComponent] ========== 다중선택 디버깅 =========="); console.log(" 컴포넌트 ID:", component.id); console.log(" 최종 isMultiple 값:", isMultiple); console.log(" ----------------------------------------"); console.log(" props.multiple:", (props as any).multiple); console.log(" config.multiple:", config?.multiple); console.log(" componentConfig.multiple:", componentConfig?.multiple); console.log(" component.componentConfig.multiple:", component.componentConfig?.multiple); console.log(" ----------------------------------------"); console.log(" config 전체:", config); console.log(" componentConfig 전체:", componentConfig); console.log(" component.componentConfig 전체:", component.componentConfig); console.log(" ======================================="); // 다중선택이 활성화되었는지 알림 if (isMultiple) { console.log("✅ 다중선택 모드 활성화됨!"); } else { console.log("❌ 단일선택 모드 (다중선택 비활성화)"); } }, [(props as any).multiple, config?.multiple, componentConfig?.multiple, component.componentConfig?.multiple]); // webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식) const webType = component.componentConfig?.webType || "select"; // 외부에서 전달받은 value가 있으면 우선 사용, 없으면 config.value 사용 const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || ""); const [selectedLabel, setSelectedLabel] = useState(""); // multiselect의 경우 배열로 관리 (콤마 구분자로 파싱) const [selectedValues, setSelectedValues] = useState(() => { const initialValue = externalValue || config?.value || ""; if (isMultiple && typeof initialValue === "string" && initialValue) { return initialValue.split(",").map(v => v.trim()).filter(v => v); } return []; }); // autocomplete의 경우 검색어 관리 const [searchQuery, setSearchQuery] = useState(""); const selectRef = useRef(null); // 안정적인 쿼리 키를 위한 메모이제이션 const stableTableName = useMemo(() => component.tableName, [component.tableName]); const stableColumnName = useMemo(() => component.columnName, [component.columnName]); const staticCodeCategory = useMemo(() => config?.codeCategory, [config?.codeCategory]); // 🚀 React Query: 테이블 코드 카테고리 조회 const { data: dynamicCodeCategory } = useTableCodeCategory(stableTableName, stableColumnName); // 코드 카테고리 결정: 동적 카테고리 > 설정 카테고리 (메모이제이션) const codeCategory = useMemo(() => { const category = dynamicCodeCategory || staticCodeCategory; return category; }, [dynamicCodeCategory, staticCodeCategory, component.id]); // 🚀 React Query: 코드 옵션 조회 (안정적인 enabled 조건) const isCodeCategoryValid = useMemo(() => { return !!codeCategory && codeCategory !== "none"; }, [codeCategory]); const { options: codeOptions, isLoading: isLoadingCodes, isFetching, } = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid); // 🆕 카테고리 타입 (category webType)을 위한 옵션 로딩 const [categoryOptions, setCategoryOptions] = useState([]); const [isLoadingCategories, setIsLoadingCategories] = useState(false); useEffect(() => { if (webType === "category" && component.tableName && component.columnName) { console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", { tableName: component.tableName, columnName: component.columnName, webType, }); setIsLoadingCategories(true); import("@/lib/api/tableCategoryValue").then(({ getCategoryValues }) => { getCategoryValues(component.tableName!, component.columnName!) .then((response) => { console.log("🔍 [SelectBasic] 카테고리 API 응답:", response); if (response.success && response.data) { console.log("🔍 [SelectBasic] 원본 데이터 샘플:", { firstItem: response.data[0], keys: response.data[0] ? Object.keys(response.data[0]) : [], }); const activeValues = response.data.filter((v) => v.isActive !== false); const options = activeValues.map((v) => ({ value: v.valueCode, label: v.valueLabel || v.valueCode, })); console.log("✅ [SelectBasic] 카테고리 옵션 설정:", { activeValuesCount: activeValues.length, options, sampleOption: options[0], }); setCategoryOptions(options); } else { console.error("❌ [SelectBasic] 카테고리 응답 실패:", response); } }) .catch((error) => { console.error("❌ [SelectBasic] 카테고리 값 조회 실패:", error); }) .finally(() => { setIsLoadingCategories(false); }); }); } }, [webType, component.tableName, component.columnName]); // 디버깅: menuObjid가 제대로 전달되는지 확인 useEffect(() => { if (codeCategory && codeCategory !== "none") { console.log(`🎯 [SelectBasicComponent ${component.id}] 코드 옵션 로드:`, { codeCategory, menuObjid, hasMenuObjid: !!menuObjid, isCodeCategoryValid, codeOptionsCount: codeOptions.length, isLoading: isLoadingCodes, }); } }, [component.id, codeCategory, menuObjid, codeOptions.length, isLoadingCodes, isCodeCategoryValid]); // 외부 value prop 변경 시 selectedValue 업데이트 useEffect(() => { const newValue = externalValue || config?.value || ""; console.log("🔍 [SelectBasic] 외부 값 변경 감지:", { componentId: component.id, columnName: (component as any).columnName, isMultiple, newValue, selectedValue, selectedValues, externalValue, "config.value": config?.value, }); // 다중선택 모드인 경우 if (isMultiple) { if (typeof newValue === "string" && newValue) { const values = newValue.split(",").map(v => v.trim()).filter(v => v); const currentValuesStr = selectedValues.join(","); if (newValue !== currentValuesStr) { console.log("✅ [SelectBasic] 다중선택 값 업데이트:", { from: selectedValues, to: values, }); setSelectedValues(values); } } else if (!newValue && selectedValues.length > 0) { console.log("✅ [SelectBasic] 다중선택 값 초기화"); setSelectedValues([]); } } else { // 단일선택 모드인 경우 if (newValue !== selectedValue) { setSelectedValue(newValue); } } }, [externalValue, config?.value, isMultiple]); // ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거 // - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime) // - 중복 요청 방지: 동일한 queryKey에 대해 자동 중복 제거 // - 상태 동기화: 모든 컴포넌트가 같은 캐시 공유 // 선택된 값에 따른 라벨 업데이트 useEffect(() => { const getAllOptions = () => { const configOptions = config.options || []; return [...codeOptions, ...categoryOptions, ...configOptions]; }; const options = getAllOptions(); const selectedOption = options.find((option) => option.value === selectedValue); // 🎯 코드 타입의 경우 코드값과 코드명을 모두 고려하여 라벨 찾기 let newLabel = selectedOption?.label || ""; // selectedOption이 없고 selectedValue가 있다면, 코드명으로도 검색해보기 if (!selectedOption && selectedValue && codeOptions.length > 0) { // 1) selectedValue가 코드명인 경우 (예: "국내") const labelMatch = options.find((option) => option.label === selectedValue); if (labelMatch) { newLabel = labelMatch.label; } else { // 2) selectedValue가 코드값인 경우라면 원래 로직대로 라벨을 찾되, 없으면 원값 표시 newLabel = selectedValue; // 코드값 그대로 표시 (예: "555") } } if (newLabel !== selectedLabel) { setSelectedLabel(newLabel); } }, [selectedValue, codeOptions, config.options]); // 클릭 이벤트 핸들러 (React Query로 간소화) const handleToggle = () => { if (isDesignMode) return; // React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요 setIsOpen(!isOpen); }; // 옵션 선택 핸들러 const handleOptionSelect = (value: string, label: string) => { setSelectedValue(value); setSelectedLabel(label); setIsOpen(false); // 디자인 모드에서의 컴포넌트 속성 업데이트 if (onUpdate) { onUpdate("value", value); } // 인터랙티브 모드에서 폼 데이터 업데이트 (TextInputComponent와 동일한 로직) if (isInteractive && onFormDataChange && component.columnName) { onFormDataChange(component.columnName, value); } }; // 외부 클릭 시 드롭다운 닫기 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (selectRef.current && !selectRef.current.contains(event.target as Node)) { setIsOpen(false); } }; if (isOpen) { document.addEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [isOpen]); // ✅ React Query가 자동으로 처리하므로 수동 이벤트 리스너 불필요 // - refetchOnWindowFocus: true (기본값) // - refetchOnReconnect: true (기본값) // - staleTime으로 적절한 캐시 관리 // 모든 옵션 가져오기 const getAllOptions = () => { const configOptions = config.options || []; return [...codeOptions, ...categoryOptions, ...configOptions]; }; const allOptions = getAllOptions(); const placeholder = componentConfig.placeholder || "선택하세요"; // 🔍 디버깅: 최종 옵션 확인 useEffect(() => { if (webType === "category" && allOptions.length > 0) { console.log("🔍 [SelectBasic] 최종 allOptions:", { count: allOptions.length, categoryOptionsCount: categoryOptions.length, codeOptionsCount: codeOptions.length, sampleOptions: allOptions.slice(0, 3), }); } }, [webType, allOptions.length, categoryOptions.length, codeOptions.length]); // DOM props에서 React 전용 props 필터링 const { component: _component, componentConfig: _componentConfig, screenId: _screenId, onUpdate: _onUpdate, isSelected: _isSelected, isDesignMode: _isDesignMode, className: _className, style: _style, onClick: _onClick, onDragStart: _onDragStart, onDragEnd: _onDragEnd, ...otherProps } = props; const safeDomProps = filterDOMProps(otherProps); // 세부 타입별 렌더링 const renderSelectByWebType = () => { // code-radio: 라디오 버튼으로 코드 선택 if (webType === "code-radio") { return (
{allOptions.map((option, index) => ( ))}
); } // code-autocomplete: 코드 자동완성 if (webType === "code-autocomplete") { const filteredOptions = allOptions.filter( (opt) => opt.label.toLowerCase().includes(searchQuery.toLowerCase()) || opt.value.toLowerCase().includes(searchQuery.toLowerCase()), ); return (
{ setSearchQuery(e.target.value); setIsOpen(true); }} onFocus={() => setIsOpen(true)} placeholder="코드 또는 코드명 입력..." className={cn( "h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none", !isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200", isSelected && "ring-2 ring-orange-500", )} readOnly={isDesignMode} /> {isOpen && !isDesignMode && filteredOptions.length > 0 && (
{filteredOptions.map((option, index) => (
{ setSearchQuery(""); handleOptionSelect(option.value, option.label); }} >
{option.label} {option.value}
))}
)}
); } // code: 기본 코드 선택박스 (select와 동일) if (webType === "code") { return (
{selectedLabel || placeholder}
{isOpen && !isDesignMode && (
{isLoadingCodes ? (
로딩 중...
) : allOptions.length > 0 ? ( allOptions.map((option, index) => (
handleOptionSelect(option.value, option.label)} > {option.label}
)) ) : (
옵션이 없습니다
)}
)}
); } // multiselect: 여러 항목 선택 (태그 형식) if (webType === "multiselect") { return (
{selectedValues.map((val, idx) => { const opt = allOptions.find((o) => o.value === val); return ( {opt?.label || val} ); })} 0 ? "" : placeholder} className="min-w-[100px] flex-1 border-none bg-transparent outline-none" onClick={() => setIsOpen(true)} readOnly={isDesignMode} />
); } // autocomplete: 검색 기능 포함 if (webType === "autocomplete") { const filteredOptions = allOptions.filter( (opt) => opt.label.toLowerCase().includes(searchQuery.toLowerCase()) || opt.value.toLowerCase().includes(searchQuery.toLowerCase()), ); return (
{ setSearchQuery(e.target.value); setIsOpen(true); }} onFocus={() => setIsOpen(true)} placeholder={placeholder} className={cn( "h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none", !isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200", isSelected && "ring-2 ring-orange-500", )} readOnly={isDesignMode} /> {isOpen && !isDesignMode && filteredOptions.length > 0 && (
{filteredOptions.map((option, index) => (
{ setSearchQuery(option.label); setSelectedValue(option.value); setSelectedLabel(option.label); setIsOpen(false); if (isInteractive && onFormDataChange && component.columnName) { onFormDataChange(component.columnName, option.value); } }} > {option.label}
))}
)}
); } // dropdown (검색 선택박스): 기본 select와 유사하지만 검색 가능 if (webType === "dropdown") { return (
{selectedLabel || placeholder}
{isOpen && !isDesignMode && (
setSearchQuery(e.target.value)} placeholder="검색..." className="w-full border-b border-gray-300 px-3 py-2 outline-none" />
{allOptions .filter( (opt) => opt.label.toLowerCase().includes(searchQuery.toLowerCase()) || opt.value.toLowerCase().includes(searchQuery.toLowerCase()), ) .map((option, index) => (
handleOptionSelect(option.value, option.label)} > {option.label || option.value}
))}
)}
); } // select (기본 선택박스) // 다중선택 모드인 경우 if (isMultiple) { return (
!isDesignMode && setIsOpen(true)} style={{ pointerEvents: isDesignMode ? "none" : "auto", height: "100%" }} > {selectedValues.map((val, idx) => { const opt = allOptions.find((o) => o.value === val); return ( {opt?.label || val} ); })} {selectedValues.length === 0 && ( {placeholder} )}
{isOpen && !isDesignMode && (
{(isLoadingCodes || isLoadingCategories) ? (
로딩 중...
) : allOptions.length > 0 ? ( allOptions.map((option, index) => { const isSelected = selectedValues.includes(option.value); return (
{ const newVals = isSelected ? selectedValues.filter((v) => v !== option.value) : [...selectedValues, option.value]; setSelectedValues(newVals); const newValue = newVals.join(","); if (isInteractive && onFormDataChange && component.columnName) { onFormDataChange(component.columnName, newValue); } }} >
{}} className="h-4 w-4" /> {option.label || option.value}
); }) ) : (
옵션이 없습니다
)}
)}
); } // 단일선택 모드 return (
{selectedLabel || placeholder}
{isOpen && !isDesignMode && (
{isLoadingCodes ? (
로딩 중...
) : allOptions.length > 0 ? ( allOptions.map((option, index) => (
handleOptionSelect(option.value, option.label)} > {option.label || option.value}
)) ) : (
옵션이 없습니다
)}
)}
); }; return (
{/* 라벨 렌더링 */} {component.label && (component.style?.labelDisplay ?? true) && ( )} {/* 세부 타입별 UI 렌더링 */} {renderSelectByWebType()}
); }; // Wrapper 컴포넌트 (기존 호환성을 위해) export const SelectBasicWrapper = SelectBasicComponent; // 기본 export export { SelectBasicComponent };