feat(pop-designer): POP 디자이너 v2.0 - 4가지 디바이스 모드 및 캔버스 UX 개선

- v2 레이아웃 데이터 구조 도입 (4모드별 별도 레이아웃 + 공유 컴포넌트 정의)
  - tablet_landscape, tablet_portrait, mobile_landscape, mobile_portrait
  - sections/components를 Record<string, Definition> 객체로 관리
  - v1 → v2 자동 마이그레이션 지원

- 캔버스 UX 개선
  - 줌 기능 (30%~150%, 마우스 휠 + 버튼)
  - 패닝 기능 (중앙 마우스, Space+드래그, 배경 드래그)
  - 2개 캔버스 동시 표시 (가로/세로 모드)

- Delete 키로 섹션/컴포넌트 삭제 기능 추가
  - layout.sections 순회하여 componentIds에서 부모 섹션 찾는 방식

- 미리보기 v2 레이아웃 호환성 수정
  - Object.keys(layout.sections).length 체크로 변경

수정 파일: PopDesigner.tsx, PopCanvas.tsx, SectionGridV2.tsx(신규),
          types/pop-layout.ts, PopPanel.tsx, PopScreenPreview.tsx,
          PopCategoryTree.tsx, screenManagementService.ts
This commit is contained in:
SeongHyun Kim
2026-02-03 11:25:00 +09:00
parent d9b7ef9ad4
commit 368d641ae8
8 changed files with 1919 additions and 566 deletions

View File

@@ -4710,12 +4710,89 @@ export class ScreenManagementService {
// ========================================
// POP 레이아웃 관리 (모바일/태블릿)
// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)
// ========================================
/**
* POP v1 → v2 마이그레이션 (백엔드)
* - 단일 sections 배열 → 4모드별 layouts + 공유 sections/components
*/
private migratePopV1ToV2(v1Data: any): any {
console.log("POP v1 → v2 마이그레이션 시작");
// 기본 v2 구조
const v2Data: any = {
version: "pop-2.0",
layouts: {
tablet_landscape: { sectionPositions: {}, componentPositions: {} },
tablet_portrait: { sectionPositions: {}, componentPositions: {} },
mobile_landscape: { sectionPositions: {}, componentPositions: {} },
mobile_portrait: { sectionPositions: {}, componentPositions: {} },
},
sections: {},
components: {},
dataFlow: {
sectionConnections: [],
},
settings: {
touchTargetMin: 48,
mode: "normal",
canvasGrid: v1Data.canvasGrid || { columns: 24, rowHeight: 20, gap: 4 },
},
metadata: v1Data.metadata,
};
// v1 섹션 배열 처리
const sections = v1Data.sections || [];
const modeKeys = ["tablet_landscape", "tablet_portrait", "mobile_landscape", "mobile_portrait"];
for (const section of sections) {
// 섹션 정의 생성
v2Data.sections[section.id] = {
id: section.id,
label: section.label,
componentIds: (section.components || []).map((c: any) => c.id),
innerGrid: section.innerGrid || { columns: 3, rows: 3, gap: 4 },
style: section.style,
};
// 섹션 위치 복사 (4모드 모두 동일)
const sectionPos = section.grid || { col: 1, row: 1, colSpan: 3, rowSpan: 4 };
for (const mode of modeKeys) {
v2Data.layouts[mode].sectionPositions[section.id] = { ...sectionPos };
}
// 컴포넌트별 처리
for (const comp of section.components || []) {
// 컴포넌트 정의 생성
v2Data.components[comp.id] = {
id: comp.id,
type: comp.type,
label: comp.label,
dataBinding: comp.dataBinding,
style: comp.style,
config: comp.config,
};
// 컴포넌트 위치 복사 (4모드 모두 동일)
const compPos = comp.grid || { col: 1, row: 1, colSpan: 1, rowSpan: 1 };
for (const mode of modeKeys) {
v2Data.layouts[mode].componentPositions[comp.id] = { ...compPos };
}
}
}
const sectionCount = Object.keys(v2Data.sections).length;
const componentCount = Object.keys(v2Data.components).length;
console.log(`POP v1 → v2 마이그레이션 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
return v2Data;
}
/**
* POP 레이아웃 조회
* - screen_layouts_pop 테이블에서 화면당 1개 레코드 조회
* - V2와 동일한 로직, 테이블명만 다름
* - v1 데이터는 자동으로 v2로 마이그레이션하여 반환
*/
async getLayoutPop(
screenId: number,
@@ -4792,16 +4869,32 @@ export class ScreenManagementService {
return null;
}
console.log(
`POP 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`,
);
return layout.layout_data;
const layoutData = layout.layout_data;
// v1 → v2 자동 마이그레이션
if (layoutData && layoutData.version === "pop-1.0") {
console.log("POP v1 레이아웃 감지, v2로 마이그레이션");
return this.migratePopV1ToV2(layoutData);
}
// v2 또는 버전 태그 없는 경우 (버전 태그 없으면 sections 구조 확인)
if (layoutData && !layoutData.version && layoutData.sections && Array.isArray(layoutData.sections)) {
console.log("버전 태그 없는 v1 레이아웃 감지, v2로 마이그레이션");
return this.migratePopV1ToV2({ ...layoutData, version: "pop-1.0" });
}
// v2 레이아웃 그대로 반환
const sectionCount = layoutData?.sections ? Object.keys(layoutData.sections).length : 0;
const componentCount = layoutData?.components ? Object.keys(layoutData.components).length : 0;
console.log(`POP v2 레이아웃 로드 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
return layoutData;
}
/**
* POP 레이아웃 저장
* - screen_layouts_pop 테이블에 화면당 1개 레코드 저장
* - V2와 동일한 로직, 테이블명만 다름
* - v2 형식으로 저장 (version: "pop-2.0")
*/
async saveLayoutPop(
screenId: number,
@@ -4811,7 +4904,18 @@ export class ScreenManagementService {
): Promise<void> {
console.log(`=== POP 레이아웃 저장 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
// v2 구조 확인
const isV2 = layoutData.version === "pop-2.0" ||
(layoutData.layouts && layoutData.sections && layoutData.components);
if (isV2) {
const sectionCount = Object.keys(layoutData.sections || {}).length;
const componentCount = Object.keys(layoutData.components || {}).length;
console.log(`v2 레이아웃: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
} else {
console.log(`v1 레이아웃 (섹션 수: ${layoutData.sections?.length || 0})`);
}
// 권한 확인
const screens = await query<{ company_code: string | null }>(
@@ -4829,11 +4933,20 @@ export class ScreenManagementService {
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
}
// 버전 정보 추가 (프론트엔드 pop-1.0과 통일)
const dataToSave = {
version: "pop-1.0",
...layoutData
};
// 버전 정보 보장 (v2 우선, v1은 프론트엔드에서 마이그레이션 후 저장 권장)
let dataToSave: any;
if (isV2) {
dataToSave = {
...layoutData,
version: "pop-2.0",
};
} else {
// v1 형식으로 저장 (하위 호환)
dataToSave = {
version: "pop-1.0",
...layoutData,
};
}
// UPSERT (있으면 업데이트, 없으면 삽입)
await query(
@@ -4844,7 +4957,7 @@ export class ScreenManagementService {
[screenId, companyCode, JSON.stringify(dataToSave), userId || null],
);
console.log(`POP 레이아웃 저장 완료`);
console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version})`);
}
/**