import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import { Plus, Trash2, GripVertical, Layers, SplitSquareVertical, ChevronDown, ChevronRight, Zap, Loader2, Box, Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { screenApi } from "@/lib/api/screen"; import { apiClient } from "@/lib/api/client"; import { toast } from "sonner"; import { ComponentData, ConditionalZone } from "@/types/screen-management"; // DB 레이어 타입 interface DBLayer { layer_id: number; layer_name: string; condition_config: any; component_count: number; updated_at: string; } interface LayerManagerPanelProps { screenId: number | null; activeLayerId: number; // 현재 활성 레이어 ID (DB layer_id) onLayerChange: (layerId: number) => void; // 레이어 전환 components?: ComponentData[]; // 현재 활성 레이어의 컴포넌트 (폴백용) zones?: ConditionalZone[]; // Zone 목록 (ScreenDesigner에서 전달) onZonesChange?: (zones: ConditionalZone[]) => void; // Zone 목록 변경 콜백 } export const LayerManagerPanel: React.FC = ({ screenId, activeLayerId, onLayerChange, components = [], zones: externalZones, onZonesChange, }) => { const [layers, setLayers] = useState([]); const [isLoading, setIsLoading] = useState(false); // 펼침/접힘 상태: zone_id별 const [expandedZones, setExpandedZones] = useState>(new Set()); // Zone에 레이어 추가 시 조건값 입력 상태 const [addingToZoneId, setAddingToZoneId] = useState(null); const [newConditionValue, setNewConditionValue] = useState(""); // Zone 트리거 설정 열기 상태 const [triggerEditZoneId, setTriggerEditZoneId] = useState(null); // 기본 레이어 컴포넌트 (트리거 선택용) const [baseLayerComponents, setBaseLayerComponents] = useState([]); const zones = externalZones || []; // 레이어 목록 로드 const loadLayers = useCallback(async () => { if (!screenId) return; setIsLoading(true); try { const data = await screenApi.getScreenLayers(screenId); setLayers(data); } catch (error) { console.error("레이어 목록 로드 실패:", error); } finally { setIsLoading(false); } }, [screenId]); // 기본 레이어 컴포넌트 로드 const loadBaseLayerComponents = useCallback(async () => { if (!screenId) return; // 현재 활성 레이어가 기본 레이어(1)이면 props의 실시간 컴포넌트 사용 if (activeLayerId === 1 && components.length > 0) { setBaseLayerComponents(components); return; } try { const data = await screenApi.getLayerLayout(screenId, 1); if (data?.components) { setBaseLayerComponents(data.components as ComponentData[]); } } catch { setBaseLayerComponents(components); } }, [screenId, components, activeLayerId]); useEffect(() => { loadLayers(); loadBaseLayerComponents(); }, [loadLayers, loadBaseLayerComponents]); // Zone별 레이어 그룹핑 const getLayersForZone = useCallback((zoneId: number): DBLayer[] => { return layers.filter(l => { const cc = l.condition_config; return cc && cc.zone_id === zoneId; }); }, [layers]); // Zone에 속하지 않는 조건부 레이어 (레거시) const orphanLayers = layers.filter(l => { if (l.layer_id === 1) return false; const cc = l.condition_config; return !cc || !cc.zone_id; }); // 기본 레이어 const baseLayer = layers.find(l => l.layer_id === 1); // Zone에 레이어 추가 const handleAddLayerToZone = useCallback(async (zoneId: number) => { if (!screenId || !newConditionValue.trim()) return; try { const result = await screenApi.addLayerToZone( screenId, zoneId, newConditionValue.trim(), `레이어 (${newConditionValue.trim()})`, ); toast.success(`레이어가 Zone에 추가되었습니다. (ID: ${result.layerId})`); setAddingToZoneId(null); setNewConditionValue(""); await loadLayers(); onLayerChange(result.layerId); } catch (error) { console.error("Zone 레이어 추가 실패:", error); toast.error("레이어 추가에 실패했습니다."); } }, [screenId, newConditionValue, loadLayers, onLayerChange]); // 레이어 삭제 const handleDeleteLayer = useCallback(async (layerId: number) => { if (!screenId || layerId === 1) return; try { await screenApi.deleteLayer(screenId, layerId); toast.success("레이어가 삭제되었습니다."); await loadLayers(); if (activeLayerId === layerId) onLayerChange(1); } catch (error) { console.error("레이어 삭제 실패:", error); toast.error("레이어 삭제에 실패했습니다."); } }, [screenId, activeLayerId, loadLayers, onLayerChange]); // Zone 삭제 const handleDeleteZone = useCallback(async (zoneId: number) => { if (!screenId) return; try { await screenApi.deleteZone(zoneId); toast.success("조건부 영역이 삭제되었습니다."); // Zone 목록 새로고침 const loadedZones = await screenApi.getScreenZones(screenId); onZonesChange?.(loadedZones); await loadLayers(); onLayerChange(1); } catch (error) { console.error("Zone 삭제 실패:", error); toast.error("Zone 삭제에 실패했습니다."); } }, [screenId, loadLayers, onLayerChange, onZonesChange]); // 동적 소스 옵션 캐시 (trigger_component_id → 옵션 배열) const [dynamicOptionsCache, setDynamicOptionsCache] = useState>({}); const [loadingDynamicOptions, setLoadingDynamicOptions] = useState>(new Set()); // 이미 로드 시도한 키를 추적 (중복 요청 방지) const loadedKeysRef = useRef>(new Set()); // 동적 소스 옵션 로드 함수 const loadDynamicOptions = useCallback(async (triggerCompId: string, comp: ComponentData) => { const cacheKey = triggerCompId; // 이미 로드 완료 또는 로드 중이면 스킵 if (loadedKeysRef.current.has(cacheKey)) return; loadedKeysRef.current.add(cacheKey); setLoadingDynamicOptions(prev => new Set(prev).add(cacheKey)); try { const config = comp.componentConfig || {}; const isCategory = (comp as any).inputType === "category" || (comp as any).webType === "category"; const source = isCategory ? "category" : config.source; const compTableName = (comp as any).tableName || config.tableName; const compColumnName = (comp as any).columnName || config.columnName; let fetchedOptions: { value: string; label: string }[] = []; if (source === "category" || isCategory) { // 카테고리 소스: /table-categories/:tableName/:columnName/values const catTable = config.categoryTable || compTableName; const catColumn = config.categoryColumn || compColumnName; if (catTable && catColumn) { const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`); const data = response.data; if (data.success && data.data) { // 트리 구조를 평탄화 (valueCode/valueLabel 사용) const flattenTree = (items: any[]): { value: string; label: string }[] => { const result: { value: string; label: string }[] = []; for (const item of items) { result.push({ value: item.valueCode, label: item.valueLabel }); if (item.children && item.children.length > 0) { result.push(...flattenTree(item.children)); } } return result; }; fetchedOptions = flattenTree(data.data); } } } else if (source === "code" && config.codeGroup) { // 공통코드 소스 const response = await apiClient.get(`/common-codes/categories/${config.codeGroup}/options`); const data = response.data; if (data.success && data.data) { fetchedOptions = data.data.map((item: { value: string; label: string }) => ({ value: item.value, label: item.label, })); } } else if (source === "entity" && config.entityTable) { // 엔티티 소스 const valueCol = config.entityValueColumn || "id"; const labelCol = config.entityLabelColumn || "name"; const response = await apiClient.get(`/entity/${config.entityTable}/options`, { params: { value: valueCol, label: labelCol }, }); const data = response.data; if (data.success && data.data) { fetchedOptions = data.data; } } else if ((source === "distinct" || source === "select") && compTableName && compColumnName) { // DISTINCT 소스 const isValidCol = compColumnName && !compColumnName.startsWith("comp_"); if (isValidCol) { const response = await apiClient.get(`/entity/${compTableName}/distinct/${compColumnName}`); const data = response.data; if (data.success && data.data) { fetchedOptions = data.data.map((item: { value: string; label: string }) => ({ value: String(item.value), label: String(item.label), })); } } } setDynamicOptionsCache(prev => ({ ...prev, [cacheKey]: fetchedOptions })); } catch (error) { console.error("트리거 옵션 동적 로드 실패:", error); setDynamicOptionsCache(prev => ({ ...prev, [cacheKey]: [] })); } finally { setLoadingDynamicOptions(prev => { const next = new Set(prev); next.delete(cacheKey); return next; }); } }, []); // Zone 트리거 컴포넌트 업데이트 const handleUpdateZoneTrigger = useCallback(async (zoneId: number, triggerComponentId: string, operator: string = "eq") => { try { await screenApi.updateZone(zoneId, { trigger_component_id: triggerComponentId, trigger_operator: operator, }); const loadedZones = await screenApi.getScreenZones(screenId!); onZonesChange?.(loadedZones); // 트리거 변경 시 해당 컴포넌트의 동적 옵션 캐시 초기화 → 새로 로드 loadedKeysRef.current.delete(triggerComponentId); const triggerComp = baseLayerComponents.find(c => c.id === triggerComponentId); if (triggerComp) { loadDynamicOptions(triggerComponentId, triggerComp); } toast.success("트리거가 설정되었습니다."); } catch (error) { console.error("Zone 트리거 업데이트 실패:", error); toast.error("트리거 설정에 실패했습니다."); } }, [screenId, onZonesChange, baseLayerComponents, loadDynamicOptions]); // Zone 접힘/펼침 토글 const toggleZone = (zoneId: number) => { setExpandedZones(prev => { const next = new Set(prev); next.has(zoneId) ? next.delete(zoneId) : next.add(zoneId); return next; }); }; // 트리거로 사용 가능한 컴포넌트 (select, combobox 등) const triggerableComponents = baseLayerComponents.filter(c => ["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t)) ); // Zone 트리거가 변경되면 동적 옵션 로드 useEffect(() => { for (const zone of zones) { if (!zone.trigger_component_id) continue; const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id); if (!triggerComp) continue; const config = triggerComp.componentConfig || {}; const source = config.source; const isCategory = (triggerComp as any).inputType === "category" || (triggerComp as any).webType === "category"; // 정적 옵션이 아닌 경우에만 동적 로드 const hasStaticOptions = config.options && Array.isArray(config.options) && config.options.length > 0; if (!hasStaticOptions && (source === "category" || source === "code" || source === "entity" || source === "distinct" || source === "select" || isCategory)) { loadDynamicOptions(zone.trigger_component_id, triggerComp); } } }, [zones, baseLayerComponents, loadDynamicOptions]); // Zone의 트리거 컴포넌트에서 옵션 목록 가져오기 (정적 + 동적 지원) const getTriggerOptions = useCallback((zone: ConditionalZone): { value: string; label: string }[] => { if (!zone.trigger_component_id) return []; const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id); if (!triggerComp) return []; const config = triggerComp.componentConfig || {}; // 1. 정적 옵션 우선 확인 if (config.options && Array.isArray(config.options) && config.options.length > 0) { return config.options .filter((opt: any) => opt.value) .map((opt: any) => ({ value: opt.value, label: opt.label || opt.value })); } // 2. 동적 소스 옵션 (캐시에서 가져오기) const cached = dynamicOptionsCache[zone.trigger_component_id]; if (cached && cached.length > 0) { return cached; } return []; }, [baseLayerComponents, dynamicOptionsCache]); return (
{/* 헤더 */}

레이어

{layers.length}
{/* 레이어 + Zone 목록 */}
{isLoading ? (
로딩 중...
) : ( <> {/* 기본 레이어 */} {baseLayer && (
onLayerChange(1)} >
{baseLayer.layer_name}
기본 {baseLayer.component_count}개 컴포넌트
)} {/* 조건부 영역(Zone) 목록 */} {zones.map((zone) => { const zoneLayers = getLayersForZone(zone.zone_id); const isExpanded = expandedZones.has(zone.zone_id); const isTriggerEdit = triggerEditZoneId === zone.zone_id; return (
{/* Zone 헤더 */}
toggleZone(zone.zone_id)} > {isExpanded ? ( ) : ( )}
{zone.zone_name}
Zone {zoneLayers.length}개 레이어 | {zone.width}x{zone.height} {zone.trigger_component_id && ( 트리거 )}
{/* Zone 액션 버튼 */}
{/* 펼쳐진 Zone 내용 */} {isExpanded && (
{/* 트리거 설정 패널 */} {isTriggerEdit && (

트리거 컴포넌트 선택

{triggerableComponents.length === 0 ? (

기본 레이어에 Select/Combobox/Radio 컴포넌트가 없습니다.

) : (
{triggerableComponents.map(c => ( ))}
)}
)} {/* Zone 소속 레이어 목록 */} {zoneLayers.map((layer) => { const isActive = activeLayerId === layer.layer_id; const triggerOpts = getTriggerOptions(zone); const currentCondValue = layer.condition_config?.condition_value || ""; return (
onLayerChange(layer.layer_id)} >
{layer.layer_name}
{triggerOpts.length > 0 ? ( ) : ( 조건값: {currentCondValue || "미설정"} )} | {layer.component_count}개
); })} {/* 레이어 추가 */} {addingToZoneId === zone.zone_id ? (
{(() => { // 동적 옵션 로딩 중 표시 const isLoadingOpts = zone.trigger_component_id ? loadingDynamicOptions.has(zone.trigger_component_id) : false; if (isLoadingOpts) { return (
옵션 로딩 중...
); } const triggerOpts = getTriggerOptions(zone); // 이미 사용된 조건값 제외 const usedValues = new Set( zoneLayers.map(l => l.condition_config?.condition_value).filter(Boolean) ); const availableOpts = triggerOpts.filter(o => !usedValues.has(o.value)); if (availableOpts.length > 0) { return ( ); } return ( setNewConditionValue(e.target.value)} placeholder="조건값 입력" className="h-6 text-[11px] flex-1" autoFocus onKeyDown={(e) => { if (e.key === "Enter") handleAddLayerToZone(zone.zone_id); }} /> ); })()}
) : ( )}
)}
); })} {/* 고아 레이어 (Zone에 소속되지 않은 조건부 레이어) */} {orphanLayers.length > 0 && (

Zone 미할당 레이어

{orphanLayers.map((layer) => { const isActive = activeLayerId === layer.layer_id; return (
onLayerChange(layer.layer_id)} >
{layer.layer_name}
조건부 {layer.component_count}개
); })}
)} )}
{/* Zone 생성 드래그 영역 */}
{ e.dataTransfer.setData("application/json", JSON.stringify({ type: "create-zone" })); e.dataTransfer.effectAllowed = "copy"; }} > 조건부 영역 추가 (캔버스로 드래그)

기본 레이어에서 Zone을 배치한 후, Zone 내에 레이어를 추가하세요

); };