2025-09-29 17:24:06 +09:00
|
|
|
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
2025-09-19 02:15:21 +09:00
|
|
|
|
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-11-28 14:56:11 +09:00
|
|
|
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
|
|
|
|
|
import type { DataProvidable } from "@/types/data-transfer";
|
2025-12-10 13:53:44 +09:00
|
|
|
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
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;
|
2025-09-19 02:15:21 +09:00
|
|
|
|
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;
|
2025-09-19 02:15:21 +09:00
|
|
|
|
value?: any; // 외부에서 전달받는 값
|
2025-11-11 15:25:07 +09:00
|
|
|
|
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
|
2025-12-10 13:53:44 +09:00
|
|
|
|
formData?: Record<string, 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,
|
2025-09-19 02:15:21 +09:00
|
|
|
|
isInteractive = false,
|
|
|
|
|
|
onFormDataChange,
|
2025-09-15 15:38:48 +09:00
|
|
|
|
className,
|
|
|
|
|
|
style,
|
2025-09-11 18:38:28 +09:00
|
|
|
|
onClick,
|
|
|
|
|
|
onDragStart,
|
|
|
|
|
|
onDragEnd,
|
2025-09-19 02:15:21 +09:00
|
|
|
|
value: externalValue, // 명시적으로 value prop 받기
|
2025-11-11 15:25:07 +09:00
|
|
|
|
menuObjid, // 🆕 메뉴 OBJID
|
2025-12-10 13:53:44 +09:00
|
|
|
|
formData, // 🆕 폼 데이터 (연쇄 드롭다운용)
|
2025-09-11 18:38:28 +09:00
|
|
|
|
...props
|
|
|
|
|
|
}) => {
|
2025-12-04 10:39:07 +09:00
|
|
|
|
// 🆕 읽기전용/비활성화 상태 확인
|
|
|
|
|
|
const isReadonly = (component as any).readonly || (props as any).readonly || componentConfig?.readonly || false;
|
|
|
|
|
|
const isDisabled = (component as any).disabled || (props as any).disabled || componentConfig?.disabled || false;
|
|
|
|
|
|
const isFieldDisabled = isDesignMode || isReadonly || isDisabled;
|
2025-11-28 14:56:11 +09:00
|
|
|
|
// 화면 컨텍스트 (데이터 제공자로 등록)
|
|
|
|
|
|
const screenContext = useScreenContextOptional();
|
|
|
|
|
|
|
2025-11-20 18:26:19 +09:00
|
|
|
|
// 🚨 최초 렌더링 확인용 (테스트 후 제거)
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-09-15 15:38:48 +09:00
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
2025-09-19 02:15:21 +09:00
|
|
|
|
|
|
|
|
|
|
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
|
|
|
|
|
const config = (props as any).webTypeConfig || componentConfig || {};
|
|
|
|
|
|
|
2025-11-20 18:21:09 +09:00
|
|
|
|
// 🆕 multiple 값: props.multiple (spread된 값) > config.multiple 순서로 우선순위
|
|
|
|
|
|
const isMultiple = (props as any).multiple ?? config?.multiple ?? false;
|
|
|
|
|
|
|
|
|
|
|
|
// 🔍 디버깅: config 및 multiple 확인
|
|
|
|
|
|
useEffect(() => {
|
2025-11-20 18:23:29 +09:00
|
|
|
|
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("❌ 단일선택 모드 (다중선택 비활성화)");
|
|
|
|
|
|
}
|
2025-11-20 18:21:09 +09:00
|
|
|
|
}, [(props as any).multiple, config?.multiple, componentConfig?.multiple, component.componentConfig?.multiple]);
|
|
|
|
|
|
|
2025-10-14 11:48:04 +09:00
|
|
|
|
// webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식)
|
|
|
|
|
|
const webType = component.componentConfig?.webType || "select";
|
|
|
|
|
|
|
2025-09-19 02:15:21 +09:00
|
|
|
|
// 외부에서 전달받은 value가 있으면 우선 사용, 없으면 config.value 사용
|
|
|
|
|
|
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
|
2025-09-15 15:38:48 +09:00
|
|
|
|
const [selectedLabel, setSelectedLabel] = useState("");
|
2025-09-19 02:15:21 +09:00
|
|
|
|
|
2025-11-20 18:17:08 +09:00
|
|
|
|
// multiselect의 경우 배열로 관리 (콤마 구분자로 파싱)
|
|
|
|
|
|
const [selectedValues, setSelectedValues] = useState<string[]>(() => {
|
|
|
|
|
|
const initialValue = externalValue || config?.value || "";
|
2025-11-20 18:21:09 +09:00
|
|
|
|
if (isMultiple && typeof initialValue === "string" && initialValue) {
|
2025-11-20 18:17:08 +09:00
|
|
|
|
return initialValue.split(",").map(v => v.trim()).filter(v => v);
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
});
|
2025-10-14 11:48:04 +09:00
|
|
|
|
|
|
|
|
|
|
// autocomplete의 경우 검색어 관리
|
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
|
|
|
2025-09-29 17:24:06 +09:00
|
|
|
|
|
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;
|
|
|
|
|
|
return category;
|
|
|
|
|
|
}, [dynamicCodeCategory, staticCodeCategory, component.id]);
|
|
|
|
|
|
|
|
|
|
|
|
// 🚀 React Query: 코드 옵션 조회 (안정적인 enabled 조건)
|
|
|
|
|
|
const isCodeCategoryValid = useMemo(() => {
|
|
|
|
|
|
return !!codeCategory && codeCategory !== "none";
|
|
|
|
|
|
}, [codeCategory]);
|
|
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
|
options: codeOptions,
|
|
|
|
|
|
isLoading: isLoadingCodes,
|
|
|
|
|
|
isFetching,
|
2025-11-11 15:25:07 +09:00
|
|
|
|
} = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid);
|
2025-09-29 17:24:06 +09:00
|
|
|
|
|
2025-11-20 18:35:48 +09:00
|
|
|
|
// 🆕 카테고리 타입 (category webType)을 위한 옵션 로딩
|
|
|
|
|
|
const [categoryOptions, setCategoryOptions] = useState<Option[]>([]);
|
|
|
|
|
|
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
|
|
|
|
|
|
2025-12-10 13:53:44 +09:00
|
|
|
|
// 🆕 연쇄 드롭다운 설정 확인
|
|
|
|
|
|
const cascadingRelationCode = config?.cascadingRelationCode || componentConfig?.cascadingRelationCode;
|
2025-12-18 14:12:48 +09:00
|
|
|
|
// 🆕 카테고리 값 연쇄관계 설정
|
|
|
|
|
|
const categoryRelationCode = config?.categoryRelationCode || componentConfig?.categoryRelationCode;
|
2025-12-10 13:53:44 +09:00
|
|
|
|
const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child";
|
|
|
|
|
|
const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField;
|
2025-12-18 14:12:48 +09:00
|
|
|
|
|
|
|
|
|
|
// 🆕 자식 역할일 때 부모 값 추출 (단일 또는 다중)
|
|
|
|
|
|
const rawParentValue = cascadingRole === "child" && cascadingParentField && formData
|
2025-12-10 13:53:44 +09:00
|
|
|
|
? formData[cascadingParentField]
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
2025-12-18 14:12:48 +09:00
|
|
|
|
// 🆕 부모값이 콤마로 구분된 문자열이면 배열로 변환 (다중 선택 지원)
|
|
|
|
|
|
const parentValues: string[] | undefined = useMemo(() => {
|
|
|
|
|
|
if (!rawParentValue) return undefined;
|
|
|
|
|
|
|
|
|
|
|
|
// 이미 배열인 경우
|
|
|
|
|
|
if (Array.isArray(rawParentValue)) {
|
|
|
|
|
|
return rawParentValue.map(v => String(v)).filter(v => v);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 콤마로 구분된 문자열인 경우
|
|
|
|
|
|
const strValue = String(rawParentValue);
|
|
|
|
|
|
if (strValue.includes(',')) {
|
|
|
|
|
|
return strValue.split(',').map(v => v.trim()).filter(v => v);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 단일 값
|
|
|
|
|
|
return [strValue];
|
|
|
|
|
|
}, [rawParentValue]);
|
|
|
|
|
|
|
|
|
|
|
|
// 🆕 연쇄 드롭다운 훅 사용 (역할에 따라 다른 옵션 로드) - 다중 부모값 지원
|
2025-12-10 13:53:44 +09:00
|
|
|
|
const {
|
|
|
|
|
|
options: cascadingOptions,
|
|
|
|
|
|
loading: isLoadingCascading,
|
|
|
|
|
|
} = useCascadingDropdown({
|
|
|
|
|
|
relationCode: cascadingRelationCode,
|
2025-12-18 14:12:48 +09:00
|
|
|
|
categoryRelationCode: categoryRelationCode, // 🆕 카테고리 값 연쇄관계 지원
|
2025-12-10 13:53:44 +09:00
|
|
|
|
role: cascadingRole, // 부모/자식 역할 전달
|
2025-12-18 14:12:48 +09:00
|
|
|
|
parentValues: parentValues, // 다중 부모값
|
2025-12-10 13:53:44 +09:00
|
|
|
|
});
|
2025-12-18 14:12:48 +09:00
|
|
|
|
|
|
|
|
|
|
// 🆕 카테고리 값 연쇄관계가 활성화되었는지 확인
|
|
|
|
|
|
const hasCategoryRelation = !!categoryRelationCode;
|
2025-12-10 13:53:44 +09:00
|
|
|
|
|
2025-11-20 18:35:48 +09:00
|
|
|
|
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) {
|
2025-11-21 09:39:09 +09:00
|
|
|
|
console.log("🔍 [SelectBasic] 원본 데이터 샘플:", {
|
|
|
|
|
|
firstItem: response.data[0],
|
|
|
|
|
|
keys: response.data[0] ? Object.keys(response.data[0]) : [],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-20 18:35:48 +09:00
|
|
|
|
const activeValues = response.data.filter((v) => v.isActive !== false);
|
|
|
|
|
|
const options = activeValues.map((v) => ({
|
2025-11-21 09:40:24 +09:00
|
|
|
|
value: v.valueCode,
|
|
|
|
|
|
label: v.valueLabel || v.valueCode,
|
2025-11-20 18:35:48 +09:00
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-11-11 16:28:17 +09:00
|
|
|
|
// 디버깅: menuObjid가 제대로 전달되는지 확인
|
2025-09-29 17:24:06 +09:00
|
|
|
|
useEffect(() => {
|
2025-11-11 16:28:17 +09:00
|
|
|
|
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]);
|
2025-09-19 02:15:21 +09:00
|
|
|
|
|
|
|
|
|
|
// 외부 value prop 변경 시 selectedValue 업데이트
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const newValue = externalValue || config?.value || "";
|
2025-11-21 10:03:26 +09:00
|
|
|
|
|
|
|
|
|
|
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) {
|
2025-11-20 18:17:08 +09:00
|
|
|
|
const values = newValue.split(",").map(v => v.trim()).filter(v => v);
|
2025-11-21 10:03:26 +09:00
|
|
|
|
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);
|
2025-11-20 18:17:08 +09:00
|
|
|
|
}
|
2025-09-19 02:15:21 +09:00
|
|
|
|
}
|
2025-11-20 18:21:09 +09:00
|
|
|
|
}, [externalValue, config?.value, isMultiple]);
|
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
|
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
|
// 📦 DataProvidable 인터페이스 구현 (데이터 전달 시 셀렉트 값 제공)
|
|
|
|
|
|
const dataProvider: DataProvidable = {
|
|
|
|
|
|
componentId: component.id,
|
|
|
|
|
|
componentType: "select",
|
|
|
|
|
|
|
|
|
|
|
|
getSelectedData: () => {
|
|
|
|
|
|
// 현재 선택된 값을 배열로 반환
|
|
|
|
|
|
const fieldName = component.columnName || "selectedValue";
|
|
|
|
|
|
return [{
|
|
|
|
|
|
[fieldName]: selectedValue,
|
|
|
|
|
|
value: selectedValue,
|
|
|
|
|
|
label: selectedLabel,
|
|
|
|
|
|
}];
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
getAllData: () => {
|
|
|
|
|
|
// 모든 옵션 반환
|
|
|
|
|
|
const configOptions = config.options || [];
|
|
|
|
|
|
return [...codeOptions, ...categoryOptions, ...configOptions];
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
clearSelection: () => {
|
|
|
|
|
|
setSelectedValue("");
|
|
|
|
|
|
setSelectedLabel("");
|
|
|
|
|
|
if (isMultiple) {
|
|
|
|
|
|
setSelectedValues([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 화면 컨텍스트에 데이터 제공자로 등록
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (screenContext && component.id) {
|
|
|
|
|
|
screenContext.registerDataProvider(component.id, dataProvider);
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
screenContext.unregisterDataProvider(component.id);
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [screenContext, component.id, selectedValue, selectedLabel, selectedValues]);
|
|
|
|
|
|
|
2025-09-15 15:38:48 +09:00
|
|
|
|
// 선택된 값에 따른 라벨 업데이트
|
|
|
|
|
|
useEffect(() => {
|
2025-12-10 13:53:44 +09:00
|
|
|
|
const getAllOptionsForLabel = () => {
|
2025-12-18 14:12:48 +09:00
|
|
|
|
// 🆕 카테고리 값 연쇄관계가 설정된 경우 연쇄 옵션 사용
|
|
|
|
|
|
if (categoryRelationCode) {
|
|
|
|
|
|
return cascadingOptions;
|
|
|
|
|
|
}
|
2025-12-10 13:53:44 +09:00
|
|
|
|
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
|
|
|
|
|
if (cascadingRelationCode) {
|
|
|
|
|
|
return cascadingOptions;
|
|
|
|
|
|
}
|
2025-09-19 02:15:21 +09:00
|
|
|
|
const configOptions = config.options || [];
|
2025-11-20 18:35:48 +09:00
|
|
|
|
return [...codeOptions, ...categoryOptions, ...configOptions];
|
2025-09-15 15:38:48 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-10 13:53:44 +09:00
|
|
|
|
const options = getAllOptionsForLabel();
|
2025-09-15 15:38:48 +09:00
|
|
|
|
const selectedOption = options.find((option) => option.value === selectedValue);
|
2025-09-19 15:22:25 +09:00
|
|
|
|
|
|
|
|
|
|
// 🎯 코드 타입의 경우 코드값과 코드명을 모두 고려하여 라벨 찾기
|
|
|
|
|
|
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")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-15 15:38:48 +09:00
|
|
|
|
if (newLabel !== selectedLabel) {
|
|
|
|
|
|
setSelectedLabel(newLabel);
|
|
|
|
|
|
}
|
2025-12-18 14:12:48 +09:00
|
|
|
|
}, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode, categoryRelationCode]);
|
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 = () => {
|
2025-12-04 10:39:07 +09:00
|
|
|
|
if (isFieldDisabled) return; // 🆕 읽기전용/비활성화 상태에서는 토글 불가
|
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-19 02:15:21 +09:00
|
|
|
|
// 디자인 모드에서의 컴포넌트 속성 업데이트
|
2025-09-15 15:38:48 +09:00
|
|
|
|
if (onUpdate) {
|
|
|
|
|
|
onUpdate("value", value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-19 02:15:21 +09:00
|
|
|
|
// 인터랙티브 모드에서 폼 데이터 업데이트 (TextInputComponent와 동일한 로직)
|
|
|
|
|
|
if (isInteractive && onFormDataChange && component.columnName) {
|
|
|
|
|
|
onFormDataChange(component.columnName, value);
|
|
|
|
|
|
}
|
2025-09-15 15:38:48 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 외부 클릭 시 드롭다운 닫기
|
|
|
|
|
|
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 = () => {
|
2025-12-18 14:12:48 +09:00
|
|
|
|
// 🆕 카테고리 값 연쇄관계가 설정된 경우 연쇄 옵션 사용
|
|
|
|
|
|
if (categoryRelationCode) {
|
|
|
|
|
|
return cascadingOptions;
|
|
|
|
|
|
}
|
2025-12-10 13:53:44 +09:00
|
|
|
|
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
|
|
|
|
|
if (cascadingRelationCode) {
|
|
|
|
|
|
return cascadingOptions;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-19 02:15:21 +09:00
|
|
|
|
const configOptions = config.options || [];
|
2025-11-20 18:35:48 +09:00
|
|
|
|
return [...codeOptions, ...categoryOptions, ...configOptions];
|
2025-09-15 15:38:48 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const allOptions = getAllOptions();
|
|
|
|
|
|
const placeholder = componentConfig.placeholder || "선택하세요";
|
2025-09-11 18:38:28 +09:00
|
|
|
|
|
2025-11-20 18:35:48 +09:00
|
|
|
|
// 🔍 디버깅: 최종 옵션 확인
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-09-19 02:15:21 +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)}
|
2025-12-04 10:39:07 +09:00
|
|
|
|
disabled={isFieldDisabled}
|
2025-10-17 16:21:08 +09:00
|
|
|
|
className="border-input text-primary focus:ring-ring h-4 w-4"
|
2025-10-14 11:48:04 +09:00
|
|
|
|
/>
|
2025-10-17 16:21:08 +09:00
|
|
|
|
<span className="text-sm">{option.label}</span>
|
2025-10-14 11:48:04 +09:00
|
|
|
|
</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);
|
|
|
|
|
|
}}
|
2025-12-01 18:35:55 +09:00
|
|
|
|
onFocus={() => setIsOpen(true)}
|
2025-10-14 11:48:04 +09:00
|
|
|
|
placeholder="코드 또는 코드명 입력..."
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
2025-12-04 10:39:07 +09:00
|
|
|
|
!isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
|
2025-10-14 11:48:04 +09:00
|
|
|
|
isSelected && "ring-2 ring-orange-500",
|
2025-12-04 10:39:07 +09:00
|
|
|
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
2025-10-14 11:48:04 +09:00
|
|
|
|
)}
|
2025-12-04 10:39:07 +09:00
|
|
|
|
readOnly={isFieldDisabled}
|
|
|
|
|
|
disabled={isFieldDisabled}
|
2025-10-14 11:48:04 +09:00
|
|
|
|
/>
|
2025-12-04 10:39:07 +09:00
|
|
|
|
{isOpen && !isFieldDisabled && filteredOptions.length > 0 && (
|
2025-12-01 18:35:55 +09:00
|
|
|
|
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
2025-10-14 11:48:04 +09:00
|
|
|
|
{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>
|
|
|
|
|
|
))}
|
2025-12-01 18:35:55 +09:00
|
|
|
|
</div>
|
2025-10-14 11:48:04 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// code: 기본 코드 선택박스 (select와 동일)
|
|
|
|
|
|
if (webType === "code") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="w-full">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
2025-12-04 10:39:07 +09:00
|
|
|
|
"flex h-10 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
|
|
|
|
|
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
|
2025-10-14 11:48:04 +09:00
|
|
|
|
isSelected && "ring-2 ring-orange-500",
|
|
|
|
|
|
isOpen && "border-orange-500",
|
2025-12-04 10:39:07 +09:00
|
|
|
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
2025-10-14 11:48:04 +09:00
|
|
|
|
)}
|
|
|
|
|
|
onClick={handleToggle}
|
2025-12-04 10:39:07 +09:00
|
|
|
|
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
|
2025-10-14 11:48:04 +09:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
2025-12-04 10:39:07 +09:00
|
|
|
|
{isOpen && !isFieldDisabled && (
|
2025-12-01 18:35:55 +09:00
|
|
|
|
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
2025-10-14 11:48:04 +09:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2025-12-01 18:35:55 +09:00
|
|
|
|
</div>
|
2025-10-14 11:48:04 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// multiselect: 여러 항목 선택 (태그 형식)
|
|
|
|
|
|
if (webType === "multiselect") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="w-full">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
2025-10-14 17:40:51 +09:00
|
|
|
|
"box-border flex h-full w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
|
2025-12-04 10:39:07 +09:00
|
|
|
|
!isFieldDisabled && "hover:border-orange-400",
|
2025-10-14 11:48:04 +09:00
|
|
|
|
isSelected && "ring-2 ring-orange-500",
|
2025-12-04 10:39:07 +09:00
|
|
|
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
2025-10-14 11:48:04 +09:00
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{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"
|
2025-12-04 10:39:07 +09:00
|
|
|
|
onClick={() => !isFieldDisabled && setIsOpen(true)}
|
|
|
|
|
|
readOnly={isFieldDisabled}
|
|
|
|
|
|
disabled={isFieldDisabled}
|
2025-10-14 11:48:04 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</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) => {
|
2025-12-04 10:39:07 +09:00
|
|
|
|
if (isFieldDisabled) return;
|
2025-10-14 11:48:04 +09:00
|
|
|
|
setSearchQuery(e.target.value);
|
|
|
|
|
|
setIsOpen(true);
|
|
|
|
|
|
}}
|
2025-12-04 10:39:07 +09:00
|
|
|
|
onFocus={() => !isFieldDisabled && setIsOpen(true)}
|
2025-10-14 11:48:04 +09:00
|
|
|
|
placeholder={placeholder}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
2025-12-04 10:39:07 +09:00
|
|
|
|
!isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
|
2025-10-14 11:48:04 +09:00
|
|
|
|
isSelected && "ring-2 ring-orange-500",
|
2025-12-04 10:39:07 +09:00
|
|
|
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
2025-10-14 11:48:04 +09:00
|
|
|
|
)}
|
2025-12-04 10:39:07 +09:00
|
|
|
|
readOnly={isFieldDisabled}
|
|
|
|
|
|
disabled={isFieldDisabled}
|
2025-10-14 11:48:04 +09:00
|
|
|
|
/>
|
2025-12-04 10:39:07 +09:00
|
|
|
|
{isOpen && !isFieldDisabled && filteredOptions.length > 0 && (
|
2025-12-01 18:35:55 +09:00
|
|
|
|
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
2025-10-14 11:48:04 +09:00
|
|
|
|
{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>
|
|
|
|
|
|
))}
|
2025-12-01 18:35:55 +09:00
|
|
|
|
</div>
|
2025-10-14 11:48:04 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// dropdown (검색 선택박스): 기본 select와 유사하지만 검색 가능
|
|
|
|
|
|
if (webType === "dropdown") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="w-full">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
2025-12-04 10:39:07 +09:00
|
|
|
|
"flex h-10 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
|
|
|
|
|
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
|
2025-10-14 11:48:04 +09:00
|
|
|
|
isSelected && "ring-2 ring-orange-500",
|
|
|
|
|
|
isOpen && "border-orange-500",
|
2025-12-04 10:39:07 +09:00
|
|
|
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
2025-10-14 11:48:04 +09:00
|
|
|
|
)}
|
|
|
|
|
|
onClick={handleToggle}
|
2025-12-04 10:39:07 +09:00
|
|
|
|
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
|
2025-10-14 11:48:04 +09:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
2025-12-04 10:39:07 +09:00
|
|
|
|
{isOpen && !isFieldDisabled && (
|
2025-12-01 18:35:55 +09:00
|
|
|
|
<div className="absolute z-[99999] mt-1 w-full rounded-md border border-gray-300 bg-white shadow-lg">
|
2025-10-14 11:48:04 +09:00
|
|
|
|
<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>
|
2025-12-01 18:35:55 +09:00
|
|
|
|
</div>
|
2025-10-14 11:48:04 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// select (기본 선택박스)
|
2025-11-20 18:17:08 +09:00
|
|
|
|
// 다중선택 모드인 경우
|
2025-11-20 18:21:09 +09:00
|
|
|
|
if (isMultiple) {
|
2025-11-20 18:17:08 +09:00
|
|
|
|
return (
|
2025-11-21 16:23:37 +09:00
|
|
|
|
<div className="w-full" style={{ height: "100%" }}>
|
2025-11-20 18:17:08 +09:00
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
2025-11-21 16:23:37 +09:00
|
|
|
|
"box-border flex w-full flex-wrap items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
|
2025-12-04 10:39:07 +09:00
|
|
|
|
!isFieldDisabled && "hover:border-orange-400",
|
2025-11-20 18:17:08 +09:00
|
|
|
|
isSelected && "ring-2 ring-orange-500",
|
2025-12-04 10:39:07 +09:00
|
|
|
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
2025-11-20 18:17:08 +09:00
|
|
|
|
)}
|
2025-12-04 10:39:07 +09:00
|
|
|
|
onClick={() => !isFieldDisabled && setIsOpen(true)}
|
2025-11-21 16:23:37 +09:00
|
|
|
|
style={{
|
2025-12-04 10:39:07 +09:00
|
|
|
|
pointerEvents: isFieldDisabled ? "none" : "auto",
|
2025-11-21 16:23:37 +09:00
|
|
|
|
height: "100%"
|
|
|
|
|
|
}}
|
2025-11-20 18:17:08 +09:00
|
|
|
|
>
|
|
|
|
|
|
{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={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
const newVals = selectedValues.filter((v) => v !== val);
|
|
|
|
|
|
setSelectedValues(newVals);
|
|
|
|
|
|
const newValue = newVals.join(",");
|
|
|
|
|
|
if (isInteractive && onFormDataChange && component.columnName) {
|
|
|
|
|
|
onFormDataChange(component.columnName, newValue);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="ml-1 text-blue-600 hover:text-blue-800"
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
{selectedValues.length === 0 && (
|
|
|
|
|
|
<span className="text-gray-500">{placeholder}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-12-04 10:39:07 +09:00
|
|
|
|
{isOpen && !isFieldDisabled && (
|
2025-12-01 18:35:55 +09:00
|
|
|
|
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
2025-11-20 18:35:48 +09:00
|
|
|
|
{(isLoadingCodes || isLoadingCategories) ? (
|
2025-11-20 18:17:08 +09:00
|
|
|
|
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
|
|
|
|
|
) : allOptions.length > 0 ? (
|
2025-12-18 14:12:48 +09:00
|
|
|
|
(() => {
|
|
|
|
|
|
// 부모별 그룹핑 (카테고리 연쇄관계인 경우)
|
|
|
|
|
|
const hasParentInfo = allOptions.some((opt: any) => opt.parent_label);
|
|
|
|
|
|
|
|
|
|
|
|
if (hasParentInfo) {
|
|
|
|
|
|
// 부모별로 그룹핑
|
|
|
|
|
|
const groupedOptions: Record<string, { parentLabel: string; options: typeof allOptions }> = {};
|
|
|
|
|
|
allOptions.forEach((opt: any) => {
|
|
|
|
|
|
const parentKey = opt.parent_value || "기타";
|
|
|
|
|
|
const parentLabel = opt.parent_label || "기타";
|
|
|
|
|
|
if (!groupedOptions[parentKey]) {
|
|
|
|
|
|
groupedOptions[parentKey] = { parentLabel, options: [] };
|
|
|
|
|
|
}
|
|
|
|
|
|
groupedOptions[parentKey].options.push(opt);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return Object.entries(groupedOptions).map(([parentKey, group]) => (
|
|
|
|
|
|
<div key={parentKey}>
|
|
|
|
|
|
{/* 그룹 헤더 */}
|
|
|
|
|
|
<div className="sticky top-0 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-600 border-b">
|
|
|
|
|
|
{group.parentLabel}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/* 그룹 옵션들 */}
|
|
|
|
|
|
{group.options.map((option, index) => {
|
|
|
|
|
|
const isOptionSelected = selectedValues.includes(option.value);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={`${option.value}-${index}`}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
|
|
|
|
|
isOptionSelected && "bg-blue-50 font-medium"
|
|
|
|
|
|
)}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const newVals = isOptionSelected
|
|
|
|
|
|
? selectedValues.filter((v) => v !== option.value)
|
|
|
|
|
|
: [...selectedValues, option.value];
|
|
|
|
|
|
setSelectedValues(newVals);
|
|
|
|
|
|
const newValue = newVals.join(",");
|
|
|
|
|
|
if (isInteractive && onFormDataChange && component.columnName) {
|
|
|
|
|
|
onFormDataChange(component.columnName, newValue);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={isOptionSelected}
|
|
|
|
|
|
value={option.value}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
const newVals = isOptionSelected
|
|
|
|
|
|
? 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 pointer-events-auto"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span>{option.label || option.value}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2025-11-20 18:17:08 +09:00
|
|
|
|
</div>
|
2025-12-18 14:12:48 +09:00
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 부모 정보가 없으면 기존 방식
|
|
|
|
|
|
return allOptions.map((option, index) => {
|
|
|
|
|
|
const isOptionSelected = selectedValues.includes(option.value);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={`${option.value}-${index}`}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
|
|
|
|
|
isOptionSelected && "bg-blue-50 font-medium"
|
|
|
|
|
|
)}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const newVals = isOptionSelected
|
|
|
|
|
|
? selectedValues.filter((v) => v !== option.value)
|
|
|
|
|
|
: [...selectedValues, option.value];
|
|
|
|
|
|
setSelectedValues(newVals);
|
|
|
|
|
|
const newValue = newVals.join(",");
|
|
|
|
|
|
if (isInteractive && onFormDataChange && component.columnName) {
|
|
|
|
|
|
onFormDataChange(component.columnName, newValue);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={isOptionSelected}
|
|
|
|
|
|
value={option.value}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
const newVals = isOptionSelected
|
|
|
|
|
|
? 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 pointer-events-auto"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span>{option.label || option.value}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
})()
|
2025-11-20 18:17:08 +09:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
|
|
|
|
|
)}
|
2025-12-01 18:35:55 +09:00
|
|
|
|
</div>
|
2025-11-20 18:17:08 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 단일선택 모드
|
2025-10-14 11:48:04 +09:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="w-full">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
2025-12-04 10:39:07 +09:00
|
|
|
|
"flex h-10 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
|
|
|
|
|
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
|
2025-10-14 11:48:04 +09:00
|
|
|
|
isSelected && "ring-2 ring-orange-500",
|
|
|
|
|
|
isOpen && "border-orange-500",
|
2025-12-04 10:39:07 +09:00
|
|
|
|
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
|
2025-10-14 11:48:04 +09:00
|
|
|
|
)}
|
|
|
|
|
|
onClick={handleToggle}
|
2025-12-04 10:39:07 +09:00
|
|
|
|
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
|
2025-10-14 11:48:04 +09:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
2025-12-04 10:39:07 +09:00
|
|
|
|
{isOpen && !isFieldDisabled && (
|
2025-12-01 18:35:55 +09:00
|
|
|
|
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
2025-10-14 11:48:04 +09:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2025-12-01 18:35:55 +09:00
|
|
|
|
</div>
|
2025-10-14 11:48:04 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</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}
|
2025-09-19 02:15:21 +09:00
|
|
|
|
{...safeDomProps}
|
2025-09-15 15:38:48 +09:00
|
|
|
|
>
|
2025-09-19 02:15:21 +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">
|
2025-09-19 02:15:21 +09:00
|
|
|
|
{component.label}
|
2025-10-14 11:48:04 +09:00
|
|
|
|
{component.required && <span className="text-red-500">*</span>}
|
2025-09-19 02:15:21 +09:00
|
|
|
|
</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 };
|