diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index a89e50d1..bcfff1d2 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1443,13 +1443,7 @@ async function collectAllChildMenuIds(parentObjid: number): Promise { * 메뉴 및 관련 데이터 정리 헬퍼 함수 */ async function cleanupMenuRelatedData(menuObjid: number): Promise { - // 1. category_column_mapping에서 menu_objid를 NULL로 설정 - await query( - `UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`, - [menuObjid] - ); - - // 2. code_category에서 menu_objid를 NULL로 설정 + // 1. code_category에서 menu_objid를 NULL로 설정 await query( `UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`, [menuObjid] diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index a0521eec..3e624c40 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -787,6 +787,78 @@ export const updateLayerCondition = async (req: AuthenticatedRequest, res: Respo } }; +// ======================================== +// 조건부 영역(Zone) 관리 +// ======================================== + +// Zone 목록 조회 +export const getScreenZones = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const zones = await screenManagementService.getScreenZones(parseInt(screenId), companyCode); + res.json({ success: true, data: zones }); + } catch (error) { + console.error("Zone 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "Zone 목록 조회에 실패했습니다." }); + } +}; + +// Zone 생성 +export const createZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const zone = await screenManagementService.createZone(parseInt(screenId), companyCode, req.body); + res.json({ success: true, data: zone }); + } catch (error) { + console.error("Zone 생성 실패:", error); + res.status(500).json({ success: false, message: "Zone 생성에 실패했습니다." }); + } +}; + +// Zone 업데이트 (위치/크기/트리거) +export const updateZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { zoneId } = req.params; + const { companyCode } = req.user as any; + await screenManagementService.updateZone(parseInt(zoneId), companyCode, req.body); + res.json({ success: true, message: "Zone이 업데이트되었습니다." }); + } catch (error) { + console.error("Zone 업데이트 실패:", error); + res.status(500).json({ success: false, message: "Zone 업데이트에 실패했습니다." }); + } +}; + +// Zone 삭제 +export const deleteZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { zoneId } = req.params; + const { companyCode } = req.user as any; + await screenManagementService.deleteZone(parseInt(zoneId), companyCode); + res.json({ success: true, message: "Zone이 삭제되었습니다." }); + } catch (error) { + console.error("Zone 삭제 실패:", error); + res.status(500).json({ success: false, message: "Zone 삭제에 실패했습니다." }); + } +}; + +// Zone에 레이어 추가 +export const addLayerToZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId, zoneId } = req.params; + const { companyCode } = req.user as any; + const { conditionValue, layerName } = req.body; + const result = await screenManagementService.addLayerToZone( + parseInt(screenId), companyCode, parseInt(zoneId), conditionValue, layerName + ); + res.json({ success: true, data: result }); + } catch (error) { + console.error("Zone 레이어 추가 실패:", error); + res.status(500).json({ success: false, message: "Zone에 레이어를 추가하지 못했습니다." }); + } +}; + // ======================================== // POP 레이아웃 관리 (모바일/태블릿) // ======================================== diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 08bf57f6..824bee71 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -46,6 +46,11 @@ import { getLayerLayout, deleteLayer, updateLayerCondition, + getScreenZones, + createZone, + updateZone, + deleteZone, + addLayerToZone, } from "../controllers/screenManagementController"; const router = express.Router(); @@ -98,6 +103,13 @@ router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특 router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제 router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정 +// 조건부 영역(Zone) 관리 +router.get("/screens/:screenId/zones", getScreenZones); // Zone 목록 +router.post("/screens/:screenId/zones", createZone); // Zone 생성 +router.put("/zones/:zoneId", updateZone); // Zone 업데이트 +router.delete("/zones/:zoneId", deleteZone); // Zone 삭제 +router.post("/screens/:screenId/zones/:zoneId/layers", addLayerToZone); // Zone에 레이어 추가 + // POP 레이아웃 관리 (모바일/태블릿) router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회 router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장 diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 551fba91..7dc3b2a6 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5363,6 +5363,170 @@ export class ScreenManagementService { ); } + // ======================================== + // 조건부 영역(Zone) 관리 + // ======================================== + + /** + * 화면의 조건부 영역(Zone) 목록 조회 + */ + async getScreenZones(screenId: number, companyCode: string): Promise { + let zones; + if (companyCode === "*") { + // 최고 관리자: 모든 회사 Zone 조회 가능 + zones = await query( + `SELECT * FROM screen_conditional_zones WHERE screen_id = $1 ORDER BY zone_id`, + [screenId], + ); + } else { + // 일반 회사: 자사 Zone만 조회 (company_code = '*' 제외) + zones = await query( + `SELECT * FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2 ORDER BY zone_id`, + [screenId, companyCode], + ); + } + return zones; + } + + /** + * 조건부 영역(Zone) 생성 + */ + async createZone( + screenId: number, + companyCode: string, + zoneData: { + zone_name?: string; + x: number; + y: number; + width: number; + height: number; + trigger_component_id?: string; + trigger_operator?: string; + }, + ): Promise { + const result = await queryOne( + `INSERT INTO screen_conditional_zones + (screen_id, company_code, zone_name, x, y, width, height, trigger_component_id, trigger_operator) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + screenId, + companyCode, + zoneData.zone_name || '조건부 영역', + zoneData.x, + zoneData.y, + zoneData.width, + zoneData.height, + zoneData.trigger_component_id || null, + zoneData.trigger_operator || 'eq', + ], + ); + return result; + } + + /** + * 조건부 영역(Zone) 업데이트 (위치/크기/트리거) + */ + async updateZone( + zoneId: number, + companyCode: string, + updates: { + zone_name?: string; + x?: number; + y?: number; + width?: number; + height?: number; + trigger_component_id?: string; + trigger_operator?: string; + }, + ): Promise { + const setClauses: string[] = ['updated_at = NOW()']; + const params: any[] = [zoneId, companyCode]; + let paramIdx = 3; + + for (const [key, value] of Object.entries(updates)) { + if (value !== undefined) { + setClauses.push(`${key} = $${paramIdx}`); + params.push(value); + paramIdx++; + } + } + + await query( + `UPDATE screen_conditional_zones SET ${setClauses.join(', ')} + WHERE zone_id = $1 AND company_code = $2`, + params, + ); + } + + /** + * 조건부 영역(Zone) 삭제 + 소속 레이어들의 condition_config 정리 + */ + async deleteZone(zoneId: number, companyCode: string): Promise { + // Zone에 소속된 레이어들의 condition_config에서 zone_id 제거 + await query( + `UPDATE screen_layouts_v2 SET condition_config = NULL, updated_at = NOW() + WHERE company_code = $1 AND condition_config->>'zone_id' = $2::text`, + [companyCode, String(zoneId)], + ); + + await query( + `DELETE FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`, + [zoneId, companyCode], + ); + } + + /** + * Zone에 레이어 추가 (빈 레이아웃으로 새 레이어 생성 + zone_id 할당) + */ + async addLayerToZone( + screenId: number, + companyCode: string, + zoneId: number, + conditionValue: string, + layerName?: string, + ): Promise<{ layerId: number }> { + // 다음 layer_id 계산 + const maxResult = await queryOne<{ max_id: number }>( + `SELECT COALESCE(MAX(layer_id), 1) as max_id FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + const newLayerId = (maxResult?.max_id || 1) + 1; + + // Zone 정보로 캔버스 크기 결정 (company_code 필터링 필수) + const zone = await queryOne( + `SELECT * FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`, + [zoneId, companyCode], + ); + + const layoutData = { + version: "2.1", + components: [], + screenResolution: zone + ? { width: zone.width, height: zone.height } + : { width: 800, height: 200 }, + }; + + const conditionConfig = { + zone_id: zoneId, + condition_value: conditionValue, + }; + + await query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, layer_id, layer_name, condition_config) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE + SET layout_data = EXCLUDED.layout_data, + layer_name = EXCLUDED.layer_name, + condition_config = EXCLUDED.condition_config, + updated_at = NOW()`, + [screenId, companyCode, JSON.stringify(layoutData), newLayerId, layerName || `레이어 ${newLayerId}`, JSON.stringify(conditionConfig)], + ); + + return { layerId: newLayerId }; + } + // ======================================== // POP 레이아웃 관리 (모바일/태블릿) // v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로) diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 1b13e29a..92904e73 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -89,6 +89,8 @@ function ScreenViewPage() { // 🆕 레이어 시스템 지원 const [conditionalLayers, setConditionalLayers] = useState([]); + // 🆕 조건부 영역(Zone) 목록 + const [zones, setZones] = useState([]); // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); @@ -208,15 +210,18 @@ function ScreenViewPage() { } }, [screenId]); - // 🆕 조건부 레이어 로드 (기본 레이어 외 모든 레이어 로드) + // 🆕 조건부 레이어 + Zone 로드 useEffect(() => { - const loadConditionalLayers = async () => { + const loadConditionalLayersAndZones = async () => { if (!screenId || !layout) return; try { - // 1. 모든 레이어 목록 조회 + // 1. Zone 로드 + const loadedZones = await screenApi.getScreenZones(screenId); + setZones(loadedZones); + + // 2. 모든 레이어 목록 조회 const allLayers = await screenApi.getScreenLayers(screenId); - // layer_id > 1인 레이어만 (기본 레이어 제외) const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1); if (nonBaseLayers.length === 0) { @@ -224,7 +229,7 @@ function ScreenViewPage() { return; } - // 2. 각 레이어의 레이아웃 데이터 로드 + // 3. 각 레이어의 레이아웃 데이터 로드 const layerDefinitions: LayerDefinition[] = []; for (const layerInfo of nonBaseLayers) { @@ -233,12 +238,9 @@ function ScreenViewPage() { const condConfig = layerInfo.condition_config || layerData?.conditionConfig || {}; // 레이어 컴포넌트 변환 (V2 → Legacy) - // getLayerLayout 응답: { ...layout_data, layerId, layerName, conditionConfig } - // layout_data가 spread 되므로 components는 최상위에 있음 let layerComponents: any[] = []; const rawComponents = layerData?.components; if (rawComponents && Array.isArray(rawComponents) && rawComponents.length > 0) { - // V2 컴포넌트를 Legacy 형식으로 변환 const tempV2 = { version: "2.0" as const, components: rawComponents, @@ -253,20 +255,33 @@ function ScreenViewPage() { } } + // Zone 기반 condition_config 처리 + const zoneId = condConfig.zone_id; + const conditionValue = condConfig.condition_value; + const zone = zoneId ? loadedZones.find((z: any) => z.zone_id === zoneId) : null; + // LayerDefinition 생성 const layerDef: LayerDefinition = { id: String(layerInfo.layer_id), name: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`, type: "conditional", zIndex: layerInfo.layer_id * 10, - isVisible: false, // 조건 충족 시에만 표시 + isVisible: false, isLocked: false, - condition: condConfig.targetComponentId ? { + // Zone 기반 조건 (Zone에서 트리거 정보를 가져옴) + condition: zone ? { + targetComponentId: zone.trigger_component_id || "", + operator: (zone.trigger_operator as "eq" | "neq" | "in") || "eq", + value: conditionValue, + } : condConfig.targetComponentId ? { targetComponentId: condConfig.targetComponentId, operator: condConfig.operator || "eq", value: condConfig.value, } : undefined, - displayRegion: condConfig.displayRegion || undefined, + // Zone 기반: displayRegion은 Zone에서 가져옴 + zoneId: zoneId || undefined, + conditionValue: conditionValue || undefined, + displayRegion: zone ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } : condConfig.displayRegion || undefined, components: layerComponents, }; @@ -277,16 +292,16 @@ function ScreenViewPage() { } console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({ - id: l.id, name: l.name, condition: l.condition, displayRegion: l.displayRegion, + id: l.id, name: l.name, zoneId: l.zoneId, conditionValue: l.conditionValue, componentCount: l.components.length, }))); setConditionalLayers(layerDefinitions); } catch (error) { - console.error("레이어 로드 실패:", error); + console.error("레이어/Zone 로드 실패:", error); } }; - loadConditionalLayers(); + loadConditionalLayersAndZones(); }, [screenId, layout]); // 🆕 조건부 레이어 조건 평가 (formData 변경 시 동기적으로 즉시 계산) @@ -760,20 +775,20 @@ function ScreenViewPage() { } } - // 🆕 조건부 레이어 displayRegion 기반 높이 조정 - // 기본 레이어 컴포넌트는 displayRegion 포함한 위치에 배치되므로, - // 비활성(빈 영역) 시 아래 컴포넌트를 위로 당겨 빈 공간 제거 - for (const layer of conditionalLayers) { - if (!layer.displayRegion) continue; - const region = layer.displayRegion; - const regionBottom = region.y + region.height; - const isActive = activeLayerIds.includes(layer.id); - - // 컴포넌트가 조건부 영역 하단보다 아래에 있는 경우 - if (component.position.y >= regionBottom) { - if (!isActive) { - // 비활성: 영역 높이만큼 위로 당김 (빈 공간 제거) - totalHeightAdjustment -= region.height; + // 🆕 Zone 기반 높이 조정 + // Zone 단위로 활성 여부를 판단하여 Y 오프셋 계산 + // Zone은 겹치지 않으므로 merge 로직이 불필요 (단순 boolean 판단) + for (const zone of zones) { + const zoneBottom = zone.y + zone.height; + // 컴포넌트가 Zone 하단보다 아래에 있는 경우 + if (component.position.y >= zoneBottom) { + // Zone에 매칭되는 활성 레이어가 있는지 확인 + const hasActiveLayer = conditionalLayers.some( + l => l.zoneId === zone.zone_id && activeLayerIds.includes(l.id) + ); + if (!hasActiveLayer) { + // Zone에 활성 레이어 없음: Zone 높이만큼 위로 당김 (빈 공간 제거) + totalHeightAdjustment -= zone.height; } } } @@ -1099,12 +1114,16 @@ function ScreenViewPage() { ); })} - {/* 🆕 조건부 레이어 컴포넌트 렌더링 */} + {/* 🆕 조건부 레이어 컴포넌트 렌더링 (Zone 기반) */} {conditionalLayers.map((layer) => { const isActive = activeLayerIds.includes(layer.id); if (!isActive || !layer.components || layer.components.length === 0) return null; - const region = layer.displayRegion; + // Zone 기반: zoneId로 Zone 찾아서 위치/크기 결정 + const zone = layer.zoneId ? zones.find(z => z.zone_id === layer.zoneId) : null; + const region = zone + ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } + : layer.displayRegion; return (
diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 4f295878..05d8bdc9 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -284,59 +284,38 @@ export const InteractiveScreenViewer: React.FC = ( }); }, [finalFormData, layers, allComponents, handleLayerAction]); - // 🆕 모든 조건부 레이어의 displayRegion 정보 (활성/비활성 모두) - const conditionalRegionInfos = useMemo(() => { - return layers - .filter((layer) => layer.type === "conditional" && layer.displayRegion) - .map((layer) => ({ - layerId: layer.id, - region: layer.displayRegion!, - isActive: activeLayerIds.includes(layer.id), - })) - .sort((a, b) => a.region.y - b.region.y); // Y 좌표 기준 정렬 - }, [layers, activeLayerIds]); - - // 🆕 접힌 조건부 영역 (비활성 상태인 것만) - const collapsedRegions = useMemo(() => { - return conditionalRegionInfos - .filter((info) => !info.isActive) - .map((info) => info.region); - }, [conditionalRegionInfos]); - - // 🆕 Y 오프셋 계산 함수 (다중 조건부 영역 지원) - // 컴포넌트의 원래 Y 좌표보다 위에 있는 접힌 영역들의 높이를 누적하여 빼줌 - // 겹치는 영역은 중복 계산하지 않도록 병합(merge) 처리 + // 🆕 Zone 기반 Y 오프셋 계산 (단순화) + // Zone 단위로 활성 여부만 판단 → merge 로직 불필요 const calculateYOffset = useCallback((componentY: number): number => { - if (collapsedRegions.length === 0) return 0; - - // 컴포넌트보다 위에 있는 접힌 영역만 필터링 - const relevantRegions = collapsedRegions.filter( - (region) => region.y + region.height <= componentY - ); - - if (relevantRegions.length === 0) return 0; - - // 겹치는 영역 병합 (다중 조건부 영역이 겹치는 경우 중복 높이 제거) - const mergedRegions: { y: number; bottom: number }[] = []; - for (const region of relevantRegions) { - const bottom = region.y + region.height; - if (mergedRegions.length === 0) { - mergedRegions.push({ y: region.y, bottom }); - } else { - const last = mergedRegions[mergedRegions.length - 1]; - if (region.y <= last.bottom) { - // 겹치는 영역 - 병합 (더 큰 하단으로 확장) - last.bottom = Math.max(last.bottom, bottom); - } else { - // 겹치지 않는 영역 - 새로 추가 - mergedRegions.push({ y: region.y, bottom }); - } + // layers에서 Zone 정보 추출 (displayRegion이 있는 레이어들을 zone 단위로 그룹핑) + const zoneMap = new Map(); + + for (const layer of layers) { + if (layer.type !== "conditional" || !layer.zoneId || !layer.displayRegion) continue; + const zid = layer.zoneId; + if (!zoneMap.has(zid)) { + zoneMap.set(zid, { + y: layer.displayRegion.y, + height: layer.displayRegion.height, + hasActive: false, + }); + } + if (activeLayerIds.includes(layer.id)) { + zoneMap.get(zid)!.hasActive = true; } } - // 병합된 영역들의 높이 합산 - return mergedRegions.reduce((offset, merged) => offset + (merged.bottom - merged.y), 0); - }, [collapsedRegions]); + let totalOffset = 0; + for (const [, zone] of zoneMap) { + const zoneBottom = zone.y + zone.height; + // 컴포넌트가 Zone 하단보다 아래에 있고, Zone에 활성 레이어가 없으면 접힘 + if (componentY >= zoneBottom && !zone.hasActive) { + totalOffset += zone.height; + } + } + + return totalOffset; + }, [layers, activeLayerIds]); // 개선된 검증 시스템 (선택적 활성화) const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0 @@ -2378,7 +2357,48 @@ export const InteractiveScreenViewer: React.FC = ( ); } - // 일반/조건부 레이어 (base, conditional) + // 조건부 레이어: Zone 기반 영역 내에 컴포넌트 렌더링 + if (layer.type === "conditional" && layer.displayRegion) { + const region = layer.displayRegion; + return ( +
+ {layer.components.map((comp) => ( +
+ +
+ ))} +
+ ); + } + + // 기본/기타 레이어 (base) return (
= ( style={{ zIndex: layer.zIndex }} > {layer.components.map((comp) => { - // 기본 레이어 컴포넌트만 Y 오프셋 적용 (조건부 레이어 컴포넌트는 자체 영역 내 표시) const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0; const adjustedY = comp.position.y - yOffset; @@ -2414,7 +2433,7 @@ export const InteractiveScreenViewer: React.FC = ( })}
); - }, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents]); + }, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents, layers]); return ( diff --git a/frontend/components/screen/LayerManagerPanel.tsx b/frontend/components/screen/LayerManagerPanel.tsx index c45ca18d..9179afbe 100644 --- a/frontend/components/screen/LayerManagerPanel.tsx +++ b/frontend/components/screen/LayerManagerPanel.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import { @@ -12,13 +13,13 @@ import { ChevronRight, Zap, Loader2, + Box, + Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { screenApi } from "@/lib/api/screen"; -import { convertV2ToLegacy } from "@/lib/utils/layoutV2Converter"; import { toast } from "sonner"; -import { LayerConditionPanel } from "./LayerConditionPanel"; -import { ComponentData, LayerCondition, DisplayRegion } from "@/types/screen-management"; +import { ComponentData, ConditionalZone } from "@/types/screen-management"; // DB 레이어 타입 interface DBLayer { @@ -34,6 +35,8 @@ interface LayerManagerPanelProps { 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 = ({ @@ -41,13 +44,23 @@ export const LayerManagerPanel: React.FC = ({ activeLayerId, onLayerChange, components = [], + zones: externalZones, + onZonesChange, }) => { const [layers, setLayers] = useState([]); const [isLoading, setIsLoading] = useState(false); - const [conditionOpenLayerId, setConditionOpenLayerId] = useState(null); - // 기본 레이어(layer_id=1)의 컴포넌트 (조건 설정 시 트리거 대상) + // 펼침/접힘 상태: 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; @@ -62,60 +75,60 @@ export const LayerManagerPanel: React.FC = ({ } }, [screenId]); - // 기본 레이어 컴포넌트 로드 (조건 설정 패널에서 트리거 컴포넌트 선택용) + // 기본 레이어 컴포넌트 로드 const loadBaseLayerComponents = useCallback(async () => { if (!screenId) return; try { const data = await screenApi.getLayerLayout(screenId, 1); - if (data && data.components) { - const legacy = convertV2ToLegacy(data); - if (legacy) { - setBaseLayerComponents(legacy.components as ComponentData[]); - return; - } + if (data?.components) { + setBaseLayerComponents(data.components as ComponentData[]); } - setBaseLayerComponents([]); } catch { - // 기본 레이어가 없거나 로드 실패 시 현재 컴포넌트 사용 setBaseLayerComponents(components); } }, [screenId, components]); useEffect(() => { loadLayers(); - }, [loadLayers]); + loadBaseLayerComponents(); + }, [loadLayers, loadBaseLayerComponents]); - // 조건 설정 패널이 열릴 때 기본 레이어 컴포넌트 로드 - useEffect(() => { - if (conditionOpenLayerId !== null) { - loadBaseLayerComponents(); - } - }, [conditionOpenLayerId, loadBaseLayerComponents]); + // Zone별 레이어 그룹핑 + const getLayersForZone = useCallback((zoneId: number): DBLayer[] => { + return layers.filter(l => { + const cc = l.condition_config; + return cc && cc.zone_id === zoneId; + }); + }, [layers]); - // 새 레이어 추가 - const handleAddLayer = useCallback(async () => { - if (!screenId) return; - // 다음 layer_id 계산 - const maxLayerId = layers.length > 0 ? Math.max(...layers.map((l) => l.layer_id)) : 0; - const newLayerId = maxLayerId + 1; + // 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 { - // 빈 레이아웃으로 새 레이어 저장 - await screenApi.saveLayoutV2(screenId, { - version: "2.0", - components: [], - layerId: newLayerId, - layerName: `조건부 레이어 ${newLayerId}`, - }); - toast.success(`조건부 레이어 ${newLayerId}가 생성되었습니다.`); + const result = await screenApi.addLayerToZone( + screenId, zoneId, newConditionValue.trim(), + `레이어 (${newConditionValue.trim()})`, + ); + toast.success(`레이어가 Zone에 추가되었습니다. (ID: ${result.layerId})`); + setAddingToZoneId(null); + setNewConditionValue(""); await loadLayers(); - // 새 레이어로 전환 - onLayerChange(newLayerId); + onLayerChange(result.layerId); } catch (error) { - console.error("레이어 추가 실패:", error); + console.error("Zone 레이어 추가 실패:", error); toast.error("레이어 추가에 실패했습니다."); } - }, [screenId, layers, loadLayers, onLayerChange]); + }, [screenId, newConditionValue, loadLayers, onLayerChange]); // 레이어 삭제 const handleDeleteLayer = useCallback(async (layerId: number) => { @@ -124,42 +137,59 @@ export const LayerManagerPanel: React.FC = ({ await screenApi.deleteLayer(screenId, layerId); toast.success("레이어가 삭제되었습니다."); await loadLayers(); - // 기본 레이어로 전환 - if (activeLayerId === layerId) { - onLayerChange(1); - } + if (activeLayerId === layerId) onLayerChange(1); } catch (error) { console.error("레이어 삭제 실패:", error); toast.error("레이어 삭제에 실패했습니다."); } }, [screenId, activeLayerId, loadLayers, onLayerChange]); - // 조건 업데이트 (기존 condition_config의 displayRegion 보존) - const handleUpdateCondition = useCallback(async (layerId: number, condition: LayerCondition | undefined) => { + // Zone 삭제 + const handleDeleteZone = useCallback(async (zoneId: number) => { if (!screenId) return; try { - // 기존 condition_config를 가져와서 displayRegion 보존 - const layerData = await screenApi.getLayerLayout(screenId, layerId); - const existingCondition = layerData?.conditionConfig || {}; - const displayRegion = existingCondition.displayRegion; - - let mergedCondition: any; - if (condition) { - // 조건 설정: 새 조건 + 기존 displayRegion 보존 - mergedCondition = { ...condition, ...(displayRegion ? { displayRegion } : {}) }; - } else { - // 조건 삭제: displayRegion만 남기거나, 없으면 null - mergedCondition = displayRegion ? { displayRegion } : null; - } - - await screenApi.updateLayerCondition(screenId, layerId, mergedCondition); - toast.success("조건이 저장되었습니다."); + await screenApi.deleteZone(zoneId); + toast.success("조건부 영역이 삭제되었습니다."); + // Zone 목록 새로고침 + const loadedZones = await screenApi.getScreenZones(screenId); + onZonesChange?.(loadedZones); await loadLayers(); + onLayerChange(1); } catch (error) { - console.error("조건 업데이트 실패:", error); - toast.error("조건 저장에 실패했습니다."); + console.error("Zone 삭제 실패:", error); + toast.error("Zone 삭제에 실패했습니다."); } - }, [screenId, loadLayers]); + }, [screenId, loadLayers, onLayerChange, onZonesChange]); + + // 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); + toast.success("트리거가 설정되었습니다."); + } catch (error) { + console.error("Zone 트리거 업데이트 실패:", error); + toast.error("트리거 설정에 실패했습니다."); + } + }, [screenId, onZonesChange]); + + // 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)) + ); return (
@@ -172,19 +202,9 @@ export const LayerManagerPanel: React.FC = ({ {layers.length}
- -
- {/* 레이어 목록 */} + {/* 레이어 + Zone 목록 */}
{isLoading ? ( @@ -192,146 +212,266 @@ export const LayerManagerPanel: React.FC = ({ 로딩 중...
- ) : layers.length === 0 ? ( -
-

레이어를 로드하는 중...

-

먼저 화면을 저장하면 기본 레이어가 생성됩니다.

-
) : ( - layers - .slice() - .reverse() - .map((layer) => { - const isActive = activeLayerId === layer.layer_id; - const isBase = layer.layer_id === 1; - const hasCondition = !!layer.condition_config; - const isConditionOpen = conditionOpenLayerId === layer.layer_id; + <> + {/* 기본 레이어 */} + {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 헤더 */}
onLayerChange(layer.layer_id)} - // 조건부 레이어를 캔버스로 드래그 (영역 배치용) - draggable={!isBase} - onDragStart={(e) => { - if (isBase) return; - e.dataTransfer.setData("application/json", JSON.stringify({ - type: "layer-region", - layerId: layer.layer_id, - layerName: layer.layer_name, - })); - e.dataTransfer.effectAllowed = "copy"; - }} + onClick={() => toggleZone(zone.zone_id)} > - - + {isExpanded ? ( + + ) : ( + + )} +
+ {zone.zone_name}
- - {isBase ? : } - - {layer.layer_name} -
-
- - {isBase ? "기본" : "조건부"} + + Zone - {layer.component_count}개 컴포넌트 + {zoneLayers.length}개 레이어 | {zone.width}x{zone.height} - {hasCondition && ( + {zone.trigger_component_id && ( - - 조건 + 트리거 )}
- {/* 액션 버튼 */} + {/* Zone 액션 버튼 */}
- {!isBase && ( - - )} - {!isBase && ( - - )} + +
- {/* 조건 설정 패널 */} - {!isBase && isConditionOpen && ( + {/* 펼쳐진 Zone 내용 */} + {isExpanded && (
- handleUpdateCondition(layer.layer_id, condition)} - onUpdateDisplayRegion={() => {}} - onClose={() => setConditionOpenLayerId(null)} - /> + {/* 트리거 설정 패널 */} + {isTriggerEdit && ( +
+

트리거 컴포넌트 선택

+ {triggerableComponents.length === 0 ? ( +

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

+ ) : ( +
+ {triggerableComponents.map(c => ( + + ))} +
+ )} +
+ )} + + {/* Zone 소속 레이어 목록 */} + {zoneLayers.map((layer) => { + const isActive = activeLayerId === layer.layer_id; + return ( +
onLayerChange(layer.layer_id)} + > + +
+ {layer.layer_name} +
+ + 조건값: {layer.condition_config?.condition_value || "미설정"} + + + | {layer.component_count}개 + +
+
+ +
+ ); + })} + + {/* 레이어 추가 */} + {addingToZoneId === zone.zone_id ? ( +
+ setNewConditionValue(e.target.value)} + placeholder="조건값 입력 (예: 옵션1)" + 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 내에 레이어를 추가하세요 +

); diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index a530c024..977715a6 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1,8 +1,8 @@ "use client"; import { useState, useCallback, useEffect, useMemo, useRef } from "react"; -import { cn } from "@/lib/utils"; import { Database, Cog } from "lucide-react"; +import { cn } from "@/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { @@ -133,6 +133,9 @@ interface ScreenDesignerProps { selectedScreen: ScreenDefinition | null; onBackToList: () => void; onScreenUpdate?: (updatedScreen: Partial) => void; + // POP 모드 지원 + isPop?: boolean; + defaultDevicePreview?: "mobile" | "tablet"; } import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext"; @@ -159,7 +162,15 @@ const panelConfigs: PanelConfig[] = [ }, ]; -export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) { +export default function ScreenDesigner({ + selectedScreen, + onBackToList, + onScreenUpdate, + isPop = false, + defaultDevicePreview = "tablet" +}: ScreenDesignerProps) { + // POP 모드 여부에 따른 API 분기 + const USE_POP_API = isPop; const [layout, setLayout] = useState({ components: [], gridSettings: { @@ -501,25 +512,76 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU return lines; }, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]); - // 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리) - const [activeLayerId, setActiveLayerIdLocal] = useState("default-layer"); + // 🆕 현재 편집 중인 레이어 ID (DB의 layer_id, 1 = 기본 레이어) + const [activeLayerId, setActiveLayerIdLocal] = useState(1); + const activeLayerIdRef = useRef(1); + const setActiveLayerIdWithRef = useCallback((id: number) => { + setActiveLayerIdLocal(id); + activeLayerIdRef.current = id; + }, []); - // 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반) - // 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시 - // layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리 - const visibleComponents = useMemo(() => { - // 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시 - if (!activeLayerId) { - return layout.components; + // 🆕 좌측 패널 탭 상태 관리 + const [leftPanelTab, setLeftPanelTab] = useState("components"); + + // 🆕 조건부 영역(Zone) 목록 (DB screen_conditional_zones 기반) + const [zones, setZones] = useState([]); + + // 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정) + const [regionDrag, setRegionDrag] = useState<{ + isDrawing: boolean; // 새 영역 그리기 모드 + isDragging: boolean; // 기존 영역 이동 모드 + isResizing: boolean; // 기존 영역 리사이즈 모드 + targetLayerId: string | null; // 대상 Zone ID (문자열) + startX: number; + startY: number; + currentX: number; + currentY: number; + resizeHandle: string | null; // 리사이즈 핸들 위치 + originalRegion: { x: number; y: number; width: number; height: number } | null; + }>({ + isDrawing: false, + isDragging: false, + isResizing: false, + targetLayerId: null, + startX: 0, + startY: 0, + currentX: 0, + currentY: 0, + resizeHandle: null, + originalRegion: null, + }); + + // 🆕 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용) + const [activeLayerZone, setActiveLayerZone] = useState(null); + + // 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기 + useEffect(() => { + if (activeLayerId <= 1 || !selectedScreen?.screenId) { + setActiveLayerZone(null); + return; } + // 레이어의 condition_config에서 zone_id를 가져와서 zones에서 찾기 + const findZone = async () => { + try { + const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, activeLayerId); + const zoneId = layerData?.conditionConfig?.zone_id; + if (zoneId) { + const zone = zones.find(z => z.zone_id === zoneId); + setActiveLayerZone(zone || null); + } else { + setActiveLayerZone(null); + } + } catch { + setActiveLayerZone(null); + } + }; + findZone(); + }, [activeLayerId, selectedScreen?.screenId, zones]); - // 활성 레이어에 속한 컴포넌트만 필터링 - return layout.components.filter((comp) => { - // layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리 - const compLayerId = comp.layerId || "default-layer"; - return compLayerId === activeLayerId; - }); - }, [layout.components, activeLayerId]); + // 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시) + const visibleComponents = useMemo(() => { + return layout.components; + }, [layout.components]); // 이미 배치된 컬럼 목록 계산 const placedColumns = useMemo(() => { @@ -1448,9 +1510,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU console.warn("⚠️ 화면에 할당된 메뉴가 없습니다"); } - // V2 API 사용 여부에 따라 분기 + // V2/POP API 사용 여부에 따라 분기 let response: any; - if (USE_V2_API) { + if (USE_POP_API) { + // POP 모드: screen_layouts_pop 테이블 사용 + const popResponse = await screenApi.getLayoutPop(selectedScreen.screenId); + response = popResponse ? convertV2ToLegacy(popResponse) : null; + console.log("📱 POP 레이아웃 로드:", popResponse?.components?.length || 0, "개 컴포넌트"); + } else if (USE_V2_API) { + // 데스크톱 V2 모드: screen_layouts_v2 테이블 사용 const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId); // 🐛 디버깅: API 응답에서 fieldMapping.id 확인 @@ -1533,6 +1601,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU // 파일 컴포넌트 데이터 복원 (비동기) restoreFileComponentsData(layoutWithDefaultGrid.components); + + // 🆕 조건부 영역(Zone) 로드 + try { + const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId); + setZones(loadedZones); + } catch { /* Zone 로드 실패 무시 */ } } } catch (error) { // console.error("레이아웃 로드 실패:", error); @@ -1970,37 +2044,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU // 현재 선택된 테이블을 화면의 기본 테이블로 저장 const currentMainTableName = tables.length > 0 ? tables[0].tableName : null; - // 🆕 레이어 정보도 함께 저장 (레이어가 있으면 레이어의 컴포넌트로 업데이트) - const updatedLayers = layout.layers?.map((layer) => ({ - ...layer, - components: layer.components.map((comp) => { - // 분할 패널 업데이트 로직 적용 - const updatedComp = updatedComponents.find((uc) => uc.id === comp.id); - return updatedComp || comp; - }), - })); - const layoutWithResolution = { ...layout, components: updatedComponents, - layers: updatedLayers, // 🆕 레이어 정보 포함 screenResolution: screenResolution, mainTableName: currentMainTableName, // 화면의 기본 테이블 }; - // 🔍 버튼 컴포넌트들의 action.type 확인 - const buttonComponents = layoutWithResolution.components.filter( - (c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary", - ); - // 💾 저장 로그 (디버그 완료 - 간소화) - // console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length }); - // 분할 패널 디버그 로그 (주석 처리) - // V2 API 사용 여부에 따라 분기 - if (USE_V2_API) { - // 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리) - const v2Layout = convertLegacyToV2(layoutWithResolution); - await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); - // console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트"); + // V2/POP API 사용 여부에 따라 분기 + const v2Layout = convertLegacyToV2(layoutWithResolution); + if (USE_POP_API) { + // POP 모드: screen_layouts_pop 테이블에 저장 + await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); + } else if (USE_V2_API) { + // 레이어 기반 저장: 현재 활성 레이어의 layout만 저장 + const currentLayerId = activeLayerIdRef.current || 1; + await screenApi.saveLayoutV2(selectedScreen.screenId, { + ...v2Layout, + layerId: currentLayerId, + }); } else { await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); } @@ -2023,6 +2085,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU } }, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]); + // POP 미리보기 핸들러 (새 창에서 열기) + const handlePopPreview = useCallback(() => { + if (!selectedScreen?.screenId) { + toast.error("화면 정보가 없습니다."); + return; + } + + const deviceType = defaultDevicePreview || "tablet"; + const previewUrl = `/pop/screens/${selectedScreen.screenId}?preview=true&device=${deviceType}`; + window.open(previewUrl, "_blank", "width=800,height=900"); + }, [selectedScreen, defaultDevicePreview]); + // 다국어 자동 생성 핸들러 const handleGenerateMultilang = useCallback(async () => { if (!selectedScreen?.screenId) { @@ -2101,8 +2175,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU // 자동 저장 (매핑 정보가 손실되지 않도록) try { - if (USE_V2_API) { - const v2Layout = convertLegacyToV2(updatedLayout); + const v2Layout = convertLegacyToV2(updatedLayout); + if (USE_POP_API) { + await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); + } else if (USE_V2_API) { await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); } else { await screenApi.saveLayout(selectedScreen.screenId, updatedLayout); @@ -2522,10 +2598,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU } }); - // 🆕 현재 활성 레이어에 컴포넌트 추가 + // 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지) const componentsWithLayerId = newComponents.map((comp) => ({ ...comp, - layerId: activeLayerId || "default-layer", + layerId: activeLayerIdRef.current || 1, })); // 레이아웃에 새 컴포넌트들 추가 @@ -2544,7 +2620,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU toast.success(`${template.name} 템플릿이 추가되었습니다.`); }, - [layout, selectedScreen, saveToHistory, activeLayerId], + [layout, selectedScreen, saveToHistory], ); // 레이아웃 드래그 처리 @@ -2598,7 +2674,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU label: layoutData.label, allowedComponentTypes: layoutData.allowedComponentTypes, dropZoneConfig: layoutData.dropZoneConfig, - layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) } as ComponentData; // 레이아웃에 새 컴포넌트 추가 @@ -2615,7 +2691,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`); }, - [layout, screenResolution, saveToHistory, zoomLevel, activeLayerId], + [layout, screenResolution, saveToHistory, zoomLevel], ); // handleZoneComponentDrop은 handleComponentDrop으로 대체됨 @@ -3007,9 +3083,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU }) : null; - // 캔버스 경계 내로 위치 제한 (조건부 레이어 편집 시 displayRegion 크기 기준) + // 캔버스 경계 내로 위치 제한 (조건부 레이어 편집 시 Zone 크기 기준) const currentLayerId = activeLayerIdRef.current || 1; - const activeLayerRegion = currentLayerId > 1 ? layerRegions[currentLayerId] : null; + const activeLayerRegion = currentLayerId > 1 ? activeLayerZone : null; const canvasBoundW = activeLayerRegion ? activeLayerRegion.width : screenResolution.width; const canvasBoundH = activeLayerRegion ? activeLayerRegion.height : screenResolution.height; const boundedX = Math.max(0, Math.min(dropX, canvasBoundW - componentWidth)); @@ -3210,7 +3286,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU position: snappedPosition, size: componentSize, gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용 - layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) componentConfig: { type: component.id, // 새 컴포넌트 시스템의 ID 사용 webType: component.webType, // 웹타입 정보 추가 @@ -3244,7 +3320,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU toast.success(`${component.name} 컴포넌트가 추가되었습니다.`); }, - [layout, selectedScreen, saveToHistory, activeLayerId], + [layout, selectedScreen, saveToHistory], ); // 드래그 앤 드롭 처리 @@ -3253,7 +3329,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU }, []); const handleDrop = useCallback( - (e: React.DragEvent) => { + async (e: React.DragEvent) => { e.preventDefault(); const dragData = e.dataTransfer.getData("application/json"); @@ -3285,6 +3361,31 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU return; } + // 🆕 조건부 영역(Zone) 생성 드래그인 경우 → DB screen_conditional_zones에 저장 + if (parsedData.type === "create-zone" && selectedScreen?.screenId) { + const canvasRect = canvasRef.current?.getBoundingClientRect(); + if (!canvasRect) return; + const dropX = Math.round((e.clientX - canvasRect.left) / zoomLevel); + const dropY = Math.round((e.clientY - canvasRect.top) / zoomLevel); + try { + await screenApi.createZone(selectedScreen.screenId, { + zone_name: "조건부 영역", + x: Math.max(0, dropX - 400), + y: Math.max(0, dropY), + width: Math.min(800, screenResolution.width), + height: 200, + }); + // Zone 목록 새로고침 + const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId); + setZones(loadedZones); + toast.success("조건부 영역이 생성되었습니다."); + } catch (error) { + console.error("Zone 생성 실패:", error); + toast.error("조건부 영역 생성에 실패했습니다."); + } + return; + } + // 기존 테이블/컬럼 드래그 처리 const { type, table, column } = parsedData; @@ -3616,7 +3717,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU tableName: table.tableName, position: { x, y, z: 1 } as Position, size: { width: 300, height: 200 }, - layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) style: { labelDisplay: true, labelFontSize: "14px", @@ -3867,7 +3968,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU componentType: v2Mapping.componentType, // v2-input, v2-select 등 position: { x: relativeX, y: relativeY, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, - layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { @@ -3934,7 +4035,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU componentType: v2Mapping.componentType, // v2-input, v2-select 등 position: { x, y, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, - layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { @@ -4192,9 +4293,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU const rawX = relativeMouseX - dragState.grabOffset.x; const rawY = relativeMouseY - dragState.grabOffset.y; - // 조건부 레이어 편집 시 displayRegion 크기 기준 경계 제한 + // 조건부 레이어 편집 시 Zone 크기 기준 경계 제한 const dragLayerId = activeLayerIdRef.current || 1; - const dragLayerRegion = dragLayerId > 1 ? layerRegions[dragLayerId] : null; + const dragLayerRegion = dragLayerId > 1 ? activeLayerZone : null; const dragBoundW = dragLayerRegion ? dragLayerRegion.width : screenResolution.width; const dragBoundH = dragLayerRegion ? dragLayerRegion.height : screenResolution.height; @@ -4763,7 +4864,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU z: clipComponent.position.z || 1, } as Position, parentId: undefined, // 붙여넣기 시 부모 관계 해제 - layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기 + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용) }; newComponents.push(newComponent); }); @@ -4784,7 +4885,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU // console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개"); toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`); - }, [clipboard, layout, saveToHistory, activeLayerId]); + }, [clipboard, layout, saveToHistory]); // 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로) // 🆕 플로우 버튼 그룹 다이얼로그 상태 @@ -5488,9 +5589,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU gridSettings: layoutWithResolution.gridSettings, screenResolution: layoutWithResolution.screenResolution, }); - // V2 API 사용 여부에 따라 분기 - if (USE_V2_API) { - const v2Layout = convertLegacyToV2(layoutWithResolution); + // V2/POP API 사용 여부에 따라 분기 + const v2Layout = convertLegacyToV2(layoutWithResolution); + if (USE_POP_API) { + await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); + } else if (USE_V2_API) { await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); } else { await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); @@ -5684,21 +5787,124 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU }; }, [layout, selectedComponent]); + // 🆕 조건부 영역 드래그 핸들러 (이동/리사이즈, DB 기반) + const handleRegionMouseDown = useCallback(( + e: React.MouseEvent, + layerId: string, + mode: "move" | "resize", + handle?: string, + ) => { + e.stopPropagation(); + e.preventDefault(); + const zoneId = Number(layerId); // layerId는 실제로 zoneId + const zone = zones.find(z => z.zone_id === zoneId); + if (!zone) return; + + const canvasRect = canvasRef.current?.getBoundingClientRect(); + if (!canvasRect) return; + + const x = (e.clientX - canvasRect.left) / zoomLevel; + const y = (e.clientY - canvasRect.top) / zoomLevel; + + setRegionDrag({ + isDrawing: false, + isDragging: mode === "move", + isResizing: mode === "resize", + targetLayerId: String(zoneId), + startX: x, + startY: y, + currentX: x, + currentY: y, + resizeHandle: handle || null, + originalRegion: { x: zone.x, y: zone.y, width: zone.width, height: zone.height }, + }); + }, [zones, zoomLevel]); + + // 🆕 캔버스 마우스 이벤트 (영역 이동/리사이즈) + const handleRegionCanvasMouseMove = useCallback((e: React.MouseEvent) => { + if (!regionDrag.isDragging && !regionDrag.isResizing) return; + if (!regionDrag.targetLayerId) return; + + const canvasRect = canvasRef.current?.getBoundingClientRect(); + if (!canvasRect) return; + + const x = (e.clientX - canvasRect.left) / zoomLevel; + const y = (e.clientY - canvasRect.top) / zoomLevel; + + if (regionDrag.isDragging && regionDrag.originalRegion) { + const dx = x - regionDrag.startX; + const dy = y - regionDrag.startY; + const newRegion = { + x: Math.max(0, Math.round(regionDrag.originalRegion.x + dx)), + y: Math.max(0, Math.round(regionDrag.originalRegion.y + dy)), + width: regionDrag.originalRegion.width, + height: regionDrag.originalRegion.height, + }; + const zoneId = Number(regionDrag.targetLayerId); + setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z)); + } else if (regionDrag.isResizing && regionDrag.originalRegion) { + const dx = x - regionDrag.startX; + const dy = y - regionDrag.startY; + const orig = regionDrag.originalRegion; + const newRegion = { ...orig }; + + const handle = regionDrag.resizeHandle; + if (handle?.includes("e")) newRegion.width = Math.max(50, Math.round(orig.width + dx)); + if (handle?.includes("s")) newRegion.height = Math.max(30, Math.round(orig.height + dy)); + if (handle?.includes("w")) { + newRegion.x = Math.max(0, Math.round(orig.x + dx)); + newRegion.width = Math.max(50, Math.round(orig.width - dx)); + } + if (handle?.includes("n")) { + newRegion.y = Math.max(0, Math.round(orig.y + dy)); + newRegion.height = Math.max(30, Math.round(orig.height - dy)); + } + + const zoneId = Number(regionDrag.targetLayerId); + setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z)); + } + }, [regionDrag, zoomLevel]); + + const handleRegionCanvasMouseUp = useCallback(async () => { + // 드래그 완료 시 DB에 Zone 저장 + if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId) { + const zoneId = Number(regionDrag.targetLayerId); + const zone = zones.find(z => z.zone_id === zoneId); + if (zone) { + try { + await screenApi.updateZone(zoneId, { + x: zone.x, y: zone.y, width: zone.width, height: zone.height, + }); + } catch { + console.error("Zone 저장 실패"); + } + } + } + // 드래그 상태 초기화 + setRegionDrag({ + isDrawing: false, + isDragging: false, + isResizing: false, + targetLayerId: null, + startX: 0, startY: 0, currentX: 0, currentY: 0, + resizeHandle: null, + originalRegion: null, + }); + }, [regionDrag, zones]); + // 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영 - // 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음 + // Zone 기반이므로 displayRegion 보존 불필요 const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => { setLayout((prevLayout) => ({ ...prevLayout, layers: newLayers, - // components는 그대로 유지 - layerId 속성으로 레이어 구분 - // components: prevLayout.components (기본값으로 유지됨) })); }, []); // 🆕 활성 레이어 변경 핸들러 - const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => { - setActiveLayerIdLocal(newActiveLayerId); - }, []); + const handleActiveLayerChange = useCallback((newActiveLayerId: number) => { + setActiveLayerIdWithRef(newActiveLayerId); + }, [setActiveLayerIdWithRef]); // 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성 // 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠 @@ -5748,6 +5954,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU onBack={onBackToList} onSave={handleSave} isSaving={isSaving} + onPreview={isPop ? handlePopPreview : undefined} onResolutionChange={setScreenResolution} gridSettings={layout.gridSettings} onGridSettingsChange={updateGridSettings} @@ -5778,7 +5985,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
- + 컴포넌트 @@ -5811,9 +6018,43 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU /> - {/* 🆕 레이어 관리 탭 */} + {/* 🆕 레이어 관리 탭 (DB 기반) */} - + { + if (!selectedScreen?.screenId) return; + try { + // 1. 현재 레이어 저장 + const curId = Number(activeLayerIdRef.current) || 1; + const v2Layout = convertLegacyToV2({ ...layout, screenResolution }); + await screenApi.saveLayoutV2(selectedScreen.screenId, { ...v2Layout, layerId: curId }); + + // 2. 새 레이어 로드 + const data = await screenApi.getLayerLayout(selectedScreen.screenId, layerId); + if (data && data.components) { + const legacy = convertV2ToLegacy(data); + if (legacy) { + setLayout((prev) => ({ ...prev, components: legacy.components })); + } else { + setLayout((prev) => ({ ...prev, components: [] })); + } + } else { + setLayout((prev) => ({ ...prev, components: [] })); + } + + setActiveLayerIdWithRef(layerId); + setSelectedComponent(null); + } catch (error) { + console.error("레이어 전환 실패:", error); + toast.error("레이어 전환에 실패했습니다."); + } + }} + components={layout.components} + zones={zones} + onZonesChange={setZones} + /> @@ -6390,14 +6631,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU {activeLayerId > 1 && (
- 레이어 {activeLayerId} 편집 중 + + 레이어 {activeLayerId} 편집 중 + {activeLayerZone && ( + + (캔버스: {activeLayerZone.width} x {activeLayerZone.height}px - {activeLayerZone.zone_name}) + + )} + {!activeLayerZone && ( + + (조건부 영역 미설정 - 기본 레이어에서 Zone을 먼저 생성하세요) + + )} +
)} {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */} {(() => { - // 🆕 조건부 레이어 편집 시 캔버스 크기를 displayRegion에 맞춤 - const activeRegion = activeLayerId > 1 ? layerRegions[activeLayerId] : null; + // 🆕 조건부 레이어 편집 시 캔버스 크기를 Zone에 맞춤 + const activeRegion = activeLayerId > 1 ? activeLayerZone : null; const canvasW = activeRegion ? activeRegion.width : screenResolution.width; const canvasH = activeRegion ? activeRegion.height : screenResolution.height; @@ -6444,6 +6697,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU startSelectionDrag(e); } }} + onMouseMove={(e) => { + // 영역 이동/리사이즈 처리 + if (regionDrag.isDragging || regionDrag.isResizing) { + handleRegionCanvasMouseMove(e); + } + }} + onMouseUp={() => { + if (regionDrag.isDragging || regionDrag.isResizing) { + handleRegionCanvasMouseUp(); + } + }} + onMouseLeave={() => { + if (regionDrag.isDragging || regionDrag.isResizing) { + handleRegionCanvasMouseUp(); + } + }} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; @@ -6512,6 +6781,106 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU return ( <> + {/* 조건부 영역(Zone) (기본 레이어에서만 표시, DB 기반) */} + {/* 내부는 pointerEvents: none으로 아래 컴포넌트 클릭/드래그 통과 */} + {activeLayerId === 1 && zones.map((zone) => { + const layerId = zone.zone_id; // 렌더링용 ID + const region = zone; + const resizeHandles = ["nw", "ne", "sw", "se", "n", "s", "e", "w"]; + const handleCursors: Record = { + nw: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", se: "nwse-resize", + n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize", + }; + const handlePositions: Record = { + nw: { top: -4, left: -4 }, ne: { top: -4, right: -4 }, + sw: { bottom: -4, left: -4 }, se: { bottom: -4, right: -4 }, + n: { top: -4, left: "50%", transform: "translateX(-50%)" }, + s: { bottom: -4, left: "50%", transform: "translateX(-50%)" }, + e: { top: "50%", right: -4, transform: "translateY(-50%)" }, + w: { top: "50%", left: -4, transform: "translateY(-50%)" }, + }; + // 테두리 두께 (이동 핸들 영역) + const borderWidth = 6; + return ( +
+ {/* 테두리 이동 핸들: 상/하/좌/우 얇은 영역만 pointerEvents 활성 */} + {/* 상단 */} +
handleRegionMouseDown(e, String(layerId), "move")} + /> + {/* 하단 */} +
handleRegionMouseDown(e, String(layerId), "move")} + /> + {/* 좌측 */} +
handleRegionMouseDown(e, String(layerId), "move")} + /> + {/* 우측 */} +
handleRegionMouseDown(e, String(layerId), "move")} + /> + {/* 라벨 */} + handleRegionMouseDown(e, String(layerId), "move")} + > + Zone {zone.zone_id} - {zone.zone_name} + + {/* 리사이즈 핸들 */} + {resizeHandles.map((handle) => ( +
handleRegionMouseDown(e, String(layerId), "resize", handle)} + /> + ))} + {/* 삭제 버튼 */} + +
+ ); + })} + + {/* 일반 컴포넌트들 */} {regularComponents.map((component) => { const children = @@ -7137,4 +7506,4 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU ); -} +} \ No newline at end of file diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index f6aad934..96766816 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -235,6 +235,57 @@ export const screenApi = { await apiClient.put(`/screen-management/screens/${screenId}/layers/${layerId}/condition`, { conditionConfig, layerName }); }, + // ======================================== + // 조건부 영역(Zone) 관리 + // ======================================== + + // Zone 목록 조회 + getScreenZones: async (screenId: number): Promise => { + const response = await apiClient.get(`/screen-management/screens/${screenId}/zones`); + return response.data.data || []; + }, + + // Zone 생성 + createZone: async (screenId: number, zoneData: { + zone_name?: string; + x: number; + y: number; + width: number; + height: number; + trigger_component_id?: string; + trigger_operator?: string; + }): Promise => { + const response = await apiClient.post(`/screen-management/screens/${screenId}/zones`, zoneData); + return response.data.data; + }, + + // Zone 업데이트 (위치/크기/트리거) + updateZone: async (zoneId: number, updates: { + zone_name?: string; + x?: number; + y?: number; + width?: number; + height?: number; + trigger_component_id?: string; + trigger_operator?: string; + }): Promise => { + await apiClient.put(`/screen-management/zones/${zoneId}`, updates); + }, + + // Zone 삭제 + deleteZone: async (zoneId: number): Promise => { + await apiClient.delete(`/screen-management/zones/${zoneId}`); + }, + + // Zone에 레이어 추가 + addLayerToZone: async (screenId: number, zoneId: number, conditionValue: string, layerName?: string): Promise<{ layerId: number }> => { + const response = await apiClient.post(`/screen-management/screens/${screenId}/zones/${zoneId}/layers`, { + conditionValue, + layerName, + }); + return response.data.data; + }, + // ======================================== // POP 레이아웃 관리 (모바일/태블릿) // ======================================== diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index 0687e8d3..91c61146 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -878,7 +878,7 @@ export interface LayerOverlayConfig { /** * 조건부 레이어 표시 영역 - * 조건 미충족 시 이 영역이 사라지고, 아래 컴포넌트들이 위로 이동 + * @deprecated Zone 기반으로 전환 - ConditionalZone.x/y/width/height 사용 */ export interface DisplayRegion { x: number; @@ -887,6 +887,27 @@ export interface DisplayRegion { height: number; } +/** + * 조건부 영역(Zone) + * - 기본 레이어 캔버스에서 영역을 정의하고, 여러 레이어를 할당 + * - Zone 내에서는 항상 1개 레이어만 활성 (exclusive) + * - Zone 단위로 접힘/펼침 판단 (Y 오프셋 계산 단순화) + */ +export interface ConditionalZone { + zone_id: number; + screen_id: number; + company_code: string; + zone_name: string; + x: number; + y: number; + width: number; + height: number; + trigger_component_id: string | null; // 기본 레이어의 트리거 컴포넌트 ID + trigger_operator: string; // eq, neq, in + created_at?: string; + updated_at?: string; +} + /** * 레이어 정의 */ @@ -898,10 +919,14 @@ export interface LayerDefinition { isVisible: boolean; // 초기 표시 여부 isLocked: boolean; // 편집 잠금 여부 - // 조건부 표시 로직 + // 조건부 표시 로직 (레거시 - Zone 미사용 레이어용) condition?: LayerCondition; - // 조건부 레이어 표시 영역 (조건 미충족 시 이 영역이 사라짐) + // Zone 기반 조건부 설정 (신규) + zoneId?: number; // 소속 조건부 영역 ID + conditionValue?: string; // Zone 트리거 매칭 값 + + // 조건부 레이어 표시 영역 (레거시 호환 - Zone으로 대체됨) displayRegion?: DisplayRegion; // 모달/드로어 전용 설정