ERP-node/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx

657 lines
25 KiB
TypeScript
Raw Normal View History

2025-09-29 17:24:06 +09:00
import React, { useState, useEffect, useRef, useMemo } from "react";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
2025-09-29 17:24:06 +09:00
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
2025-10-14 11:48:04 +09:00
import { cn } from "@/lib/registry/components/common/inputStyles";
2025-09-11 18:38:28 +09:00
2025-09-15 15:38:48 +09:00
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;
2025-09-15 15:38:48 +09:00
className?: string;
style?: React.CSSProperties;
onClick?: () => void;
onDragStart?: () => void;
onDragEnd?: () => void;
value?: any; // 외부에서 전달받는 값
2025-09-15 15:38:48 +09:00
[key: string]: any;
}
2025-09-11 18:38:28 +09:00
2025-09-29 17:24:06 +09:00
// ✅ React Query를 사용하여 중복 요청 방지 및 자동 캐싱 처리
// - 동일한 queryKey에 대해서는 자동으로 중복 요청 제거
// - 10분 staleTime으로 적절한 캐시 관리
// - 30분 gcTime으로 메모리 효율성 확보
2025-09-15 15:38:48 +09:00
const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
2025-09-11 18:38:28 +09:00
component,
2025-09-15 15:38:48 +09:00
componentConfig,
screenId,
onUpdate,
2025-09-11 18:38:28 +09:00
isSelected = false,
2025-09-15 15:38:48 +09:00
isDesignMode = false,
isInteractive = false,
onFormDataChange,
2025-09-15 15:38:48 +09:00
className,
style,
2025-09-11 18:38:28 +09:00
onClick,
onDragStart,
onDragEnd,
value: externalValue, // 명시적으로 value prop 받기
2025-09-11 18:38:28 +09:00
...props
}) => {
2025-09-29 17:24:06 +09:00
// 🚨 최우선 디버깅: 컴포넌트가 실행되는지 확인
console.log("🚨🚨🚨 SelectBasicComponent 실행됨!!!", {
componentId: component?.id,
componentType: component?.type,
webType: component?.webType,
tableName: component?.tableName,
columnName: component?.columnName,
screenId,
timestamp: new Date().toISOString(),
});
2025-09-15 15:38:48 +09:00
const [isOpen, setIsOpen] = useState(false);
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
const config = (props as any).webTypeConfig || componentConfig || {};
2025-10-14 11:48:04 +09:00
// webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식)
const webType = component.componentConfig?.webType || "select";
// 외부에서 전달받은 value가 있으면 우선 사용, 없으면 config.value 사용
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
2025-09-15 15:38:48 +09:00
const [selectedLabel, setSelectedLabel] = useState("");
2025-10-14 11:48:04 +09:00
// multiselect의 경우 배열로 관리
const [selectedValues, setSelectedValues] = useState<string[]>([]);
// autocomplete의 경우 검색어 관리
const [searchQuery, setSearchQuery] = useState("");
2025-09-29 17:24:06 +09:00
console.log("🔍 SelectBasicComponent 초기화 (React Query):", {
componentId: component.id,
externalValue,
componentConfigValue: componentConfig?.value,
webTypeConfigValue: (props as any).webTypeConfig?.value,
configValue: config?.value,
finalSelectedValue: externalValue || config?.value || "",
2025-09-29 17:24:06 +09:00
tableName: component.tableName,
columnName: component.columnName,
staticCodeCategory: config?.codeCategory,
// React Query 디버깅 정보
timestamp: new Date().toISOString(),
mountCount: ++(window as any).selectMountCount || ((window as any).selectMountCount = 1),
});
2025-09-29 17:24:06 +09:00
// 언마운트 시 로깅
useEffect(() => {
const componentId = component.id;
console.log(`🔍 [${componentId}] SelectBasicComponent 마운트됨`);
return () => {
console.log(`🔍 [${componentId}] SelectBasicComponent 언마운트됨`);
};
}, [component.id]);
2025-09-15 15:38:48 +09:00
const selectRef = useRef<HTMLDivElement>(null);
2025-09-29 17:24:06 +09:00
// 안정적인 쿼리 키를 위한 메모이제이션
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;
console.log(`🔑 [${component.id}] 코드 카테고리 결정:`, {
dynamicCodeCategory,
staticCodeCategory,
finalCategory: category,
});
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);
// React Query 상태 디버깅
useEffect(() => {
console.log(`🎯 [${component.id}] React Query 상태:`, {
codeCategory,
isCodeCategoryValid,
codeOptionsLength: codeOptions.length,
isLoadingCodes,
isFetching,
cacheStatus: isFetching ? "FETCHING" : "FROM_CACHE",
});
}, [component.id, codeCategory, isCodeCategoryValid, codeOptions.length, isLoadingCodes, isFetching]);
// 외부 value prop 변경 시 selectedValue 업데이트
useEffect(() => {
const newValue = externalValue || config?.value || "";
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
if (newValue !== selectedValue) {
console.log(`🔄 SelectBasicComponent value 업데이트: "${selectedValue}" → "${newValue}"`);
2025-09-22 14:13:05 +09:00
console.log("🔍 업데이트 조건 분석:", {
externalValue,
componentConfigValue: componentConfig?.value,
configValue: config?.value,
newValue,
selectedValue,
shouldUpdate: newValue !== selectedValue,
});
setSelectedValue(newValue);
}
}, [externalValue, config?.value]);
2025-09-15 15:38:48 +09:00
2025-09-29 17:24:06 +09:00
// ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거
// - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime)
// - 중복 요청 방지: 동일한 queryKey에 대해 자동 중복 제거
// - 상태 동기화: 모든 컴포넌트가 같은 캐시 공유
2025-09-15 15:38:48 +09:00
// 선택된 값에 따른 라벨 업데이트
useEffect(() => {
const getAllOptions = () => {
const configOptions = config.options || [];
2025-09-15 15:38:48 +09:00
return [...codeOptions, ...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;
console.log(`🔍 [${component.id}] 코드명으로 매치 발견: "${selectedValue}" → "${newLabel}"`);
} else {
// 2) selectedValue가 코드값인 경우라면 원래 로직대로 라벨을 찾되, 없으면 원값 표시
newLabel = selectedValue; // 코드값 그대로 표시 (예: "555")
console.log(`🔍 [${component.id}] 코드값 원본 유지: "${selectedValue}"`);
}
}
console.log(`🏷️ [${component.id}] 라벨 업데이트:`, {
selectedValue,
selectedOption: selectedOption ? { value: selectedOption.value, label: selectedOption.label } : null,
newLabel,
optionsCount: options.length,
allOptionsValues: options.map((o) => o.value),
allOptionsLabels: options.map((o) => o.label),
});
2025-09-15 15:38:48 +09:00
if (newLabel !== selectedLabel) {
setSelectedLabel(newLabel);
}
}, [selectedValue, codeOptions, config.options]);
2025-09-15 15:38:48 +09:00
2025-09-29 17:24:06 +09:00
// 클릭 이벤트 핸들러 (React Query로 간소화)
2025-09-15 15:38:48 +09:00
const handleToggle = () => {
if (isDesignMode) return;
2025-09-29 17:24:06 +09:00
console.log(`🖱️ [${component.id}] 드롭다운 토글 (React Query): ${isOpen}${!isOpen}`);
2025-09-15 15:38:48 +09:00
console.log(`📊 [${component.id}] 현재 상태:`, {
2025-09-29 17:24:06 +09:00
codeCategory,
2025-09-15 15:38:48 +09:00
isLoadingCodes,
2025-09-29 17:24:06 +09:00
codeOptionsLength: codeOptions.length,
tableName: component.tableName,
columnName: component.columnName,
2025-09-15 15:38:48 +09:00
});
2025-09-29 17:24:06 +09:00
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
2025-09-15 15:38:48 +09:00
setIsOpen(!isOpen);
2025-09-11 18:38:28 +09:00
};
2025-09-15 15:38:48 +09:00
// 옵션 선택 핸들러
const handleOptionSelect = (value: string, label: string) => {
setSelectedValue(value);
setSelectedLabel(label);
setIsOpen(false);
// 디자인 모드에서의 컴포넌트 속성 업데이트
2025-09-15 15:38:48 +09:00
if (onUpdate) {
onUpdate("value", value);
}
// 인터랙티브 모드에서 폼 데이터 업데이트 (TextInputComponent와 동일한 로직)
if (isInteractive && onFormDataChange && component.columnName) {
console.log(`📤 SelectBasicComponent -> onFormDataChange 호출: ${component.columnName} = "${value}"`);
onFormDataChange(component.columnName, value);
} else {
console.log("❌ SelectBasicComponent onFormDataChange 조건 미충족:", {
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
hasColumnName: !!component.columnName,
});
}
2025-09-15 15:38:48 +09:00
console.log(`✅ [${component.id}] 옵션 선택:`, { value, label });
};
// 외부 클릭 시 드롭다운 닫기
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]);
2025-09-29 17:24:06 +09:00
// ✅ React Query가 자동으로 처리하므로 수동 이벤트 리스너 불필요
// - refetchOnWindowFocus: true (기본값)
// - refetchOnReconnect: true (기본값)
// - staleTime으로 적절한 캐시 관리
2025-09-15 15:38:48 +09:00
// 모든 옵션 가져오기
const getAllOptions = () => {
const configOptions = config.options || [];
2025-09-15 15:38:48 +09:00
console.log(`🔧 [${component.id}] 옵션 병합:`, {
codeOptionsLength: codeOptions.length,
2025-09-29 17:24:06 +09:00
codeOptions: codeOptions.map((o: Option) => ({ value: o.value, label: o.label })),
2025-09-15 15:38:48 +09:00
configOptionsLength: configOptions.length,
2025-09-29 17:24:06 +09:00
configOptions: configOptions.map((o: Option) => ({ value: o.value, label: o.label })),
2025-09-15 15:38:48 +09:00
});
return [...codeOptions, ...configOptions];
};
const allOptions = getAllOptions();
const placeholder = componentConfig.placeholder || "선택하세요";
2025-09-11 18:38:28 +09:00
// 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);
2025-10-14 11:48:04 +09:00
// 세부 타입별 렌더링
const renderSelectByWebType = () => {
// code-radio: 라디오 버튼으로 코드 선택
if (webType === "code-radio") {
return (
<div className="flex flex-col gap-2">
{allOptions.map((option, index) => (
<label key={index} className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={component.id || "code-radio-group"}
value={option.value}
checked={selectedValue === option.value}
onChange={() => handleOptionSelect(option.value, option.label)}
disabled={isDesignMode}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-900">{option.label}</span>
</label>
))}
</div>
);
}
// code-autocomplete: 코드 자동완성
if (webType === "code-autocomplete") {
const filteredOptions = allOptions.filter(
(opt) =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
opt.value.toLowerCase().includes(searchQuery.toLowerCase()),
);
return (
<div className="w-full">
<input
type="text"
value={searchQuery || selectedLabel}
onChange={(e) => {
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 && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{filteredOptions.map((option, index) => (
<div
key={`${option.value}-${index}`}
className="cursor-pointer bg-white px-3 py-2 hover:bg-gray-100"
onClick={() => {
setSearchQuery("");
handleOptionSelect(option.value, option.label);
}}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">{option.label}</span>
<span className="text-xs text-gray-500">{option.value}</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
// code: 기본 코드 선택박스 (select와 동일)
if (webType === "code") {
return (
<div className="w-full">
<div
className={cn(
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isOpen && "border-orange-500",
)}
onClick={handleToggle}
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
<svg
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{isOpen && !isDesignMode && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{isLoadingCodes ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? (
allOptions.map((option, index) => (
<div
key={`${option.value}-${index}`}
className="cursor-pointer bg-white px-3 py-2 text-gray-900 hover:bg-gray-100"
onClick={() => handleOptionSelect(option.value, option.label)}
>
{option.label}
</div>
))
) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div>
)}
</div>
)}
</div>
);
}
// multiselect: 여러 항목 선택 (태그 형식)
if (webType === "multiselect") {
return (
<div className="w-full">
<div
className={cn(
"flex min-h-[40px] w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
)}
>
{selectedValues.map((val, idx) => {
const opt = allOptions.find((o) => o.value === val);
return (
<span key={idx} className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800">
{opt?.label || val}
<button
type="button"
onClick={() => {
const newVals = selectedValues.filter((v) => v !== val);
setSelectedValues(newVals);
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newVals.join(","));
}
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
);
})}
<input
type="text"
placeholder={selectedValues.length > 0 ? "" : placeholder}
className="min-w-[100px] flex-1 border-none bg-transparent outline-none"
onClick={() => setIsOpen(true)}
readOnly={isDesignMode}
/>
</div>
</div>
);
}
// autocomplete: 검색 기능 포함
if (webType === "autocomplete") {
const filteredOptions = allOptions.filter(
(opt) =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
opt.value.toLowerCase().includes(searchQuery.toLowerCase()),
);
return (
<div className="w-full">
<input
type="text"
value={searchQuery}
onChange={(e) => {
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 && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{filteredOptions.map((option, index) => (
<div
key={`${option.value}-${index}`}
className="cursor-pointer bg-white px-3 py-2 text-gray-900 hover:bg-gray-100"
onClick={() => {
setSearchQuery(option.label);
setSelectedValue(option.value);
setSelectedLabel(option.label);
setIsOpen(false);
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, option.value);
}
}}
>
{option.label}
</div>
))}
</div>
)}
</div>
);
}
// dropdown (검색 선택박스): 기본 select와 유사하지만 검색 가능
if (webType === "dropdown") {
return (
<div className="w-full">
<div
className={cn(
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isOpen && "border-orange-500",
)}
onClick={handleToggle}
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
<svg
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{isOpen && !isDesignMode && (
<div className="absolute z-[99999] mt-1 w-full rounded-md border border-gray-300 bg-white shadow-lg">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="검색..."
className="w-full border-b border-gray-300 px-3 py-2 outline-none"
/>
<div className="max-h-60 overflow-auto">
{allOptions
.filter(
(opt) =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
opt.value.toLowerCase().includes(searchQuery.toLowerCase()),
)
.map((option, index) => (
<div
key={`${option.value}-${index}`}
className="cursor-pointer bg-white px-3 py-2 text-gray-900 hover:bg-gray-100"
onClick={() => handleOptionSelect(option.value, option.label)}
>
{option.label || option.value}
</div>
))}
</div>
</div>
)}
</div>
);
}
// select (기본 선택박스)
return (
<div className="w-full">
<div
className={cn(
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isOpen && "border-orange-500",
)}
onClick={handleToggle}
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
<svg
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{isOpen && !isDesignMode && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{isLoadingCodes ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? (
allOptions.map((option, index) => (
<div
key={`${option.value}-${index}`}
className="cursor-pointer bg-white px-3 py-2 text-gray-900 hover:bg-gray-100"
onClick={() => handleOptionSelect(option.value, option.label)}
>
{option.label || option.value}
</div>
))
) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div>
)}
</div>
)}
</div>
);
};
2025-09-11 18:38:28 +09:00
return (
2025-09-15 15:38:48 +09:00
<div
ref={selectRef}
className={`relative w-full ${className || ""}`}
style={style}
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...safeDomProps}
2025-09-15 15:38:48 +09:00
>
{/* 라벨 렌더링 */}
2025-09-30 18:42:33 +09:00
{component.label && (component.style?.labelDisplay ?? true) && (
2025-10-14 11:48:04 +09:00
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
{component.label}
2025-10-14 11:48:04 +09:00
{component.required && <span className="text-red-500">*</span>}
</label>
)}
2025-10-14 11:48:04 +09:00
{/* 세부 타입별 UI 렌더링 */}
{renderSelectByWebType()}
2025-09-11 18:38:28 +09:00
</div>
);
};
2025-09-15 15:38:48 +09:00
// Wrapper 컴포넌트 (기존 호환성을 위해)
export const SelectBasicWrapper = SelectBasicComponent;
// 기본 export
export { SelectBasicComponent };