Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node

This commit is contained in:
DDD1542 2026-02-09 15:03:29 +09:00
parent 946ce1964d
commit 2b035ce6e1
5 changed files with 406 additions and 1024 deletions

View File

@ -2839,4 +2839,4 @@ export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Respons
logger.error("POP 루트 그룹 확보 실패:", error);
res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message });
}
};
};

View File

@ -1405,7 +1405,7 @@ class DataService {
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}`);
// 2. 새 레코드와 기존 레코드 비교
// 2. id 기반 UPSERT: 레코드에 id(PK)가 있으면 UPDATE, 없으면 INSERT
let inserted = 0;
let updated = 0;
let deleted = 0;
@ -1413,125 +1413,86 @@ class DataService {
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
const normalizeDateValue = (value: any): any => {
if (value == null) return value;
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return value.split("T")[0]; // YYYY-MM-DD 만 추출
return value.split("T")[0];
}
return value;
};
// 새 레코드 처리 (INSERT or UPDATE)
for (const newRecord of records) {
console.log(`🔍 처리할 새 레코드:`, newRecord);
const existingIds = new Set(existingRecords.rows.map((r: any) => r[pkColumn]));
const processedIds = new Set<string>(); // UPDATE 처리된 id 추적
// DEBUG: 수신된 레코드와 기존 레코드 id 확인
console.log(`🔑 [UPSERT DEBUG] pkColumn: ${pkColumn}`);
console.log(`🔑 [UPSERT DEBUG] existingIds:`, Array.from(existingIds));
console.log(`🔑 [UPSERT DEBUG] records received:`, records.map((r: any) => ({ id: r[pkColumn], keys: Object.keys(r) })));
for (const newRecord of records) {
// 날짜 필드 정규화
const normalizedRecord: Record<string, any> = {};
for (const [key, value] of Object.entries(newRecord)) {
normalizedRecord[key] = normalizeDateValue(value);
}
console.log(`🔄 정규화된 레코드:`, normalizedRecord);
const recordId = normalizedRecord[pkColumn]; // 프론트에서 보낸 기존 레코드의 id
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
const fullRecord = { ...parentKeys, ...normalizedRecord };
// 고유 키: parentKeys 제외한 나머지 필드들
const uniqueFields = Object.keys(normalizedRecord);
console.log(`🔑 고유 필드들:`, uniqueFields);
// 기존 레코드에서 일치하는 것 찾기
const existingRecord = existingRecords.rows.find((existing) => {
return uniqueFields.every((field) => {
const existingValue = existing[field];
const newValue = normalizedRecord[field];
// null/undefined 처리
if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false;
// Date 타입 처리
if (existingValue instanceof Date && typeof newValue === "string") {
return (
existingValue.toISOString().split("T")[0] ===
newValue.split("T")[0]
);
}
// 문자열 비교
return String(existingValue) === String(newValue);
});
});
if (existingRecord) {
// UPDATE: 기존 레코드가 있으면 업데이트
if (recordId && existingIds.has(recordId)) {
// ===== UPDATE: id(PK)가 DB에 존재 → 해당 레코드 업데이트 =====
const fullRecord = { ...parentKeys, ...normalizedRecord };
const updateFields: string[] = [];
const updateValues: any[] = [];
let updateParamIndex = 1;
let paramIdx = 1;
for (const [key, value] of Object.entries(fullRecord)) {
if (key !== pkColumn) {
// Primary Key는 업데이트하지 않음
updateFields.push(`"${key}" = $${updateParamIndex}`);
updateFields.push(`"${key}" = $${paramIdx}`);
updateValues.push(value);
updateParamIndex++;
paramIdx++;
}
}
updateValues.push(existingRecord[pkColumn]); // WHERE 조건용
const updateQuery = `
UPDATE "${tableName}"
SET ${updateFields.join(", ")}, updated_date = NOW()
WHERE "${pkColumn}" = $${updateParamIndex}
`;
await pool.query(updateQuery, updateValues);
updated++;
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
if (updateFields.length > 0) {
updateValues.push(recordId);
const updateQuery = `
UPDATE "${tableName}"
SET ${updateFields.join(", ")}, updated_date = NOW()
WHERE "${pkColumn}" = $${paramIdx}
`;
await pool.query(updateQuery, updateValues);
updated++;
processedIds.add(recordId);
console.log(`✏️ UPDATE by id: ${pkColumn} = ${recordId}`);
}
} else {
// INSERT: 기존 레코드가 없으면 삽입
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
// created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정
const { created_date: _, ...recordWithoutCreatedDate } = fullRecord;
// ===== INSERT: id 없음 또는 DB에 없음 → 새 레코드 삽입 =====
const { [pkColumn]: _removedId, created_date: _cd, ...cleanRecord } = normalizedRecord;
const fullRecord = { ...parentKeys, ...cleanRecord };
const newId = uuidv4();
const recordWithMeta: Record<string, any> = {
...recordWithoutCreatedDate,
id: uuidv4(), // 새 ID 생성
...fullRecord,
[pkColumn]: newId,
created_date: "NOW()",
updated_date: "NOW()",
};
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
if (
!recordWithMeta.company_code &&
userCompany &&
userCompany !== "*"
) {
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
recordWithMeta.company_code = userCompany;
}
// writer가 없으면 userId 사용
if (!recordWithMeta.writer && userId) {
recordWithMeta.writer = userId;
}
const insertFields = Object.keys(recordWithMeta).filter(
(key) => recordWithMeta[key] !== "NOW()"
);
const insertPlaceholders: string[] = [];
const insertValues: any[] = [];
let insertParamIndex = 1;
let paramIdx = 1;
for (const field of Object.keys(recordWithMeta)) {
if (recordWithMeta[field] === "NOW()") {
insertPlaceholders.push("NOW()");
} else {
insertPlaceholders.push(`$${insertParamIndex}`);
insertPlaceholders.push(`$${paramIdx}`);
insertValues.push(recordWithMeta[field]);
insertParamIndex++;
paramIdx++;
}
}
@ -1541,49 +1502,21 @@ class DataService {
.join(", ")})
VALUES (${insertPlaceholders.join(", ")})
`;
console.log(` INSERT 쿼리:`, {
query: insertQuery,
values: insertValues,
});
await pool.query(insertQuery, insertValues);
inserted++;
console.log(` INSERT: 새 레코드`);
processedIds.add(newId);
console.log(` INSERT: 새 레코드 ${pkColumn} = ${newId}`);
}
}
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
for (const existingRecord of existingRecords.rows) {
const uniqueFields = Object.keys(records[0] || {});
const stillExists = records.some((newRecord) => {
return uniqueFields.every((field) => {
const existingValue = existingRecord[field];
const newValue = newRecord[field];
if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false;
if (existingValue instanceof Date && typeof newValue === "string") {
return (
existingValue.toISOString().split("T")[0] ===
newValue.split("T")[0]
);
}
return String(existingValue) === String(newValue);
});
});
if (!stillExists) {
// DELETE: 새 레코드에 없으면 삭제
// 3. 고아 레코드 삭제: 기존 레코드 중 이번에 처리되지 않은 것 삭제
for (const existingRow of existingRecords.rows) {
const existId = existingRow[pkColumn];
if (!processedIds.has(existId)) {
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
await pool.query(deleteQuery, [existId]);
deleted++;
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
console.log(`🗑️ DELETE orphan: ${pkColumn} = ${existId}`);
}
}

View File

@ -2,7 +2,6 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
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,9 +132,6 @@ 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";
@ -162,15 +158,7 @@ const panelConfigs: PanelConfig[] = [
},
];
export default function ScreenDesigner({
selectedScreen,
onBackToList,
onScreenUpdate,
isPop = false,
defaultDevicePreview = "tablet"
}: ScreenDesignerProps) {
// POP 모드 여부에 따른 API 분기
const USE_POP_API = isPop;
export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) {
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: {
@ -512,49 +500,25 @@ export default function ScreenDesigner({
return lines;
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
// 🆕 현재 편집 중인 레이어 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;
}, []);
// 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리)
const [activeLayerId, setActiveLayerIdLocal] = useState<string | null>("default-layer");
// 🆕 좌측 패널 탭 상태 관리
const [leftPanelTab, setLeftPanelTab] = useState<string>("components");
// 🆕 레이어 영역 (기본 레이어에서 조건부 레이어들의 displayRegion 표시)
const [layerRegions, setLayerRegions] = useState<Record<number, { x: number; y: number; width: number; height: number; layerName: string }>>({});
// 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정)
const [regionDrag, setRegionDrag] = useState<{
isDrawing: boolean; // 새 영역 그리기 모드
isDragging: boolean; // 기존 영역 이동 모드
isResizing: boolean; // 기존 영역 리사이즈 모드
targetLayerId: string | null; // 대상 레이어 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,
});
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
// 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반)
// 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
const visibleComponents = useMemo(() => {
return layout.components;
}, [layout.components]);
// 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시
if (!activeLayerId) {
return layout.components;
}
// 활성 레이어에 속한 컴포넌트만 필터링
return layout.components.filter((comp) => {
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
const compLayerId = comp.layerId || "default-layer";
return compLayerId === activeLayerId;
});
}, [layout.components, activeLayerId]);
// 이미 배치된 컬럼 목록 계산
const placedColumns = useMemo(() => {
@ -1483,15 +1447,9 @@ export default function ScreenDesigner({
console.warn("⚠️ 화면에 할당된 메뉴가 없습니다");
}
// V2/POP API 사용 여부에 따라 분기
// V2 API 사용 여부에 따라 분기
let response: any;
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 테이블 사용
if (USE_V2_API) {
const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId);
// 🐛 디버깅: API 응답에서 fieldMapping.id 확인
@ -1574,21 +1532,6 @@ export default function ScreenDesigner({
// 파일 컴포넌트 데이터 복원 (비동기)
restoreFileComponentsData(layoutWithDefaultGrid.components);
// 🆕 레이어 영역 로드 (조건부 레이어의 displayRegion)
try {
const layers = await screenApi.getScreenLayers(selectedScreen.screenId);
const regions: Record<number, any> = {};
for (const layer of layers) {
if (layer.layer_id > 1 && layer.condition_config?.displayRegion) {
regions[layer.layer_id] = {
...layer.condition_config.displayRegion,
layerName: layer.layer_name,
};
}
}
setLayerRegions(regions);
} catch { /* 레이어 로드 실패 무시 */ }
}
} catch (error) {
// console.error("레이아웃 로드 실패:", error);
@ -2026,25 +1969,37 @@ export default function ScreenDesigner({
// 현재 선택된 테이블을 화면의 기본 테이블로 저장
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/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,
});
// V2 API 사용 여부에 따라 분기
if (USE_V2_API) {
// 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리)
const v2Layout = convertLegacyToV2(layoutWithResolution);
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
// console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
} else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
}
@ -2067,18 +2022,6 @@ export default function ScreenDesigner({
}
}, [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) {
@ -2157,10 +2100,8 @@ export default function ScreenDesigner({
// 자동 저장 (매핑 정보가 손실되지 않도록)
try {
const v2Layout = convertLegacyToV2(updatedLayout);
if (USE_POP_API) {
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
if (USE_V2_API) {
const v2Layout = convertLegacyToV2(updatedLayout);
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
} else {
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
@ -2580,10 +2521,10 @@ export default function ScreenDesigner({
}
});
// 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지)
// 🆕 현재 활성 레이어에 컴포넌트 추가
const componentsWithLayerId = newComponents.map((comp) => ({
...comp,
layerId: activeLayerIdRef.current || 1,
layerId: activeLayerId || "default-layer",
}));
// 레이아웃에 새 컴포넌트들 추가
@ -2602,7 +2543,7 @@ export default function ScreenDesigner({
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
},
[layout, selectedScreen, saveToHistory],
[layout, selectedScreen, saveToHistory, activeLayerId],
);
// 레이아웃 드래그 처리
@ -2656,7 +2597,7 @@ export default function ScreenDesigner({
label: layoutData.label,
allowedComponentTypes: layoutData.allowedComponentTypes,
dropZoneConfig: layoutData.dropZoneConfig,
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
} as ComponentData;
// 레이아웃에 새 컴포넌트 추가
@ -2673,7 +2614,7 @@ export default function ScreenDesigner({
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
},
[layout, screenResolution, saveToHistory, zoomLevel],
[layout, screenResolution, saveToHistory, zoomLevel, activeLayerId],
);
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
@ -3264,7 +3205,7 @@ export default function ScreenDesigner({
position: snappedPosition,
size: componentSize,
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
componentConfig: {
type: component.id, // 새 컴포넌트 시스템의 ID 사용
webType: component.webType, // 웹타입 정보 추가
@ -3298,7 +3239,7 @@ export default function ScreenDesigner({
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
},
[layout, selectedScreen, saveToHistory],
[layout, selectedScreen, saveToHistory, activeLayerId],
);
// 드래그 앤 드롭 처리
@ -3307,7 +3248,7 @@ export default function ScreenDesigner({
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
(e: React.DragEvent) => {
e.preventDefault();
const dragData = e.dataTransfer.getData("application/json");
@ -3339,41 +3280,6 @@ export default function ScreenDesigner({
return;
}
// 🆕 조건부 레이어 영역 드래그인 경우 → DB condition_config에 displayRegion 저장
if (parsedData.type === "layer-region" && parsedData.layerId && 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);
const newRegion = {
x: Math.max(0, dropX - 400),
y: Math.max(0, dropY),
width: Math.min(800, screenResolution.width),
height: 200,
};
// DB에 displayRegion 저장 (condition_config에 포함)
try {
// 기존 condition_config를 가져와서 displayRegion만 추가/업데이트
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, parsedData.layerId);
const existingCondition = layerData?.conditionConfig || {};
await screenApi.updateLayerCondition(
selectedScreen.screenId,
parsedData.layerId,
{ ...existingCondition, displayRegion: newRegion }
);
// 레이어 영역 state에 반영 (캔버스에 즉시 표시)
setLayerRegions((prev) => ({
...prev,
[parsedData.layerId]: { ...newRegion, layerName: parsedData.layerName },
}));
toast.success(`"${parsedData.layerName}" 영역이 배치되었습니다.`);
} catch (error) {
console.error("레이어 영역 저장 실패:", error);
toast.error("레이어 영역 저장에 실패했습니다.");
}
return;
}
// 기존 테이블/컬럼 드래그 처리
const { type, table, column } = parsedData;
@ -3705,7 +3611,7 @@ export default function ScreenDesigner({
tableName: table.tableName,
position: { x, y, z: 1 } as Position,
size: { width: 300, height: 200 },
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
style: {
labelDisplay: true,
labelFontSize: "14px",
@ -3956,7 +3862,7 @@ export default function ScreenDesigner({
componentType: v2Mapping.componentType, // v2-input, v2-select 등
position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -4023,7 +3929,7 @@ export default function ScreenDesigner({
componentType: v2Mapping.componentType, // v2-input, v2-select 등
position: { x, y, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -4846,7 +4752,7 @@ export default function ScreenDesigner({
z: clipComponent.position.z || 1,
} as Position,
parentId: undefined, // 붙여넣기 시 부모 관계 해제
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용)
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기
};
newComponents.push(newComponent);
});
@ -4867,7 +4773,7 @@ export default function ScreenDesigner({
// console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
}, [clipboard, layout, saveToHistory]);
}, [clipboard, layout, saveToHistory, activeLayerId]);
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
// 🆕 플로우 버튼 그룹 다이얼로그 상태
@ -5571,11 +5477,9 @@ export default function ScreenDesigner({
gridSettings: layoutWithResolution.gridSettings,
screenResolution: layoutWithResolution.screenResolution,
});
// V2/POP API 사용 여부에 따라 분기
const v2Layout = convertLegacyToV2(layoutWithResolution);
if (USE_POP_API) {
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
// V2 API 사용 여부에 따라 분기
if (USE_V2_API) {
const v2Layout = convertLegacyToV2(layoutWithResolution);
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
} else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
@ -5769,152 +5673,21 @@ export default function ScreenDesigner({
};
}, [layout, selectedComponent]);
// 🆕 조건부 영역 드래그 핸들러 (이동/리사이즈, DB 기반)
const handleRegionMouseDown = useCallback((
e: React.MouseEvent,
layerId: string,
mode: "move" | "resize",
handle?: string,
) => {
e.stopPropagation();
e.preventDefault();
const lid = Number(layerId);
const region = layerRegions[lid];
if (!region) 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: layerId,
startX: x,
startY: y,
currentX: x,
currentY: y,
resizeHandle: handle || null,
originalRegion: { x: region.x, y: region.y, width: region.width, height: region.height },
});
}, [layerRegions, 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 lid = Number(regionDrag.targetLayerId);
setLayerRegions((prev) => ({
...prev,
[lid]: { ...prev[lid], ...newRegion },
}));
} 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 lid = Number(regionDrag.targetLayerId);
setLayerRegions((prev) => ({
...prev,
[lid]: { ...prev[lid], ...newRegion },
}));
}
}, [regionDrag, zoomLevel]);
const handleRegionCanvasMouseUp = useCallback(async () => {
// 드래그 완료 시 DB에 영역 저장
if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId && selectedScreen?.screenId) {
const lid = Number(regionDrag.targetLayerId);
const region = layerRegions[lid];
if (region) {
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, lid);
const existingCondition = layerData?.conditionConfig || {};
await screenApi.updateLayerCondition(
selectedScreen.screenId, lid,
{ ...existingCondition, displayRegion: { x: region.x, y: region.y, width: region.width, height: region.height } }
);
} catch {
console.error("영역 저장 실패");
}
}
}
// 드래그 상태 초기화
setRegionDrag({
isDrawing: false,
isDragging: false,
isResizing: false,
targetLayerId: null,
startX: 0, startY: 0, currentX: 0, currentY: 0,
resizeHandle: null,
originalRegion: null,
});
}, [regionDrag, layerRegions, selectedScreen]);
// 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영
// 주의: layout.layers에 직접 설정된 displayRegion 등 메타데이터를 보존
// 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
setLayout((prevLayout) => {
// 기존 layout.layers의 메타데이터(displayRegion 등)를 보존하며 병합
const mergedLayers = newLayers.map((newLayer) => {
const existingLayer = prevLayout.layers?.find((l) => l.id === newLayer.id);
if (!existingLayer) return newLayer;
// LayerContext에서 온 데이터(condition 등)를 우선하되,
// layout.layers에만 있는 데이터(캔버스에서 직접 수정한 displayRegion)도 보존
return {
...existingLayer, // 기존 메타데이터 보존 (displayRegion 등)
...newLayer, // LayerContext 데이터 우선 (condition, name, isVisible 등)
// displayRegion: 양쪽 모두 있을 수 있으므로 최신 값 우선
displayRegion: newLayer.displayRegion !== undefined
? newLayer.displayRegion
: existingLayer.displayRegion,
};
});
return {
...prevLayout,
layers: mergedLayers,
};
});
setLayout((prevLayout) => ({
...prevLayout,
layers: newLayers,
// components는 그대로 유지 - layerId 속성으로 레이어 구분
// components: prevLayout.components (기본값으로 유지됨)
}));
}, []);
// 🆕 활성 레이어 변경 핸들러
const handleActiveLayerChange = useCallback((newActiveLayerId: number) => {
setActiveLayerIdWithRef(newActiveLayerId);
}, [setActiveLayerIdWithRef]);
const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => {
setActiveLayerIdLocal(newActiveLayerId);
}, []);
// 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성
// 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠
@ -5964,7 +5737,6 @@ export default function ScreenDesigner({
onBack={onBackToList}
onSave={handleSave}
isSaving={isSaving}
onPreview={isPop ? handlePopPreview : undefined}
onResolutionChange={setScreenResolution}
gridSettings={layout.gridSettings}
onGridSettingsChange={updateGridSettings}
@ -5995,7 +5767,7 @@ export default function ScreenDesigner({
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Tabs value={leftPanelTab} onValueChange={setLeftPanelTab} className="flex min-h-0 flex-1 flex-col">
<Tabs defaultValue="components" 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">
@ -6028,41 +5800,9 @@ export default function ScreenDesigner({
/>
</TabsContent>
{/* 🆕 레이어 관리 탭 (DB 기반) */}
{/* 🆕 레이어 관리 탭 */}
<TabsContent value="layers" className="mt-0 flex-1 overflow-hidden">
<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}
/>
<LayerManagerPanel components={layout.components} />
</TabsContent>
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
@ -6635,14 +6375,6 @@ export default function ScreenDesigner({
</div>
);
})()}
{/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */}
{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>
</div>
)}
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
<div
className="flex justify-center"
@ -6683,22 +6415,6 @@ export default function ScreenDesigner({
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";
@ -6767,79 +6483,6 @@ export default function ScreenDesigner({
return (
<>
{/* 조건부 레이어 영역 (기본 레이어에서만 표시, DB 기반) */}
{activeLayerId === 1 && Object.entries(layerRegions).map(([layerIdStr, region]) => {
const layerId = Number(layerIdStr);
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%)" },
};
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: 9999,
cursor: "move",
pointerEvents: "auto",
}}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
>
<span className="pointer-events-none absolute left-2 top-1 select-none text-[10px] font-medium text-primary">
{layerId} - {region.layerName}
</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] }}
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"
onClick={async (e) => {
e.stopPropagation();
if (!selectedScreen?.screenId) return;
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerId);
const cond = layerData?.conditionConfig || {};
delete cond.displayRegion;
await screenApi.updateLayerCondition(selectedScreen.screenId, layerId, Object.keys(cond).length > 0 ? cond : null);
setLayerRegions((prev) => {
const next = { ...prev };
delete next[layerId];
return next;
});
} catch { toast.error("영역 삭제 실패"); }
}}
title="영역 삭제"
>
x
</button>
</div>
);
})}
{/* 일반 컴포넌트들 */}
{regularComponents.map((component) => {
const children =

View File

@ -134,7 +134,6 @@ interface ScreenSettingModalProps {
fieldMappings?: FieldMappingInfo[];
componentCount?: number;
onSaveSuccess?: () => void;
isPop?: boolean; // POP 화면 여부
}
// 검색 가능한 Select 컴포넌트
@ -240,7 +239,6 @@ export function ScreenSettingModal({
fieldMappings = [],
componentCount = 0,
onSaveSuccess,
isPop = false,
}: ScreenSettingModalProps) {
const [activeTab, setActiveTab] = useState("overview");
const [loading, setLoading] = useState(false);
@ -521,7 +519,6 @@ export function ScreenSettingModal({
iframeKey={iframeKey}
canvasWidth={canvasSize.width}
canvasHeight={canvasSize.height}
isPop={isPop}
/>
</div>
</div>
@ -4634,10 +4631,9 @@ interface PreviewTabProps {
iframeKey?: number; // iframe 새로고침용 키
canvasWidth?: number; // 화면 캔버스 너비
canvasHeight?: number; // 화면 캔버스 높이
isPop?: boolean; // POP 화면 여부
}
function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight, isPop = false }: PreviewTabProps) {
function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight }: PreviewTabProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
@ -4691,18 +4687,12 @@ function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWi
if (companyCode) {
params.set("company_code", companyCode);
}
// POP 화면일 경우 디바이스 타입 추가
if (isPop) {
params.set("device", "tablet");
}
// POP 화면과 데스크톱 화면 경로 분기
const screenPath = isPop ? `/pop/screens/${screenId}` : `/screens/${screenId}`;
if (typeof window !== "undefined") {
const baseUrl = window.location.origin;
return `${baseUrl}${screenPath}?${params.toString()}`;
return `${baseUrl}/screens/${screenId}?${params.toString()}`;
}
return `${screenPath}?${params.toString()}`;
}, [screenId, companyCode, isPop]);
return `/screens/${screenId}?${params.toString()}`;
}, [screenId, companyCode]);
const handleIframeLoad = () => {
setLoading(false);