공통코드 계층구조 구현

This commit is contained in:
kjs
2025-12-23 09:31:18 +09:00
parent b85b888007
commit 5f406fbe88
32 changed files with 3673 additions and 478 deletions

View File

@@ -1,10 +1,11 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
import { useCodeOptions, useTableCodeCategory, useTableColumnHierarchy } from "@/hooks/queries/useCodes";
import { cn } from "@/lib/registry/components/common/inputStyles";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import type { DataProvidable } from "@/types/data-transfer";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { HierarchicalCodeSelect } from "@/components/common/HierarchicalCodeSelect";
interface Option {
value: string;
@@ -58,7 +59,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 🆕 읽기전용/비활성화 상태 확인
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;
const isFieldDisabledBase = isDesignMode || isReadonly || isDisabled;
// 화면 컨텍스트 (데이터 제공자로 등록)
const screenContext = useScreenContextOptional();
@@ -94,7 +95,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
console.log(" componentConfig 전체:", componentConfig);
console.log(" component.componentConfig 전체:", component.componentConfig);
console.log(" =======================================");
// 다중선택이 활성화되었는지 알림
if (isMultiple) {
console.log("✅ 다중선택 모드 활성화됨!");
@@ -114,7 +115,10 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const [selectedValues, setSelectedValues] = useState<string[]>(() => {
const initialValue = externalValue || config?.value || "";
if (isMultiple && typeof initialValue === "string" && initialValue) {
return initialValue.split(",").map(v => v.trim()).filter(v => v);
return initialValue
.split(",")
.map((v) => v.trim())
.filter((v) => v);
}
return [];
});
@@ -122,7 +126,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// autocomplete의 경우 검색어 관리
const [searchQuery, setSearchQuery] = useState("");
const selectRef = useRef<HTMLDivElement>(null);
// 안정적인 쿼리 키를 위한 메모이제이션
@@ -133,6 +136,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 🚀 React Query: 테이블 코드 카테고리 조회
const { data: dynamicCodeCategory } = useTableCodeCategory(stableTableName, stableColumnName);
// 🆕 React Query: 테이블 컬럼의 계층구조 설정 조회
const { data: columnHierarchy } = useTableColumnHierarchy(stableTableName, stableColumnName);
// 코드 카테고리 결정: 동적 카테고리 > 설정 카테고리 (메모이제이션)
const codeCategory = useMemo(() => {
const category = dynamicCodeCategory || staticCodeCategory;
@@ -150,6 +156,11 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
isFetching,
} = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid);
// 🆕 계층구조 코드 자동 감지: 비활성화 (테이블 타입관리에서 hierarchyRole 설정 방식 사용)
// 기존: depth > 1인 코드가 있으면 자동으로 HierarchicalCodeSelect 사용
// 변경: 항상 false 반환하여 자동 감지 비활성화
const hasHierarchicalCodes = false;
// 🆕 카테고리 타입 (category webType)을 위한 옵션 로딩
const [categoryOptions, setCategoryOptions] = useState<Option[]>([]);
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
@@ -160,42 +171,125 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const categoryRelationCode = config?.categoryRelationCode || componentConfig?.categoryRelationCode;
const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child";
const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField;
// 🆕 계층구조 역할 설정 (대분류/중분류/소분류)
// 1순위: 동적으로 조회된 값 (테이블 타입관리에서 설정)
// 2순위: config에서 전달된 값
const hierarchyRole = columnHierarchy?.hierarchyRole || config?.hierarchyRole || componentConfig?.hierarchyRole;
const hierarchyParentField = columnHierarchy?.hierarchyParentField || config?.hierarchyParentField || componentConfig?.hierarchyParentField;
// 디버깅 로그
console.log("🔍 [SelectBasic] 계층구조 설정:", {
columnName: component.columnName,
tableName: component.tableName,
columnHierarchy,
hierarchyRole,
hierarchyParentField,
codeCategory,
});
// 🆕 자식 역할일 때 부모 값 추출 (단일 또는 다중)
const rawParentValue = cascadingRole === "child" && cascadingParentField && formData
? formData[cascadingParentField]
: undefined;
const rawParentValue =
cascadingRole === "child" && cascadingParentField && formData ? formData[cascadingParentField] : undefined;
// 🆕 계층구조 역할에 따른 부모 값 추출
const hierarchyParentValue = useMemo(() => {
if (!hierarchyRole || hierarchyRole === "large" || !hierarchyParentField || !formData) {
return undefined;
}
return formData[hierarchyParentField] as string | undefined;
}, [hierarchyRole, hierarchyParentField, formData]);
// 🆕 계층구조에서 상위 항목 미선택 시 비활성화
const isHierarchyDisabled = (hierarchyRole === "medium" || hierarchyRole === "small") && !hierarchyParentValue;
// 최종 비활성화 상태
const isFieldDisabled = isFieldDisabledBase || isHierarchyDisabled;
console.log("🔍 [SelectBasic] 비활성화 상태:", {
columnName: component.columnName,
hierarchyRole,
hierarchyParentValue,
isHierarchyDisabled,
isFieldDisabled,
});
// 🆕 계층구조 역할에 따라 옵션 필터링
const filteredCodeOptions = useMemo(() => {
console.log("🔍 [SelectBasic] 옵션 필터링:", {
columnName: component.columnName,
hierarchyRole,
hierarchyParentField,
hierarchyParentValue,
codeOptionsCount: codeOptions?.length || 0,
sampleOptions: codeOptions?.slice(0, 3),
});
if (!hierarchyRole || !codeOptions || codeOptions.length === 0) {
console.log("🔍 [SelectBasic] 필터링 스킵 - hierarchyRole 없음 또는 옵션 없음");
return codeOptions;
}
// 대분류: depth = 1 (최상위)
if (hierarchyRole === "large") {
const filtered = codeOptions.filter((opt: any) => {
const depth = opt.depth || 1;
const parentCodeValue = opt.parentCodeValue || opt.parent_code_value;
return depth === 1 || !parentCodeValue;
});
console.log("🔍 [SelectBasic] 대분류 필터링 결과:", filtered.length, "개");
return filtered;
}
// 중분류/소분류: 부모 값이 있어야 함
if ((hierarchyRole === "medium" || hierarchyRole === "small") && hierarchyParentValue) {
const filtered = codeOptions.filter((opt: any) => {
const parentCodeValue = opt.parentCodeValue || opt.parent_code_value;
return parentCodeValue === hierarchyParentValue;
});
console.log("🔍 [SelectBasic] 중/소분류 필터링 결과:", filtered.length, "개");
return filtered;
}
// 부모 값이 없으면 빈 배열 반환 (선택 불가 상태)
if (hierarchyRole === "medium" || hierarchyRole === "small") {
console.log("🔍 [SelectBasic] 중/소분류 - 부모값 없음, 빈 배열 반환");
return [];
}
return codeOptions;
}, [codeOptions, hierarchyRole, hierarchyParentValue, hierarchyParentField, component.columnName]);
// 🆕 부모값이 콤마로 구분된 문자열이면 배열로 변환 (다중 선택 지원)
const parentValues: string[] | undefined = useMemo(() => {
if (!rawParentValue) return undefined;
// 이미 배열인 경우
if (Array.isArray(rawParentValue)) {
return rawParentValue.map(v => String(v)).filter(v => v);
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);
if (strValue.includes(",")) {
return strValue
.split(",")
.map((v) => v.trim())
.filter((v) => v);
}
// 단일 값
return [strValue];
}, [rawParentValue]);
// 🆕 연쇄 드롭다운 훅 사용 (역할에 따라 다른 옵션 로드) - 다중 부모값 지원
const {
options: cascadingOptions,
loading: isLoadingCascading,
} = useCascadingDropdown({
const { options: cascadingOptions, loading: isLoadingCascading } = useCascadingDropdown({
relationCode: cascadingRelationCode,
categoryRelationCode: categoryRelationCode, // 🆕 카테고리 값 연쇄관계 지원
role: cascadingRole, // 부모/자식 역할 전달
parentValues: parentValues, // 다중 부모값
});
// 🆕 카테고리 값 연쇄관계가 활성화되었는지 확인
const hasCategoryRelation = !!categoryRelationCode;
@@ -206,32 +300,32 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
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);
@@ -264,7 +358,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 외부 value prop 변경 시 selectedValue 업데이트
useEffect(() => {
const newValue = externalValue || config?.value || "";
console.log("🔍 [SelectBasic] 외부 값 변경 감지:", {
componentId: component.id,
columnName: (component as any).columnName,
@@ -275,13 +369,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
externalValue,
"config.value": config?.value,
});
// 다중선택 모드인 경우
if (isMultiple) {
if (typeof newValue === "string" && newValue) {
const values = newValue.split(",").map(v => v.trim()).filter(v => v);
const values = newValue
.split(",")
.map((v) => v.trim())
.filter((v) => v);
const currentValuesStr = selectedValues.join(",");
if (newValue !== currentValuesStr) {
console.log("✅ [SelectBasic] 다중선택 값 업데이트:", {
from: selectedValues,
@@ -310,23 +407,25 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const dataProvider: DataProvidable = {
componentId: component.id,
componentType: "select",
getSelectedData: () => {
// 현재 선택된 값을 배열로 반환
const fieldName = component.columnName || "selectedValue";
return [{
[fieldName]: selectedValue,
value: selectedValue,
label: selectedLabel,
}];
return [
{
[fieldName]: selectedValue,
value: selectedValue,
label: selectedLabel,
},
];
},
getAllData: () => {
// 모든 옵션 반환
const configOptions = config.options || [];
return [...codeOptions, ...categoryOptions, ...configOptions];
},
clearSelection: () => {
setSelectedValue("");
setSelectedLabel("");
@@ -340,7 +439,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
useEffect(() => {
if (screenContext && component.id) {
screenContext.registerDataProvider(component.id, dataProvider);
return () => {
screenContext.unregisterDataProvider(component.id);
};
@@ -442,9 +541,18 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
if (cascadingRelationCode) {
return cascadingOptions;
}
const configOptions = config.options || [];
return [...codeOptions, ...categoryOptions, ...configOptions];
// 🆕 계층구조 역할이 설정된 경우 필터링된 옵션 사용
console.log("🔍 [SelectBasic] getAllOptions 호출:", {
columnName: component.columnName,
hierarchyRole,
codeOptionsCount: codeOptions?.length || 0,
filteredCodeOptionsCount: filteredCodeOptions?.length || 0,
categoryOptionsCount: categoryOptions?.length || 0,
configOptionsCount: configOptions?.length || 0,
});
return [...filteredCodeOptions, ...categoryOptions, ...configOptions];
};
const allOptions = getAllOptions();
@@ -482,6 +590,45 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 세부 타입별 렌더링
const renderSelectByWebType = () => {
// 🆕 계층구조 코드: 자동 감지 또는 수동 설정 시 1,2,3단계 셀렉트박스로 렌더링
// 단, hierarchyRole이 설정된 경우(개별 컬럼별 계층구조)는 일반 셀렉트 사용
const shouldUseHierarchical = !hierarchyRole && (config?.useHierarchicalCode || hasHierarchicalCodes);
if (shouldUseHierarchical && codeCategory) {
const maxDepth = config?.hierarchicalMaxDepth || 3;
const labels = config?.hierarchicalLabels || ["대분류", "중분류", "소분류"];
const placeholders = config?.hierarchicalPlaceholders || ["선택하세요", "선택하세요", "선택하세요"];
const isInline = config?.hierarchicalInline || false;
return (
<HierarchicalCodeSelect
categoryCode={codeCategory}
menuObjid={menuObjid}
maxDepth={maxDepth}
value={selectedValue}
onChange={(codeValue: string) => {
setSelectedValue(codeValue);
// 라벨 업데이트 - 선택된 값을 라벨로도 설정 (계층구조에서는 값=라벨인 경우가 많음)
setSelectedLabel(codeValue);
// 디자인 모드에서의 컴포넌트 속성 업데이트
if (onUpdate) {
onUpdate("value", codeValue);
}
// 인터랙티브 모드에서 폼 데이터 업데이트
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, codeValue);
}
}}
labels={labels as [string, string?, string?]}
placeholders={placeholders as [string, string?, string?]}
className={isInline ? "flex-row gap-2" : "flex-col gap-2"}
disabled={isFieldDisabled}
/>
);
}
// code-radio: 라디오 버튼으로 코드 선택
if (webType === "code-radio") {
return (
@@ -527,7 +674,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
!isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
isSelected && "ring-2 ring-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
isFieldDisabled && "cursor-not-allowed !bg-gray-100 opacity-60",
)}
readOnly={isFieldDisabled}
disabled={isFieldDisabled}
@@ -565,7 +712,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isOpen && "border-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
isFieldDisabled && "cursor-not-allowed !bg-gray-100 opacity-60",
)}
onClick={handleToggle}
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
@@ -612,7 +759,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
"box-border flex h-full w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
!isFieldDisabled && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
isFieldDisabled && "cursor-not-allowed !bg-gray-100 opacity-60",
)}
>
{selectedValues.map((val, idx) => {
@@ -673,7 +820,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
!isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
isSelected && "ring-2 ring-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
isFieldDisabled && "cursor-not-allowed !bg-gray-100 opacity-60",
)}
readOnly={isFieldDisabled}
disabled={isFieldDisabled}
@@ -713,7 +860,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isOpen && "border-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
isFieldDisabled && "cursor-not-allowed !bg-gray-100 opacity-60",
)}
onClick={handleToggle}
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
@@ -770,12 +917,12 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
"box-border flex w-full flex-wrap items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
!isFieldDisabled && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
isFieldDisabled && "cursor-not-allowed !bg-gray-100 opacity-60",
)}
onClick={() => !isFieldDisabled && setIsOpen(true)}
style={{
style={{
pointerEvents: isFieldDisabled ? "none" : "auto",
height: "100%"
height: "100%",
}}
>
{selectedValues.map((val, idx) => {
@@ -801,19 +948,17 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
</span>
);
})}
{selectedValues.length === 0 && (
<span className="text-gray-500">{placeholder}</span>
)}
{selectedValues.length === 0 && <span className="text-gray-500">{placeholder}</span>}
</div>
{isOpen && !isFieldDisabled && (
<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 || isLoadingCategories) ? (
{isLoadingCodes || isLoadingCategories ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? (
(() => {
// 부모별 그룹핑 (카테고리 연쇄관계인 경우)
const hasParentInfo = allOptions.some((opt: any) => opt.parent_label);
if (hasParentInfo) {
// 부모별로 그룹핑
const groupedOptions: Record<string, { parentLabel: string; options: typeof allOptions }> = {};
@@ -825,11 +970,11 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
}
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">
<div className="sticky top-0 border-b bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-600">
{group.parentLabel}
</div>
{/* 그룹 옵션들 */}
@@ -840,7 +985,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
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"
isOptionSelected && "bg-blue-50 font-medium",
)}
onClick={() => {
const newVals = isOptionSelected
@@ -869,7 +1014,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
onFormDataChange(component.columnName, newValue);
}
}}
className="h-4 w-4 pointer-events-auto"
className="pointer-events-auto h-4 w-4"
/>
<span>{option.label || option.value}</span>
</div>
@@ -879,7 +1024,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
</div>
));
}
// 부모 정보가 없으면 기존 방식
return allOptions.map((option, index) => {
const isOptionSelected = selectedValues.includes(option.value);
@@ -888,7 +1033,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
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"
isOptionSelected && "bg-blue-50 font-medium",
)}
onClick={() => {
const newVals = isOptionSelected
@@ -917,7 +1062,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
onFormDataChange(component.columnName, newValue);
}
}}
className="h-4 w-4 pointer-events-auto"
className="pointer-events-auto h-4 w-4"
/>
<span>{option.label || option.value}</span>
</div>
@@ -933,7 +1078,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
</div>
);
}
// 단일선택 모드
return (
<div className="w-full">
@@ -943,7 +1088,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isOpen && "border-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
isFieldDisabled && "cursor-not-allowed !bg-gray-100 opacity-60",
)}
onClick={handleToggle}
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}