import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Loader2, AlertCircle, Check, X, Database, Code2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { ComponentData, LayerCondition, LayerDefinition, DisplayRegion } from "@/types/screen-management"; import { getCodesByCategory, CodeItem } from "@/lib/api/codeManagement"; import { EntityReferenceAPI } from "@/lib/api/entityReference"; import { apiClient } from "@/lib/api/client"; // 통합 옵션 타입 (코드/엔티티/카테고리 모두 사용) interface ConditionOption { value: string; label: string; } // 컴포넌트의 데이터 소스 타입 type DataSourceType = "code" | "entity" | "category" | "static" | "none"; interface LayerConditionPanelProps { layer: LayerDefinition; components: ComponentData[]; // 화면의 모든 컴포넌트 baseLayerComponents?: ComponentData[]; // 기본 레이어 컴포넌트 (트리거 우선 대상) onUpdateCondition: (condition: LayerCondition | undefined) => void; onUpdateDisplayRegion: (region: DisplayRegion | undefined) => void; onClose?: () => void; } // 조건 연산자 옵션 const OPERATORS = [ { value: "eq", label: "같음 (=)" }, { value: "neq", label: "같지 않음 (≠)" }, { value: "in", label: "포함 (in)" }, ] as const; type OperatorType = "eq" | "neq" | "in"; export const LayerConditionPanel: React.FC = ({ layer, components, baseLayerComponents, onUpdateCondition, onUpdateDisplayRegion, onClose, }) => { // 조건 설정 상태 const [targetComponentId, setTargetComponentId] = useState( layer.condition?.targetComponentId || "" ); const [operator, setOperator] = useState( (layer.condition?.operator as OperatorType) || "eq" ); const [value, setValue] = useState( layer.condition?.value?.toString() || "" ); const [multiValues, setMultiValues] = useState( Array.isArray(layer.condition?.value) ? layer.condition.value : [] ); // 옵션 목록 로딩 상태 (코드/엔티티 통합) const [options, setOptions] = useState([]); const [isLoadingOptions, setIsLoadingOptions] = useState(false); const [loadError, setLoadError] = useState(null); // 트리거 가능한 컴포넌트 필터링 (기본 레이어 우선, 셀렉트/라디오/코드 타입 등) const triggerableComponents = useMemo(() => { // 기본 레이어 컴포넌트가 전달된 경우 우선 사용, 없으면 전체 컴포넌트 사용 const sourceComponents = baseLayerComponents && baseLayerComponents.length > 0 ? baseLayerComponents : components; const isTriggerComponent = (comp: ComponentData): boolean => { const componentType = (comp.componentType || "").toLowerCase(); const widgetType = ((comp as any).widgetType || "").toLowerCase(); const webType = ((comp as any).webType || "").toLowerCase(); const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase(); // 셀렉트, 라디오, 코드 타입 컴포넌트만 허용 const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity"]; return triggerTypes.some((type) => componentType.includes(type) || widgetType.includes(type) || webType.includes(type) || inputType.includes(type) ); }; // 기본 레이어 컴포넌트 ID Set (그룹 구분용) const baseLayerIds = new Set( (baseLayerComponents || []).map((c) => c.id) ); // 기본 레이어 트리거 컴포넌트 const baseLayerTriggers = sourceComponents.filter(isTriggerComponent); // 기본 레이어가 아닌 다른 레이어의 트리거 컴포넌트도 포함 (하단에 표시) // 단, baseLayerComponents가 별도로 전달된 경우에만 나머지 컴포넌트 추가 const otherLayerTriggers = baseLayerComponents && baseLayerComponents.length > 0 ? components.filter((comp) => !baseLayerIds.has(comp.id) && isTriggerComponent(comp)) : []; return { baseLayerTriggers, otherLayerTriggers }; }, [components, baseLayerComponents]); // 선택된 컴포넌트 정보 const selectedComponent = useMemo(() => { return components.find((c) => c.id === targetComponentId); }, [components, targetComponentId]); // 선택된 컴포넌트의 데이터 소스 정보 추출 const dataSourceInfo = useMemo<{ type: DataSourceType; codeCategory?: string; // 엔티티: 원본 테이블.컬럼 (entity-reference API용) originTable?: string; originColumn?: string; // 엔티티: 참조 대상 정보 (직접 조회용 폴백) referenceTable?: string; referenceColumn?: string; categoryTable?: string; categoryColumn?: string; staticOptions?: any[]; }>(() => { if (!selectedComponent) return { type: "none" }; const comp = selectedComponent as any; const config = comp.componentConfig || comp.webTypeConfig || {}; const detailSettings = comp.detailSettings || {}; // V2 컴포넌트: config.source 확인 const source = config.source; // 1. 카테고리 소스 (V2: source === "category", category_values 테이블) if (source === "category") { const categoryTable = config.categoryTable || comp.tableName; const categoryColumn = config.categoryColumn || comp.columnName; return { type: "category", categoryTable, categoryColumn }; } // 2. 코드 카테고리 확인 (V2: source === "code" + codeGroup, 기존: codeCategory) const codeCategory = config.codeGroup || // V2 컴포넌트 config.codeCategory || comp.codeCategory || detailSettings.codeCategory; if (source === "code" || codeCategory) { return { type: "code", codeCategory }; } // 3. 엔티티 참조 확인 (V2: source === "entity") // entity-reference API는 원본 테이블.컬럼으로 호출해야 함 // (백엔드에서 table_type_columns를 조회하여 참조 테이블/컬럼을 자동 매핑) const originTable = comp.tableName || config.tableName; const originColumn = comp.columnName || config.columnName; const referenceTable = config.entityTable || config.referenceTable || comp.referenceTable || detailSettings.referenceTable; const referenceColumn = config.entityValueColumn || config.referenceColumn || comp.referenceColumn || detailSettings.referenceColumn; if (source === "entity" || referenceTable) { return { type: "entity", originTable, originColumn, referenceTable, referenceColumn }; } // 4. 정적 옵션 확인 (V2: source === "static" 또는 config.options 존재) const staticOptions = config.options; if (source === "static" || (staticOptions && Array.isArray(staticOptions) && staticOptions.length > 0)) { return { type: "static", staticOptions }; } return { type: "none" }; }, [selectedComponent]); // 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적) useEffect(() => { if (dataSourceInfo.type === "none") { setOptions([]); return; } // 정적 옵션은 즉시 설정 if (dataSourceInfo.type === "static") { const staticOpts = dataSourceInfo.staticOptions || []; setOptions(staticOpts.map((opt: any) => ({ value: opt.value || "", label: opt.label || opt.value || "", }))); return; } const loadOptions = async () => { setIsLoadingOptions(true); setLoadError(null); try { if (dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && dataSourceInfo.categoryColumn) { // 카테고리 값에서 옵션 로드 (category_values 테이블) const response = await apiClient.get( `/table-categories/${dataSourceInfo.categoryTable}/${dataSourceInfo.categoryColumn}/values` ); const data = response.data; if (data.success && data.data) { // 트리 구조를 평탄화 const flattenTree = (items: any[], depth = 0): ConditionOption[] => { const result: ConditionOption[] = []; for (const item of items) { const prefix = depth > 0 ? " ".repeat(depth) : ""; result.push({ value: item.valueCode || item.valueLabel, label: `${prefix}${item.valueLabel}`, }); if (item.children && item.children.length > 0) { result.push(...flattenTree(item.children, depth + 1)); } } return result; }; setOptions(flattenTree(Array.isArray(data.data) ? data.data : [])); } else { setOptions([]); } } else if (dataSourceInfo.type === "code" && dataSourceInfo.codeCategory) { // 코드 카테고리에서 옵션 로드 const codes = await getCodesByCategory(dataSourceInfo.codeCategory); setOptions(codes.map((code) => ({ value: code.code, label: code.name, }))); } else if (dataSourceInfo.type === "entity") { // 엔티티 참조에서 옵션 로드 // 방법 1: 원본 테이블.컬럼으로 entity-reference API 호출 // (백엔드에서 table_type_columns를 통해 참조 테이블/컬럼을 자동 매핑) // 방법 2: 직접 참조 테이블로 폴백 let entityLoaded = false; if (dataSourceInfo.originTable && dataSourceInfo.originColumn) { try { const entityData = await EntityReferenceAPI.getEntityReferenceData( dataSourceInfo.originTable, dataSourceInfo.originColumn, { limit: 100 } ); setOptions(entityData.options.map((opt) => ({ value: opt.value, label: opt.label, }))); entityLoaded = true; } catch { // 원본 테이블.컬럼으로 실패 시 폴백 console.warn("원본 테이블.컬럼으로 엔티티 조회 실패, 직접 참조로 폴백"); } } // 폴백: 참조 테이블에서 직접 조회 if (!entityLoaded && dataSourceInfo.referenceTable) { try { const refColumn = dataSourceInfo.referenceColumn || "id"; const entityData = await EntityReferenceAPI.getEntityReferenceData( dataSourceInfo.referenceTable, refColumn, { limit: 100 } ); setOptions(entityData.options.map((opt) => ({ value: opt.value, label: opt.label, }))); entityLoaded = true; } catch { console.warn("직접 참조 테이블로도 엔티티 조회 실패"); } } // 모든 방법 실패 시 빈 옵션으로 설정하고 에러 표시하지 않음 if (!entityLoaded) { // 엔티티 소스이지만 테이블 조회 불가 시, 직접 입력 모드로 전환 setOptions([]); } } else { setOptions([]); } } catch (error: any) { console.error("옵션 목록 로드 실패:", error); setLoadError(error.message || "옵션 목록을 불러올 수 없습니다."); setOptions([]); } finally { setIsLoadingOptions(false); } }; loadOptions(); }, [dataSourceInfo]); // 조건 저장 const handleSave = useCallback(() => { if (!targetComponentId) { return; } const condition: LayerCondition = { targetComponentId, operator, value: operator === "in" ? multiValues : value, }; onUpdateCondition(condition); onClose?.(); }, [targetComponentId, operator, value, multiValues, onUpdateCondition, onClose]); // 조건 삭제 const handleClear = useCallback(() => { onUpdateCondition(undefined); setTargetComponentId(""); setOperator("eq"); setValue(""); setMultiValues([]); onClose?.(); }, [onUpdateCondition, onClose]); // in 연산자용 다중 값 토글 const toggleMultiValue = useCallback((val: string) => { setMultiValues((prev) => prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val] ); }, []); // 컴포넌트 라벨 가져오기 const getComponentLabel = (comp: ComponentData) => { return comp.label || (comp as any).columnName || comp.id; }; return (

조건부 표시 설정

{layer.condition && ( 설정됨 )}
{/* 트리거 컴포넌트 선택 */}
{/* 데이터 소스 표시 */} {dataSourceInfo.type === "code" && dataSourceInfo.codeCategory && (
코드: {dataSourceInfo.codeCategory}
)} {dataSourceInfo.type === "entity" && (dataSourceInfo.referenceTable || dataSourceInfo.originTable) && (
엔티티: {dataSourceInfo.referenceTable || `${dataSourceInfo.originTable}.${dataSourceInfo.originColumn}`}
)} {dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && (
카테고리: {dataSourceInfo.categoryTable}.{dataSourceInfo.categoryColumn}
)} {dataSourceInfo.type === "static" && (
정적 옵션
)}
{/* 연산자 선택 */} {targetComponentId && (
)} {/* 조건 값 선택 */} {targetComponentId && (
{isLoadingOptions ? (
옵션 목록 로딩 중...
) : loadError ? (
{loadError}
) : options.length > 0 ? ( // 옵션이 있는 경우 - 선택 UI operator === "in" ? ( // 다중 선택 (in 연산자)
{options.map((opt) => (
toggleMultiValue(opt.value)} >
{multiValues.includes(opt.value) && ( )}
{opt.label}
))}
) : ( // 단일 선택 (eq, neq 연산자) ) ) : ( // 옵션이 없는 경우 - 직접 입력 setValue(e.target.value)} placeholder="조건 값 입력..." className="h-8 text-xs" /> )} {/* 선택된 값 표시 (in 연산자) */} {operator === "in" && multiValues.length > 0 && (
{multiValues.map((val) => { const opt = options.find((o) => o.value === val); return ( {opt?.label || val} toggleMultiValue(val)} /> ); })}
)}
)} {/* 현재 조건 요약 */} {targetComponentId && (value || multiValues.length > 0) && (
요약: "{getComponentLabel(selectedComponent!)}" 값이{" "} {operator === "eq" && `"${options.find(o => o.value === value)?.label || value}"와 같으면`} {operator === "neq" && `"${options.find(o => o.value === value)?.label || value}"와 다르면`} {operator === "in" && `[${multiValues.map(v => options.find(o => o.value === v)?.label || v).join(", ")}] 중 하나이면`} {" "}이 레이어 표시
)} {/* 표시 영역 설정 */}
{layer.displayRegion ? ( <> {/* 현재 영역 정보 표시 */}
{layer.displayRegion.width} x {layer.displayRegion.height} ({layer.displayRegion.x}, {layer.displayRegion.y})

캔버스에서 점선 영역을 드래그하여 이동/리사이즈할 수 있습니다.

) : (

좌측의 레이어 항목을 캔버스로

드래그&드롭하여 영역을 배치하세요

영역을 추가하면 조건 미충족 시 해당 영역이 사라지고 아래 컴포넌트가 위로 이동합니다.

)}
{/* 버튼 */}
); };