Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Conflicts: ; frontend/components/screen/ScreenDesigner.tsx
This commit is contained in:
@@ -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<ScreenDefinition>) => 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<LayoutData>({
|
||||
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<string | null>("default-layer");
|
||||
// 🆕 현재 편집 중인 레이어 ID (DB의 layer_id, 1 = 기본 레이어)
|
||||
const [activeLayerId, setActiveLayerIdLocal] = useState<number>(1);
|
||||
const activeLayerIdRef = useRef<number>(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<string>("components");
|
||||
|
||||
// 🆕 조건부 영역(Zone) 목록 (DB screen_conditional_zones 기반)
|
||||
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
|
||||
|
||||
// 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정)
|
||||
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<import("@/types/screen-management").ConditionalZone | null>(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
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<Tabs defaultValue="components" className="flex min-h-0 flex-1 flex-col">
|
||||
<Tabs value={leftPanelTab} onValueChange={setLeftPanelTab} className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-3 gap-1">
|
||||
<TabsTrigger value="components" className="text-xs">
|
||||
컴포넌트
|
||||
@@ -5811,9 +6018,43 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 🆕 레이어 관리 탭 */}
|
||||
{/* 🆕 레이어 관리 탭 (DB 기반) */}
|
||||
<TabsContent value="layers" className="mt-0 flex-1 overflow-hidden">
|
||||
<LayerManagerPanel components={layout.components} />
|
||||
<LayerManagerPanel
|
||||
screenId={selectedScreen?.screenId || null}
|
||||
activeLayerId={Number(activeLayerIdRef.current) || 1}
|
||||
onLayerChange={async (layerId) => {
|
||||
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}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
|
||||
@@ -6390,14 +6631,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||
{activeLayerId > 1 && (
|
||||
<div className="sticky top-0 z-30 flex items-center justify-center gap-2 border-b bg-amber-50 px-4 py-1.5 backdrop-blur-sm dark:bg-amber-950/30">
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span className="text-xs font-medium">레이어 {activeLayerId} 편집 중</span>
|
||||
<span className="text-xs font-medium">
|
||||
레이어 {activeLayerId} 편집 중
|
||||
{activeLayerZone && (
|
||||
<span className="ml-2 text-amber-600">
|
||||
(캔버스: {activeLayerZone.width} x {activeLayerZone.height}px - {activeLayerZone.zone_name})
|
||||
</span>
|
||||
)}
|
||||
{!activeLayerZone && (
|
||||
<span className="ml-2 text-red-500">
|
||||
(조건부 영역 미설정 - 기본 레이어에서 Zone을 먼저 생성하세요)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + 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<string, string> = {
|
||||
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<string, React.CSSProperties> = {
|
||||
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 (
|
||||
<div
|
||||
key={`region-${layerId}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${region.x}px`,
|
||||
top: `${region.y}px`,
|
||||
width: `${region.width}px`,
|
||||
height: `${region.height}px`,
|
||||
border: "2px dashed hsl(var(--primary))",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "hsl(var(--primary) / 0.05)",
|
||||
zIndex: 50,
|
||||
pointerEvents: "none", // 내부 클릭은 아래 컴포넌트로 통과
|
||||
}}
|
||||
>
|
||||
{/* 테두리 이동 핸들: 상/하/좌/우 얇은 영역만 pointerEvents 활성 */}
|
||||
{/* 상단 */}
|
||||
<div
|
||||
className="absolute left-0 right-0 top-0"
|
||||
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
/>
|
||||
{/* 하단 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0"
|
||||
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
/>
|
||||
{/* 좌측 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0"
|
||||
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
/>
|
||||
{/* 우측 */}
|
||||
<div
|
||||
className="absolute bottom-0 right-0 top-0"
|
||||
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
/>
|
||||
{/* 라벨 */}
|
||||
<span
|
||||
className="absolute left-2 top-1 select-none text-[10px] font-medium text-primary"
|
||||
style={{ pointerEvents: "auto", cursor: "move" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
>
|
||||
Zone {zone.zone_id} - {zone.zone_name}
|
||||
</span>
|
||||
{/* 리사이즈 핸들 */}
|
||||
{resizeHandles.map((handle) => (
|
||||
<div
|
||||
key={handle}
|
||||
className="absolute z-10 h-2 w-2 rounded-sm border border-primary bg-background"
|
||||
style={{ ...handlePositions[handle], cursor: handleCursors[handle], pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "resize", handle)}
|
||||
/>
|
||||
))}
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
className="absolute -right-1 -top-3 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[8px] text-destructive-foreground hover:bg-destructive/80"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!selectedScreen?.screenId) return;
|
||||
try {
|
||||
await screenApi.deleteZone(zone.zone_id);
|
||||
setZones((prev) => prev.filter(z => z.zone_id !== zone.zone_id));
|
||||
toast.success("조건부 영역이 삭제되었습니다.");
|
||||
} catch { toast.error("Zone 삭제 실패"); }
|
||||
}}
|
||||
title="영역 삭제"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
{/* 일반 컴포넌트들 */}
|
||||
{regularComponents.map((component) => {
|
||||
const children =
|
||||
@@ -7137,4 +7506,4 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||
</LayerProvider>
|
||||
</ScreenPreviewProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user