From de2163bcefb921e9365524c62778b95fb6d39945 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 3 Feb 2026 19:11:03 +0900 Subject: [PATCH] =?UTF-8?q?POP=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20v3?= =?UTF-8?q?.0=20=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20-=20Section=20=EC=9A=94=EC=86=8C=20=EC=99=84?= =?UTF-8?q?=EC=A0=84=20=EC=A0=9C=EA=B1=B0=20=EB=94=94=EC=9E=90=EC=9D=B4?= =?UTF-8?q?=EB=84=88/=EB=B7=B0=EC=96=B4=20=EB=A0=8C=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20-=20react-grid-layout=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?,=20CSS=20Grid=201fr=20=EB=8B=A8=EC=9C=84=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EB=B0=98=EC=9D=91=ED=98=95=20=EB=AA=A8=EB=93=9C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=A0=84=ED=99=98=20-=20useResponsiveMode=20?= =?UTF-8?q?=ED=9B=85=20=EC=B6=94=EA=B0=80=20=EB=94=94=EC=9E=90=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20UI=20=EA=B0=9C=EC=84=A0=20-=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=ED=97=A4=EB=8D=94/=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=A0=9C=EA=B1=B0,=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=98=81=EC=97=AD=20=EB=93=9C=EB=9E=98=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BUGFIX_CANVAS_ROWS.md | 227 +++ POPUPDATE.md | 132 +- .../src/services/screenManagementService.ts | 57 +- .../app/(pop)/pop/screens/[screenId]/page.tsx | 354 +++-- .../components/pop/designer/PopCanvas.tsx | 752 +++++---- .../components/pop/designer/PopDesigner.tsx | 210 +-- .../components/pop/designer/SectionGrid.tsx | 352 ----- .../components/pop/designer/SectionGridV2.tsx | 373 ----- .../designer/panels/ComponentEditorPanel.tsx | 215 +++ .../pop/designer/panels/PopPanel.tsx | 230 +-- .../components/pop/designer/panels/index.ts | 1 + .../designer/renderers/ComponentRenderer.tsx | 237 +++ .../designer/renderers/PopLayoutRenderer.tsx | 401 +++++ .../pop/designer/renderers/index.ts | 11 + .../pop/designer/types/pop-layout.ts | 1341 +++++++++-------- .../pop/management/PopCategoryTree.tsx | 84 +- frontend/hooks/useDeviceOrientation.ts | 208 +++ 17 files changed, 2991 insertions(+), 2194 deletions(-) create mode 100644 BUGFIX_CANVAS_ROWS.md delete mode 100644 frontend/components/pop/designer/SectionGrid.tsx delete mode 100644 frontend/components/pop/designer/SectionGridV2.tsx create mode 100644 frontend/components/pop/designer/panels/ComponentEditorPanel.tsx create mode 100644 frontend/components/pop/designer/renderers/ComponentRenderer.tsx create mode 100644 frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx create mode 100644 frontend/components/pop/designer/renderers/index.ts create mode 100644 frontend/hooks/useDeviceOrientation.ts diff --git a/BUGFIX_CANVAS_ROWS.md b/BUGFIX_CANVAS_ROWS.md new file mode 100644 index 00000000..713edce6 --- /dev/null +++ b/BUGFIX_CANVAS_ROWS.md @@ -0,0 +1,227 @@ +# POP 레이아웃 canvasGrid.rows 버그 수정 + +## 문제점 + +### 1. 데이터 불일치 +- **DB에 저장된 데이터**: `canvasGrid.rowHeight: 20` (고정 픽셀) +- **코드에서 기대하는 데이터**: `canvasGrid.rows: 24` (비율 기반) +- **결과**: `rows`가 `undefined`로 인한 렌더링 오류 + +### 2. 타입 정의 불일치 +- **PopCanvas.tsx 타입**: `{ columns: number; rowHeight: number; gap: number }` +- **실제 사용**: `canvasGrid.rows`로 계산 +- **결과**: 타입 안정성 저하 + +### 3. 렌더링 오류 +- **디자이너**: `rowHeight = resolution.height / undefined` → `NaN` +- **뷰어**: `gridTemplateRows: repeat(undefined, 1fr)` → CSS 무효 +- **결과**: 섹션이 매우 작게 표시됨 + +--- + +## 수정 내용 + +### 1. ensureV2Layout 강화 +**파일**: `frontend/components/pop/designer/types/pop-layout.ts` + +```typescript +export const ensureV2Layout = (data: PopLayoutData): PopLayoutDataV2 => { + let result: PopLayoutDataV2; + + if (isV2Layout(data)) { + result = data; + } else if (isV1Layout(data)) { + result = migrateV1ToV2(data); + } else { + console.warn("알 수 없는 레이아웃 버전, 빈 v2 레이아웃 생성"); + result = createEmptyPopLayoutV2(); + } + + // ✅ canvasGrid.rows 보장 (구버전 데이터 호환) + if (!result.settings.canvasGrid.rows) { + console.warn("canvasGrid.rows 없음, 기본값 24로 설정"); + result.settings.canvasGrid = { + ...result.settings.canvasGrid, + rows: DEFAULT_CANVAS_GRID.rows, // 24 + }; + } + + return result; +}; +``` + +**효과**: DB에서 로드한 구버전 데이터도 자동으로 `rows: 24` 보장 + +--- + +### 2. PopCanvas.tsx 타입 수정 및 fallback +**파일**: `frontend/components/pop/designer/PopCanvas.tsx` + +**타입 정의 수정**: +```typescript +interface DeviceFrameProps { + canvasGrid: { columns: number; rows: number; gap: number }; // rowHeight → rows + // ... +} +``` + +**fallback 추가**: +```typescript +// ✅ rows가 없으면 24 사용 +const rows = canvasGrid.rows || 24; +const rowHeight = Math.floor(resolution.height / rows); +``` + +**효과**: +- 타입 일관성 확보 +- `NaN` 방지 + +--- + +### 3. PopLayoutRenderer.tsx fallback +**파일**: `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx` + +```typescript +style={{ + display: "grid", + gridTemplateColumns: `repeat(${canvasGrid.columns}, 1fr)`, + // ✅ fallback 추가 + gridTemplateRows: `repeat(${canvasGrid.rows || 24}, 1fr)`, + gap: `${canvasGrid.gap}px`, + padding: `${canvasGrid.gap}px`, +}} +``` + +**효과**: 뷰어에서도 안전하게 렌더링 + +--- + +### 4. 백엔드 저장 로직 강화 +**파일**: `backend-node/src/services/screenManagementService.ts` + +```typescript +if (isV2) { + dataToSave = { + ...layoutData, + version: "pop-2.0", + }; + + // ✅ canvasGrid.rows 검증 및 보정 + if (dataToSave.settings?.canvasGrid) { + if (!dataToSave.settings.canvasGrid.rows) { + console.warn("canvasGrid.rows 없음, 기본값 24로 설정"); + dataToSave.settings.canvasGrid.rows = 24; + } + // ✅ 구버전 rowHeight 필드 제거 + if (dataToSave.settings.canvasGrid.rowHeight) { + console.warn("구버전 rowHeight 필드 제거"); + delete dataToSave.settings.canvasGrid.rowHeight; + } + } +} +``` + +**효과**: 앞으로 저장되는 모든 데이터는 올바른 구조 보장 + +--- + +## 원칙 준수 여부 + +### 1. 데스크톱과 완전 분리 ✅ +- POP 전용 파일만 수정 +- 데스크톱 코드 0% 영향 + +### 2. 4모드 반응형 디자인 ✅ +- 변경 없음 + +### 3. 비율 기반 그리드 시스템 ✅ +- **오히려 원칙을 바로잡는 수정** +- 고정 픽셀(`rowHeight`) → 비율(`rows`) 강제 + +--- + +## 해결된 문제 + +| 문제 | 수정 전 | 수정 후 | +|------|---------|---------| +| 섹션 크기 | 매우 작게 표시 | 정상 크기 (24x24 그리드) | +| 디자이너 렌더링 | `NaN` 오류 | 정상 계산 | +| 뷰어 렌더링 | CSS 무효 | 비율 기반 렌더링 | +| 타입 안정성 | `rowHeight` vs `rows` 불일치 | `rows`로 통일 | +| 구버전 데이터 | 호환 불가 | 자동 보정 | + +--- + +## 테스트 방법 + +### 1. 기존 화면 확인 (screen_id: 3884) +```bash +# 디자이너 접속 +http://localhost:9771/screen-management/pop-designer/3884 + +# 저장 후 뷰어 확인 +http://localhost:9771/pop/screens/3884 +``` + +**기대 결과**: +- 섹션이 화면 전체 크기로 정상 표시 +- 가로/세로 모드 전환 시 비율 유지 + +### 2. 새로운 화면 생성 +- POP 디자이너에서 새 화면 생성 +- 섹션 추가 및 배치 +- 저장 후 DB 확인 + +**DB 확인**: +```sql +SELECT + screen_id, + layout_data->'settings'->'canvasGrid' as canvas_grid +FROM screen_layouts_pop +WHERE screen_id = 3884; +``` + +**기대 결과**: +```json +{ + "gap": 4, + "rows": 24, + "columns": 24 +} +``` + +--- + +## 추가 조치 사항 + +### 1. 기존 DB 데이터 마이그레이션 (선택) +만약 프론트엔드 자동 보정이 아닌 DB 마이그레이션을 원한다면: + +```sql +UPDATE screen_layouts_pop +SET layout_data = jsonb_set( + jsonb_set( + layout_data, + '{settings,canvasGrid,rows}', + '24' + ), + '{settings,canvasGrid}', + (layout_data->'settings'->'canvasGrid') - 'rowHeight' +) +WHERE layout_data->'settings'->'canvasGrid'->>'rows' IS NULL + AND layout_data->>'version' = 'pop-2.0'; +``` + +### 2. 모드별 컴포넌트 위치 반대 문제 +**별도 이슈**: `activeModeKey` 상태 관리 점검 필요 +- DeviceFrame 클릭 시 모드 전환 +- 저장 시 올바른 `modeKey` 전달 확인 + +--- + +## 결론 + +✅ **원칙 준수**: 데스크톱 분리, 4모드 반응형 유지 +✅ **비율 기반 강제**: 고정 픽셀 제거 +✅ **하위 호환**: 구버전 데이터 자동 보정 +✅ **안정성 향상**: 타입 일관성 확보 diff --git a/POPUPDATE.md b/POPUPDATE.md index 49331154..836cdb1f 100644 --- a/POPUPDATE.md +++ b/POPUPDATE.md @@ -887,6 +887,117 @@ const filteredMoveGroups = useMemo(() => { --- +## 14. 비율 기반 그리드 시스템 (2026-02-03) + +### 문제 발견 + +POP 디자이너에서 섹션을 크게 설정해도 뷰어에서 매우 얇게(약 20px) 렌더링되는 문제 발생. + +### 근본 원인 분석 + +1. **기존 구조**: `canvasGrid.rowHeight = 20` (고정 픽셀) +2. **react-grid-layout 동작**: 작은 리사이즈 → `rowSpan: 1`로 반올림 → DB 저장 +3. **뷰어 렌더링**: `gridAutoRows: 20px` → 섹션 높이 = 20px (매우 얇음) +4. **비교**: 가로(columns)는 `1fr` 비율 기반으로 잘 작동 + +### 해결책: 비율 기반 행 시스템 + +| 구분 | 이전 | 이후 | +|------|------|------| +| 타입 | `rowHeight: number` (px) | `rows: number` (개수) | +| 기본값 | `rowHeight: 20` | `rows: 24` | +| 뷰어 CSS | `gridAutoRows: 20px` | `gridTemplateRows: repeat(24, 1fr)` | +| 디자이너 계산 | 고정 20px | `resolution.height / 24` | + +### 수정된 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `types/pop-layout.ts` | `PopCanvasGrid.rowHeight` → `rows`, `DEFAULT_CANVAS_GRID.rows = 24` | +| `renderers/PopLayoutRenderer.tsx` | `gridAutoRows` → `gridTemplateRows: repeat(rows, 1fr)` | +| `PopCanvas.tsx` | `rowHeight = Math.floor(resolution.height / canvasGrid.rows)` | + +### 모드별 행 높이 계산 + +| 모드 | 해상도 높이 | 행 높이 (24행 기준) | +|------|-------------|---------------------| +| tablet_landscape | 768px | 32px | +| tablet_portrait | 1024px | 42.7px | +| mobile_landscape | 375px | 15.6px | +| mobile_portrait | 667px | 27.8px | + +### 기존 데이터 호환성 + +- 기존 `rowHeight: 20` 데이터는 `rows || 24` fallback으로 처리 +- 기존 `rowSpan: 1` 데이터는 1/24 = 4.17%로 렌더링 (여전히 작음) +- **권장**: 디자이너에서 섹션 재조정 후 재저장 + +--- + +## 15. 화면 삭제 기능 추가 (2026-02-03) + +### 추가된 기능 + +POP 카테고리 트리에서 화면 자체를 삭제하는 기능 추가. + +### UI 변경 + +| 위치 | 메뉴 항목 | 동작 | +|------|----------|------| +| 그룹 내 화면 드롭다운 | "화면 삭제" | 휴지통으로 이동 | +| 미분류 화면 드롭다운 | "화면 삭제" | 휴지통으로 이동 | + +### 삭제 흐름 + +``` +1. 드롭다운 메뉴에서 "화면 삭제" 클릭 +2. 확인 다이얼로그 표시 ("삭제된 화면은 휴지통으로 이동됩니다") +3. 확인 → DELETE /api/screen-management/screens/:id +4. 화면 is_deleted = 'Y'로 변경 (soft delete) +5. 그룹 목록 새로고침 +``` + +### 완전 삭제 vs 휴지통 이동 + +| API | 동작 | 복원 가능 | +|-----|------|----------| +| `DELETE /screens/:id` | 휴지통으로 이동 (is_deleted='Y') | O | +| `DELETE /screens/:id/permanent` | DB에서 완전 삭제 | X | + +### 수정된 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `PopCategoryTree.tsx` | `handleDeleteScreen`, `confirmDeleteScreen` 함수 추가 | +| `PopCategoryTree.tsx` | `isScreenDeleteDialogOpen`, `deletingScreen` 상태 추가 | +| `PopCategoryTree.tsx` | TreeNode에 `onDeleteScreen` prop 추가 | +| `PopCategoryTree.tsx` | 화면 삭제 확인 AlertDialog 추가 | + +--- + +## 16. 멀티테넌시 이슈 해결 (2026-02-03) + +### 문제 + +화면 그룹에서 제거 시 404 에러 발생. + +### 원인 + +- DB 데이터: `company_code = "*"` (최고 관리자 전용) +- 현재 세션: `company_code = "COMPANY_7"` +- 컨트롤러 WHERE 조건: `id = $1 AND company_code = $2` → 0 rows + +### 해결 + +세션 불일치 문제로 DB에서 직접 삭제 처리. + +### 교훈 + +- 최고 관리자로 생성한 데이터는 일반 회사 사용자가 삭제 불가 +- 로그인 후 토큰 갱신 필요 시 브라우저 완전 새로고침 + +--- + ## 트러블슈팅 ### Export default doesn't exist in target module @@ -897,6 +1008,22 @@ const filteredMoveGroups = useMemo(() => { **해결:** `import { apiClient } from "@/lib/api/client"` 사용 +### 섹션이 매우 얇게 렌더링되는 문제 + +**문제:** 디자이너에서 크게 설정한 섹션이 뷰어에서 20px 높이로 표시 + +**원인:** `canvasGrid.rowHeight = 20` 고정값 + react-grid-layout의 rowSpan 반올림 + +**해결:** 비율 기반 rows 시스템으로 변경 (섹션 14 참조) + +### 화면 삭제 404 에러 + +**문제:** 화면 그룹에서 제거 시 404 에러 + +**원인:** company_code 불일치 (세션 vs DB) + +**해결:** 브라우저 새로고침으로 토큰 갱신 또는 DB 직접 처리 + ### 관련 파일 | 파일 | 역할 | @@ -905,7 +1032,10 @@ const filteredMoveGroups = useMemo(() => { | `frontend/lib/api/popScreenGroup.ts` | POP 그룹 API 클라이언트 | | `backend-node/src/controllers/screenGroupController.ts` | 그룹 CRUD 컨트롤러 | | `backend-node/src/routes/screenGroupRoutes.ts` | 그룹 API 라우트 | +| `frontend/components/pop/designer/types/pop-layout.ts` | POP 레이아웃 타입 정의 | +| `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx` | CSS Grid 기반 렌더러 | +| `frontend/components/pop/designer/PopCanvas.tsx` | react-grid-layout 디자이너 캔버스 | --- -*최종 업데이트: 2026-01-29* +*최종 업데이트: 2026-02-03* diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 88f9f25f..25f0fc90 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -4894,7 +4894,8 @@ export class ScreenManagementService { /** * POP 레이아웃 저장 * - screen_layouts_pop 테이블에 화면당 1개 레코드 저장 - * - v2 형식으로 저장 (version: "pop-2.0") + * - v3 형식 지원 (version: "pop-3.0", 섹션 제거) + * - v2/v1 하위 호환 */ async saveLayoutPop( screenId: number, @@ -4905,11 +4906,16 @@ export class ScreenManagementService { console.log(`=== POP 레이아웃 저장 시작 ===`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); - // v2 구조 확인 + // 버전 감지 + const isV3 = layoutData.version === "pop-3.0" || + (layoutData.layouts && layoutData.components && !layoutData.sections); const isV2 = layoutData.version === "pop-2.0" || (layoutData.layouts && layoutData.sections && layoutData.components); - if (isV2) { + if (isV3) { + const componentCount = Object.keys(layoutData.components || {}).length; + console.log(`v3 레이아웃: ${componentCount}개 컴포넌트 (섹션 없음)`); + } else if (isV2) { const sectionCount = Object.keys(layoutData.sections || {}).length; const componentCount = Object.keys(layoutData.components || {}).length; console.log(`v2 레이아웃: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); @@ -4933,13 +4939,50 @@ export class ScreenManagementService { throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다."); } - // 버전 정보 보장 (v2 우선, v1은 프론트엔드에서 마이그레이션 후 저장 권장) + // SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 (로드와 동일하게) + const targetCompanyCode = companyCode === "*" + ? (existingScreen.company_code || "*") + : companyCode; + + console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`); + + // 버전 정보 보장 let dataToSave: any; - if (isV2) { + if (isV3) { + dataToSave = { + ...layoutData, + version: "pop-3.0", + }; + + // canvasGrid.rows 검증 및 보정 + if (dataToSave.settings?.canvasGrid) { + if (!dataToSave.settings.canvasGrid.rows) { + console.warn("canvasGrid.rows 없음, 기본값 24로 설정"); + dataToSave.settings.canvasGrid.rows = 24; + } + // 구버전 rowHeight 필드 제거 + if (dataToSave.settings.canvasGrid.rowHeight) { + console.warn("구버전 rowHeight 필드 제거"); + delete dataToSave.settings.canvasGrid.rowHeight; + } + } + } else if (isV2) { dataToSave = { ...layoutData, version: "pop-2.0", }; + + // canvasGrid.rows 검증 및 보정 + if (dataToSave.settings?.canvasGrid) { + if (!dataToSave.settings.canvasGrid.rows) { + console.warn("canvasGrid.rows 없음, 기본값 24로 설정"); + dataToSave.settings.canvasGrid.rows = 24; + } + if (dataToSave.settings.canvasGrid.rowHeight) { + console.warn("구버전 rowHeight 필드 제거"); + delete dataToSave.settings.canvasGrid.rowHeight; + } + } } else { // v1 형식으로 저장 (하위 호환) dataToSave = { @@ -4954,10 +4997,10 @@ export class ScreenManagementService { VALUES ($1, $2, $3, NOW(), NOW(), $4, $4) ON CONFLICT (screen_id, company_code) DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`, - [screenId, companyCode, JSON.stringify(dataToSave), userId || null], + [screenId, targetCompanyCode, JSON.stringify(dataToSave), userId || null], ); - console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version})`); + console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version}, company: ${targetCompanyCode})`); } /** diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index daa2350d..4726189c 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -1,11 +1,11 @@ "use client"; -import React, { useEffect, useState, useMemo } from "react"; +import React, { useEffect, useState } from "react"; import { useParams, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { Loader2, ArrowLeft, Smartphone, Tablet, Monitor, RotateCcw } from "lucide-react"; +import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; -import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen"; +import { ScreenDefinition, LayoutData } from "@/types/screen"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { initializeComponents } from "@/lib/registry/components"; @@ -18,35 +18,89 @@ import { ScreenContextProvider } from "@/contexts/ScreenContext"; import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; -import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; +import { + PopLayoutDataV3, + PopLayoutModeKey, + ensureV3Layout, + isV3Layout, +} from "@/components/pop/designer/types/pop-layout"; +import { + PopLayoutRenderer, + hasBaseLayout, + getEffectiveModeLayout, +} from "@/components/pop/designer/renderers"; +import { + useResponsiveMode, + useResponsiveModeWithOverride, + type DeviceType, +} from "@/hooks/useDeviceOrientation"; -// POP 디바이스 타입 -type DeviceType = "mobile" | "tablet"; - -// 디바이스별 크기 -const DEVICE_SIZES = { - mobile: { width: 375, height: 812, label: "모바일" }, - tablet: { width: 768, height: 1024, label: "태블릿" }, +// 디바이스별 크기 (프리뷰 모드용) +const DEVICE_SIZES: Record> = { + mobile: { + landscape: { width: 667, height: 375, label: "모바일 가로" }, + portrait: { width: 375, height: 667, label: "모바일 세로" }, + }, + tablet: { + landscape: { width: 1024, height: 768, label: "태블릿 가로" }, + portrait: { width: 768, height: 1024, label: "태블릿 세로" }, + }, }; +// ======================================== +// 헬퍼 함수 +// ======================================== + +const getModeKey = (device: DeviceType, isLandscape: boolean): PopLayoutModeKey => { + if (device === "tablet") { + return isLandscape ? "tablet_landscape" : "tablet_portrait"; + } + return isLandscape ? "mobile_landscape" : "mobile_portrait"; +}; + +// v3.0 레이아웃인지 확인 +const isPopLayoutV3 = (layout: any): layout is PopLayoutDataV3 => { + return layout && layout.version === "pop-3.0" && layout.layouts && layout.components; +}; + +// v1/v2 레이아웃인지 확인 (마이그레이션 대상) +const isPopLayout = (layout: any): boolean => { + return layout && ( + layout.version === "pop-1.0" || + layout.version === "pop-2.0" || + layout.version === "pop-3.0" + ); +}; + +// ======================================== +// 메인 컴포넌트 +// ======================================== + function PopScreenViewPage() { const params = useParams(); const searchParams = useSearchParams(); const router = useRouter(); const screenId = parseInt(params.screenId as string); - // URL 쿼리에서 디바이스 타입 가져오기 (기본: tablet) - const deviceParam = searchParams.get("device") as DeviceType | null; - const [deviceType, setDeviceType] = useState(deviceParam || "tablet"); - - // 프리뷰 모드 (디자이너에서 열렸을 때) const isPreviewMode = searchParams.get("preview") === "true"; - // 사용자 정보 + // 반응형 모드 감지 (화면 크기에 따라 tablet/mobile, landscape/portrait 자동 전환) + // 프리뷰 모드에서는 수동 전환 가능 + const { mode, setDevice, setOrientation, isAutoDetect } = useResponsiveModeWithOverride( + isPreviewMode ? "tablet" : undefined, + isPreviewMode ? true : undefined + ); + + // 현재 모드 정보 + const deviceType = mode.device; + const isLandscape = mode.isLandscape; + const currentModeKey = mode.modeKey; + const { user, userName, companyCode } = useAuth(); const [screen, setScreen] = useState(null); const [layout, setLayout] = useState(null); + const [popLayoutV3, setPopLayoutV3] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -73,62 +127,39 @@ function PopScreenViewPage() { setLoading(true); setError(null); - // 화면 정보 로드 const screenData = await screenApi.getScreen(screenId); setScreen(screenData); - // POP 레이아웃 로드 (screen_layouts_pop 테이블에서) - // POP 레이아웃은 sections[] 구조 사용 (데스크톱의 components[]와 다름) try { const popLayout = await screenApi.getLayoutPop(screenId); - if (popLayout && popLayout.sections && popLayout.sections.length > 0) { - // POP 레이아웃 (sections 구조) - 그대로 저장 - console.log("POP 레이아웃 로드:", popLayout.sections?.length || 0, "개 섹션"); - setLayout(popLayout as any); // sections 구조 그대로 사용 - } else if (popLayout && popLayout.components && popLayout.components.length > 0) { - // 이전 형식 (components 구조) - 호환성 유지 - console.log("POP 레이아웃 로드 (이전 형식):", popLayout.components?.length || 0, "개 컴포넌트"); + if (popLayout && isPopLayout(popLayout)) { + // v1/v2/v3 → v3로 변환 + const v3Layout = ensureV3Layout(popLayout); + setPopLayoutV3(v3Layout); + + const componentCount = Object.keys(v3Layout.components).length; + console.log(`[POP] v3 레이아웃 로드됨: ${componentCount}개 컴포넌트`); + + if (!isV3Layout(popLayout)) { + console.log("[POP] v1/v2 → v3 자동 마이그레이션 완료"); + } + } else if (popLayout && popLayout.components && Array.isArray(popLayout.components) && popLayout.components.length > 0) { + // 이전 형식 (레거시 components 구조) + console.log("[POP] 레거시 레이아웃 로드:", popLayout.components.length, "개 컴포넌트"); setLayout(popLayout as LayoutData); } else { - // POP 레이아웃이 비어있으면 빈 레이아웃 - console.log("POP 레이아웃 없음, 빈 화면 표시"); - setLayout({ - screenId, - sections: [], - components: [], - gridSettings: { - columns: 12, - gap: 8, - padding: 16, - enabled: true, - size: 8, - color: "#e0e0e0", - opacity: 0.5, - snapToGrid: true, - }, - } as any); + console.log("[POP] 레이아웃 없음"); + setPopLayoutV3(null); + setLayout(null); } } catch (layoutError) { - console.warn("POP 레이아웃 로드 실패:", layoutError); - setLayout({ - screenId, - sections: [], - components: [], - gridSettings: { - columns: 12, - gap: 8, - padding: 16, - enabled: true, - size: 8, - color: "#e0e0e0", - opacity: 0.5, - snapToGrid: true, - }, - } as any); + console.warn("[POP] 레이아웃 로드 실패:", layoutError); + setPopLayoutV3(null); + setLayout(null); } } catch (error) { - console.error("POP 화면 로드 실패:", error); + console.error("[POP] 화면 로드 실패:", error); setError("화면을 불러오는데 실패했습니다."); toast.error("화면을 불러오는데 실패했습니다."); } finally { @@ -141,8 +172,7 @@ function PopScreenViewPage() { } }, [screenId]); - // 현재 디바이스 크기 - const currentDevice = DEVICE_SIZES[deviceType]; + const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"]; if (loading) { return ( @@ -177,7 +207,7 @@ function PopScreenViewPage() { -
+
{/* 상단 툴바 (프리뷰 모드에서만) */} {isPreviewMode && (
@@ -188,27 +218,65 @@ function PopScreenViewPage() { 닫기 {screen.screenName} + + ({currentModeKey.replace("_", " ")}) +
- {/* 디바이스 전환 버튼 */} -
+
+
+ + +
+ +
+ + +
+ + {/* 자동 감지 모드 버튼 */} -
@@ -220,67 +288,30 @@ function PopScreenViewPage() { )} {/* POP 화면 컨텐츠 */} -
+
+ {/* 현재 모드 표시 (일반 모드) */} + {!isPreviewMode && ( +
+ {currentModeKey.replace("_", " ")} +
+ )} +
- {/* POP 레이아웃: sections 구조 렌더링 */} - {layout && (layout as any).sections && (layout as any).sections.length > 0 ? ( -
- {/* 그리드 레이아웃으로 섹션 배치 */} -
- {(layout as any).sections.map((section: any) => ( -
- {/* 섹션 라벨 */} - {section.label && ( -
- {section.label} -
- )} - {/* 섹션 내 컴포넌트들 */} - {section.components && section.components.length > 0 ? ( -
- {section.components.map((comp: any) => ( -
- {/* TODO: POP 전용 컴포넌트 렌더러 구현 필요 */} - - {comp.label || comp.type || comp.id} - -
- ))} -
- ) : ( -
- 빈 섹션 -
- )} -
- ))} -
-
+ {/* POP 레이아웃 v3.0 렌더링 */} + {popLayoutV3 ? ( + ) : layout && layout.components && layout.components.length > 0 ? ( - // 이전 형식 (components 구조) - 호환성 유지 + // 레거시 형식 (components 구조) - 호환성 유지
{layout.components @@ -348,6 +379,55 @@ function PopScreenViewPage() { ); } +// ======================================== +// POP 레이아웃 v3.0 렌더러 +// ======================================== +interface PopLayoutV3RendererProps { + layout: PopLayoutDataV3; + modeKey: PopLayoutModeKey; +} + +function PopLayoutV3Renderer({ layout, modeKey }: PopLayoutV3RendererProps) { + // 태블릿 가로 모드가 기준으로 설정되어 있는지 확인 + if (!hasBaseLayout(layout)) { + return ( +
+
+ ! +
+

+ 화면이 설정되지 않았습니다 +

+

+ POP 화면 디자이너에서 태블릿 가로 모드 레이아웃을 먼저 설정해주세요. +

+
+ ); + } + + // 현재 모드에 맞는 레이아웃 가져오기 + const { modeLayout, isConverted, sourceModeKey } = getEffectiveModeLayout(layout, modeKey); + + return ( +
+ {isConverted && ( +
+ {sourceModeKey} 기준 자동 변환됨 +
+ )} + + +
+ ); +} + // Provider 래퍼 export default function PopScreenViewPageWrapper() { return ( diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index b68f806d..2d98dec8 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -1,23 +1,18 @@ "use client"; -import { useCallback, useMemo, useRef, useState, useEffect } from "react"; +import { useCallback, useRef, useState, useEffect } from "react"; import { useDrop } from "react-dnd"; -import GridLayout, { Layout } from "react-grid-layout"; import { cn } from "@/lib/utils"; import { - PopLayoutDataV2, + PopLayoutDataV3, PopLayoutModeKey, PopComponentType, GridPosition, MODE_RESOLUTIONS, } from "./types/pop-layout"; -import { DND_ITEM_TYPES, DragItemSection } from "./panels/PopPanel"; -import { GripVertical, Trash2, ZoomIn, ZoomOut, Maximize2 } from "lucide-react"; +import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel"; +import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { SectionGridV2 } from "./SectionGridV2"; - -import "react-grid-layout/css/styles.css"; -import "react-resizable/css/styles.css"; // ======================================== // 타입 정의 @@ -32,24 +27,29 @@ const MODE_LABELS: Record = { mobile_portrait: "모바일 세로", }; +// 컴포넌트 타입별 라벨 +const COMPONENT_TYPE_LABELS: Record = { + "pop-field": "필드", + "pop-button": "버튼", + "pop-list": "리스트", + "pop-indicator": "인디케이터", + "pop-scanner": "스캐너", + "pop-numpad": "숫자패드", +}; + // ======================================== // Props // ======================================== interface PopCanvasProps { - layout: PopLayoutDataV2; + layout: PopLayoutDataV3; activeDevice: DeviceType; activeModeKey: PopLayoutModeKey; onModeKeyChange: (modeKey: PopLayoutModeKey) => void; - selectedSectionId: string | null; selectedComponentId: string | null; - onSelectSection: (id: string | null) => void; onSelectComponent: (id: string | null) => void; - onUpdateSectionPosition: (sectionId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void; onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void; - onDeleteSection: (id: string) => void; - onDropSection: (gridPosition: GridPosition) => void; - onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void; - onDeleteComponent: (sectionId: string, componentId: string) => void; + onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => void; + onDeleteComponent: (componentId: string) => void; } // ======================================== @@ -60,51 +60,33 @@ export function PopCanvas({ activeDevice, activeModeKey, onModeKeyChange, - selectedSectionId, selectedComponentId, - onSelectSection, onSelectComponent, - onUpdateSectionPosition, onUpdateComponentPosition, - onDeleteSection, - onDropSection, onDropComponent, onDeleteComponent, }: PopCanvasProps) { - const { settings, sections, components, layouts } = layout; + const { settings, components, layouts } = layout; const canvasGrid = settings.canvasGrid; - // 줌 상태 (0.3 ~ 1.0 범위) + // 줌 상태 (0.3 ~ 1.5 범위) const [canvasScale, setCanvasScale] = useState(0.6); // 패닝 상태 const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); - const [isSpacePressed, setIsSpacePressed] = useState(false); // Space 키 눌림 상태 + const [isSpacePressed, setIsSpacePressed] = useState(false); const containerRef = useRef(null); - // 줌 인 (최대 1.5로 증가) - const handleZoomIn = () => { - setCanvasScale((prev) => Math.min(1.5, prev + 0.1)); - }; + // 줌 컨트롤 + const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1)); + const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1)); + const handleZoomFit = () => setCanvasScale(1.0); - // 줌 아웃 (최소 0.3) - const handleZoomOut = () => { - setCanvasScale((prev) => Math.max(0.3, prev - 0.1)); - }; - - // 맞춤 (1.0) - const handleZoomFit = () => { - setCanvasScale(1.0); - }; - - // 패닝 시작 (중앙 마우스 버튼 또는 배경 영역 드래그) + // 패닝 const handlePanStart = (e: React.MouseEvent) => { - // 중앙 마우스 버튼(휠 버튼, button === 1) 또는 Space 키 누른 상태 - // 또는 내부 컨테이너(스크롤 영역) 직접 클릭 시 const isMiddleButton = e.button === 1; const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area"); - if (isMiddleButton || isSpacePressed || isScrollAreaClick) { setIsPanning(true); setPanStart({ x: e.clientX, y: e.clientY }); @@ -112,60 +94,44 @@ export function PopCanvas({ } }; - // 패닝 중 const handlePanMove = (e: React.MouseEvent) => { if (!isPanning || !containerRef.current) return; - const deltaX = e.clientX - panStart.x; const deltaY = e.clientY - panStart.y; - containerRef.current.scrollLeft -= deltaX; containerRef.current.scrollTop -= deltaY; - setPanStart({ x: e.clientX, y: e.clientY }); }; - // 패닝 종료 - const handlePanEnd = () => { - setIsPanning(false); - }; + const handlePanEnd = () => setIsPanning(false); - // 마우스 휠 줌 (0.3 ~ 1.5 범위) + // 마우스 휠 줌 const handleWheel = useCallback((e: React.WheelEvent) => { - e.preventDefault(); // 브라우저 스크롤 방지 - - const delta = e.deltaY > 0 ? -0.1 : 0.1; // 위로 스크롤: 줌인, 아래로 스크롤: 줌아웃 + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.1 : 0.1; setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta))); }, []); // Space 키 감지 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.code === "Space" && !isSpacePressed) { - setIsSpacePressed(true); - } + if (e.code === "Space" && !isSpacePressed) setIsSpacePressed(true); }; - const handleKeyUp = (e: KeyboardEvent) => { - if (e.code === "Space") { - setIsSpacePressed(false); - } + if (e.code === "Space") setIsSpacePressed(false); }; - window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); - return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); }; }, [isSpacePressed]); - // 초기 로드 시 캔버스를 중앙으로 스크롤 + // 초기 로드 시 캔버스 중앙 스크롤 useEffect(() => { if (containerRef.current) { const container = containerRef.current; - // 약간의 딜레이 후 중앙으로 스크롤 (DOM이 완전히 렌더링된 후) const timer = setTimeout(() => { const scrollX = (container.scrollWidth - container.clientWidth) / 2; const scrollY = (container.scrollHeight - container.clientHeight) / 2; @@ -173,7 +139,7 @@ export function PopCanvas({ }, 100); return () => clearTimeout(timer); } - }, [activeDevice]); // 디바이스 변경 시 재중앙화 + }, [activeDevice]); // 현재 디바이스의 가로/세로 모드 키 const landscapeModeKey: PopLayoutModeKey = activeDevice === "tablet" @@ -183,117 +149,6 @@ export function PopCanvas({ ? "tablet_portrait" : "mobile_portrait"; - // 단일 캔버스 프레임 렌더링 - const renderDeviceFrame = (modeKey: PopLayoutModeKey) => { - const resolution = MODE_RESOLUTIONS[modeKey]; - const isActive = modeKey === activeModeKey; - const modeLayout = layouts[modeKey]; - - // 이 모드의 섹션 위치 목록 - const sectionPositions = modeLayout.sectionPositions; - const sectionIds = Object.keys(sectionPositions); - - // GridLayout용 레이아웃 아이템 생성 - const gridLayoutItems: Layout[] = sectionIds.map((sectionId) => { - const pos = sectionPositions[sectionId]; - return { - i: sectionId, - x: pos.col - 1, - y: pos.row - 1, - w: pos.colSpan, - h: pos.rowSpan, - minW: 2, - minH: 1, - }; - }); - - const cols = canvasGrid.columns; - const rowHeight = canvasGrid.rowHeight; - const margin: [number, number] = [canvasGrid.gap, canvasGrid.gap]; - - const sizeLabel = `${resolution.width}x${resolution.height}`; - const modeLabel = `${MODE_LABELS[modeKey]} (${sizeLabel})`; - - // 드래그/리사이즈 완료 핸들러 - const handleDragResizeStop = ( - layoutItems: Layout[], - oldItem: Layout, - newItem: Layout - ) => { - const newPos: GridPosition = { - col: newItem.x + 1, - row: newItem.y + 1, - colSpan: newItem.w, - rowSpan: newItem.h, - }; - onUpdateSectionPosition(newItem.i, newPos, modeKey); - }; - - return ( -
{ - if (!isActive) { - onModeKeyChange(modeKey); - } - }} - > - {/* 모드 라벨 */} -
- {modeLabel} -
- - {/* 활성 표시 배지 */} - {isActive && ( -
- 편집 중 -
- )} - - {/* 드롭 영역 */} - onUpdateComponentPosition(compId, pos, modeKey)} - onDeleteSection={onDeleteSection} - onDeleteComponent={onDeleteComponent} - /> -
- ); - }; - return (
{/* 줌 컨트롤 바 */} @@ -301,36 +156,18 @@ export function PopCanvas({ 줌: {Math.round(canvasScale * 100)}% - - -
- {/* 캔버스 영역 (패닝 가능) */} + {/* 캔버스 영역 */}
- {/* 스크롤 가능한 큰 영역 - 빈 공간 클릭 시 패닝 가능 */}
- {/* 가로 모드 캔버스 */} - {renderDeviceFrame(landscapeModeKey)} + {/* 가로 모드 */} + - {/* 세로 모드 캔버스 */} - {renderDeviceFrame(portraitModeKey)} + {/* 세로 모드 */} +
@@ -366,89 +221,202 @@ export function PopCanvas({ } // ======================================== -// 캔버스 드롭 영역 컴포넌트 +// CSS Grid 기반 디바이스 프레임 (v3: 컴포넌트 직접 배치) // ======================================== -interface CanvasDropZoneProps { +interface DeviceFrameProps { modeKey: PopLayoutModeKey; isActive: boolean; - resolution: { width: number; height: number }; scale: number; - cols: number; - rowHeight: number; - margin: [number, number]; - sections: PopLayoutDataV2["sections"]; - components: PopLayoutDataV2["components"]; - sectionPositions: Record; - componentPositions: Record; - gridLayoutItems: Layout[]; - selectedSectionId: string | null; + canvasGrid: { columns: number; rows: number; gap: number }; + layout: PopLayoutDataV3; selectedComponentId: string | null; - onSelectSection: (id: string | null) => void; + onModeKeyChange: (modeKey: PopLayoutModeKey) => void; onSelectComponent: (id: string | null) => void; - onDragResizeStop: (layout: Layout[], oldItem: Layout, newItem: Layout) => void; - onDropSection: (gridPosition: GridPosition) => void; - onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void; - onUpdateComponentPosition: (componentId: string, position: GridPosition) => void; - onDeleteSection: (id: string) => void; - onDeleteComponent: (sectionId: string, componentId: string) => void; + onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void; + onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => void; + onDeleteComponent: (componentId: string) => void; } -function CanvasDropZone({ +function DeviceFrame({ modeKey, isActive, - resolution, scale, - cols, - rowHeight, - margin, - sections, - components, - sectionPositions, - componentPositions, - gridLayoutItems, - selectedSectionId, + canvasGrid, + layout, selectedComponentId, - onSelectSection, + onModeKeyChange, onSelectComponent, - onDragResizeStop, - onDropSection, - onDropComponent, onUpdateComponentPosition, - onDeleteSection, + onDropComponent, onDeleteComponent, -}: CanvasDropZoneProps) { +}: DeviceFrameProps) { + const gridRef = useRef(null); const dropRef = useRef(null); + + const { components, layouts } = layout; + const resolution = MODE_RESOLUTIONS[modeKey]; + const modeLayout = layouts[modeKey]; + const componentPositions = modeLayout.componentPositions; + const componentIds = Object.keys(componentPositions); - // 스케일 적용된 크기 - const scaledWidth = resolution.width * scale; - const scaledHeight = resolution.height * scale; + const cols = canvasGrid.columns; + const rows = canvasGrid.rows || 24; + const gap = canvasGrid.gap; - // 섹션 드롭 핸들러 + // 드래그 상태 + const [dragState, setDragState] = useState<{ + componentId: string; + startPos: GridPosition; + currentPos: GridPosition; + isDragging: boolean; + } | null>(null); + + // 리사이즈 상태 + const [resizeState, setResizeState] = useState<{ + componentId: string; + startPos: GridPosition; + currentPos: GridPosition; + handle: "se" | "sw" | "ne" | "nw" | "e" | "w" | "n" | "s"; + isResizing: boolean; + } | null>(null); + + // 라벨 + const sizeLabel = `${resolution.width}x${resolution.height}`; + const modeLabel = `${MODE_LABELS[modeKey]} (${sizeLabel})`; + + // 마우스 → 그리드 좌표 변환 + const getGridPosition = useCallback((clientX: number, clientY: number): { col: number; row: number } => { + if (!gridRef.current) return { col: 1, row: 1 }; + const rect = gridRef.current.getBoundingClientRect(); + const x = (clientX - rect.left) / scale; + const y = (clientY - rect.top) / scale; + const cellWidth = (resolution.width - gap * (cols + 1)) / cols; + const cellHeight = (resolution.height - gap * (rows + 1)) / rows; + const col = Math.max(1, Math.min(cols, Math.floor((x - gap) / (cellWidth + gap)) + 1)); + const row = Math.max(1, Math.min(rows, Math.floor((y - gap) / (cellHeight + gap)) + 1)); + return { col, row }; + }, [scale, resolution, cols, rows, gap]); + + // 드래그 시작 + const handleDragStart = useCallback((e: React.MouseEvent, componentId: string) => { + if (!isActive) return; + e.preventDefault(); + e.stopPropagation(); + const pos = componentPositions[componentId]; + setDragState({ + componentId, + startPos: { ...pos }, + currentPos: { ...pos }, + isDragging: true, + }); + }, [isActive, componentPositions]); + + // 마우스 이동 + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (dragState?.isDragging && gridRef.current) { + const { col, row } = getGridPosition(e.clientX, e.clientY); + const newCol = Math.max(1, Math.min(cols - dragState.startPos.colSpan + 1, col)); + const newRow = Math.max(1, Math.min(rows - dragState.startPos.rowSpan + 1, row)); + setDragState(prev => prev ? { + ...prev, + currentPos: { ...prev.startPos, col: newCol, row: newRow } + } : null); + } + + if (resizeState?.isResizing && gridRef.current) { + const { col, row } = getGridPosition(e.clientX, e.clientY); + const startPos = resizeState.startPos; + let newPos = { ...startPos }; + + switch (resizeState.handle) { + case "se": + newPos.colSpan = Math.max(2, col - startPos.col + 1); + newPos.rowSpan = Math.max(2, row - startPos.row + 1); + break; + case "e": + newPos.colSpan = Math.max(2, col - startPos.col + 1); + break; + case "s": + newPos.rowSpan = Math.max(2, row - startPos.row + 1); + break; + case "sw": + const newColSW = Math.min(col, startPos.col + startPos.colSpan - 2); + newPos.col = newColSW; + newPos.colSpan = startPos.col + startPos.colSpan - newColSW; + newPos.rowSpan = Math.max(2, row - startPos.row + 1); + break; + case "w": + const newColW = Math.min(col, startPos.col + startPos.colSpan - 2); + newPos.col = newColW; + newPos.colSpan = startPos.col + startPos.colSpan - newColW; + break; + case "ne": + const newRowNE = Math.min(row, startPos.row + startPos.rowSpan - 2); + newPos.row = newRowNE; + newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNE; + newPos.colSpan = Math.max(2, col - startPos.col + 1); + break; + case "n": + const newRowN = Math.min(row, startPos.row + startPos.rowSpan - 2); + newPos.row = newRowN; + newPos.rowSpan = startPos.row + startPos.rowSpan - newRowN; + break; + case "nw": + const newColNW = Math.min(col, startPos.col + startPos.colSpan - 2); + const newRowNW = Math.min(row, startPos.row + startPos.rowSpan - 2); + newPos.col = newColNW; + newPos.row = newRowNW; + newPos.colSpan = startPos.col + startPos.colSpan - newColNW; + newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNW; + break; + } + + newPos.col = Math.max(1, newPos.col); + newPos.row = Math.max(1, newPos.row); + newPos.colSpan = Math.min(cols - newPos.col + 1, newPos.colSpan); + newPos.rowSpan = Math.min(rows - newPos.row + 1, newPos.rowSpan); + + setResizeState(prev => prev ? { ...prev, currentPos: newPos } : null); + } + }, [dragState, resizeState, getGridPosition, cols, rows]); + + // 드래그/리사이즈 종료 + const handleMouseUp = useCallback(() => { + if (dragState?.isDragging) { + onUpdateComponentPosition(dragState.componentId, dragState.currentPos, modeKey); + setDragState(null); + } + if (resizeState?.isResizing) { + onUpdateComponentPosition(resizeState.componentId, resizeState.currentPos, modeKey); + setResizeState(null); + } + }, [dragState, resizeState, onUpdateComponentPosition, modeKey]); + + // 리사이즈 시작 + const handleResizeStart = useCallback((e: React.MouseEvent, componentId: string, handle: string) => { + if (!isActive) return; + e.preventDefault(); + e.stopPropagation(); + const pos = componentPositions[componentId]; + setResizeState({ + componentId, + startPos: { ...pos }, + currentPos: { ...pos }, + handle: handle as any, + isResizing: true, + }); + }, [isActive, componentPositions]); + + // 컴포넌트 드롭 const [{ isOver, canDrop }, drop] = useDrop( () => ({ - accept: DND_ITEM_TYPES.SECTION, - drop: (item: DragItemSection, monitor) => { + accept: DND_ITEM_TYPES.COMPONENT, + drop: (item: DragItemComponent, monitor) => { if (!isActive) return; - const clientOffset = monitor.getClientOffset(); - if (!clientOffset || !dropRef.current) return; - - const dropRect = dropRef.current.getBoundingClientRect(); - // 스케일 보정 - const x = (clientOffset.x - dropRect.left) / scale; - const y = (clientOffset.y - dropRect.top) / scale; - - // 그리드 위치 계산 - const colWidth = (resolution.width - 16) / cols; - const col = Math.max(1, Math.min(cols, Math.floor(x / colWidth) + 1)); - const row = Math.max(1, Math.floor(y / (rowHeight * scale)) + 1); - - onDropSection({ - col, - row, - colSpan: 3, - rowSpan: 4, - }); + if (!clientOffset || !gridRef.current) return; + const { col, row } = getGridPosition(clientOffset.x, clientOffset.y); + onDropComponent(item.componentType, { col, row, colSpan: 4, rowSpan: 3 }); }, canDrop: () => isActive, collect: (monitor) => ({ @@ -456,133 +424,139 @@ function CanvasDropZone({ canDrop: monitor.canDrop(), }), }), - [isActive, resolution, scale, cols, rowHeight, onDropSection] + [isActive, getGridPosition, onDropComponent] ); drop(dropRef); - const sectionIds = Object.keys(sectionPositions); + // 현재 표시할 위치 + const getDisplayPosition = (componentId: string): GridPosition => { + if (dragState?.componentId === componentId && dragState.isDragging) { + return dragState.currentPos; + } + if (resizeState?.componentId === componentId && resizeState.isResizing) { + return resizeState.currentPos; + } + return componentPositions[componentId]; + }; return ( -
{ - if (e.target === e.currentTarget) { - onSelectSection(null); - onSelectComponent(null); - } - }} - > - {sectionIds.length > 0 ? ( - - {sectionIds.map((sectionId) => { - const sectionDef = sections[sectionId]; - if (!sectionDef) return null; +
+ {/* 모드 라벨 */} +
+ {modeLabel} +
- return ( -
{ - e.stopPropagation(); - onSelectSection(sectionId); - }} - > - {/* 섹션 헤더 */} -
-
- - - {sectionDef.label || "섹션"} - -
- {selectedSectionId === sectionId && isActive && ( - - )} -
- - {/* 섹션 내부 - 컴포넌트들 */} -
- -
-
- ); - })} - - ) : ( + {/* 디바이스 프레임 */} +
{ + if (e.target === e.currentTarget) { + if (!isActive) onModeKeyChange(modeKey); + else onSelectComponent(null); + } + }} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onMouseLeave={handleMouseUp} + > + {/* CSS Grid (뷰어와 동일) */}
- {isOver && canDrop - ? "여기에 섹션을 놓으세요" - : isActive - ? "왼쪽 패널에서 섹션을 드래그하세요" - : "클릭하여 편집 모드로 전환"} + {componentIds.length > 0 ? ( + componentIds.map((componentId) => { + const compDef = components[componentId]; + if (!compDef) return null; + + const pos = getDisplayPosition(componentId); + const isSelected = selectedComponentId === componentId; + const isDragging = dragState?.componentId === componentId && dragState.isDragging; + const isResizing = resizeState?.componentId === componentId && resizeState.isResizing; + + return ( +
{ + e.stopPropagation(); + if (!isActive) onModeKeyChange(modeKey); + onSelectComponent(componentId); + }} + onMouseDown={(e) => handleDragStart(e, componentId)} + > + {/* 컴포넌트 라벨 */} + + {compDef.label || COMPONENT_TYPE_LABELS[compDef.type]} + + + {/* 리사이즈 핸들 */} + {isActive && isSelected && ( + <> +
handleResizeStart(e, componentId, "se")} /> +
handleResizeStart(e, componentId, "sw")} /> +
handleResizeStart(e, componentId, "ne")} /> +
handleResizeStart(e, componentId, "nw")} /> +
handleResizeStart(e, componentId, "e")} /> +
handleResizeStart(e, componentId, "w")} /> +
handleResizeStart(e, componentId, "s")} /> +
handleResizeStart(e, componentId, "n")} /> + + )} +
+ ); + }) + ) : ( +
+ {isOver && canDrop + ? "여기에 컴포넌트를 놓으세요" + : isActive + ? "왼쪽 패널에서 컴포넌트를 드래그하세요" + : "클릭하여 편집"} +
+ )}
- )} +
); } diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index c0a7269e..682d6395 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useEffect, useMemo } from "react"; +import { useState, useCallback, useEffect } from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { ArrowLeft, Save, Smartphone, Tablet } from "lucide-react"; @@ -16,22 +16,17 @@ import { toast } from "sonner"; import { PopCanvas } from "./PopCanvas"; import { PopPanel } from "./panels/PopPanel"; import { - PopLayoutDataV2, + PopLayoutDataV3, PopLayoutModeKey, PopComponentType, GridPosition, - PopSectionDefinition, - createEmptyPopLayoutV2, - createSectionDefinition, - createComponentDefinition, - ensureV2Layout, - addSectionToV2Layout, - addComponentToV2Layout, - removeSectionFromV2Layout, - removeComponentFromV2Layout, - updateSectionPositionInMode, - updateComponentPositionInMode, - isV2Layout, + PopComponentDefinition, + createEmptyPopLayoutV3, + ensureV3Layout, + addComponentToV3Layout, + removeComponentFromV3Layout, + updateComponentPositionInModeV3, + isV3Layout, } from "./types/pop-layout"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; @@ -41,16 +36,6 @@ import { ScreenDefinition } from "@/types/screen"; // ======================================== type DeviceType = "mobile" | "tablet"; -/** - * 디바이스 + 방향 → 모드 키 변환 - */ -const getModeKey = (device: DeviceType, isLandscape: boolean): PopLayoutModeKey => { - if (device === "tablet") { - return isLandscape ? "tablet_landscape" : "tablet_portrait"; - } - return isLandscape ? "mobile_landscape" : "mobile_portrait"; -}; - // ======================================== // Props // ======================================== @@ -61,7 +46,7 @@ interface PopDesignerProps { } // ======================================== -// 메인 컴포넌트 +// 메인 컴포넌트 (v3: 섹션 없이 컴포넌트 직접 배치) // ======================================== export default function PopDesigner({ selectedScreen, @@ -69,9 +54,9 @@ export default function PopDesigner({ onScreenUpdate, }: PopDesignerProps) { // ======================================== - // 레이아웃 상태 (v2) + // 레이아웃 상태 (v3) // ======================================== - const [layout, setLayout] = useState(createEmptyPopLayoutV2()); + const [layout, setLayout] = useState(createEmptyPopLayoutV3()); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); @@ -80,31 +65,17 @@ export default function PopDesigner({ // 디바이스/모드 상태 // ======================================== const [activeDevice, setActiveDevice] = useState("tablet"); - - // 활성 모드 키 (가로/세로 중 현재 포커스된 캔버스) - // 기본값: 태블릿 가로 const [activeModeKey, setActiveModeKey] = useState("tablet_landscape"); // ======================================== - // 선택 상태 + // 선택 상태 (v3: 섹션 없음, 컴포넌트만) // ======================================== - const [selectedSectionId, setSelectedSectionId] = useState(null); const [selectedComponentId, setSelectedComponentId] = useState(null); - // ======================================== - // 파생 상태 - // ======================================== - - // 선택된 섹션 정의 - const selectedSection: PopSectionDefinition | null = useMemo(() => { - if (!selectedSectionId) return null; - return layout.sections[selectedSectionId] || null; - }, [layout.sections, selectedSectionId]); - - // 현재 활성 모드의 섹션 ID 목록 - const activeSectionIds = useMemo(() => { - return Object.keys(layout.layouts[activeModeKey].sectionPositions); - }, [layout.layouts, activeModeKey]); + // 선택된 컴포넌트 정의 + const selectedComponent: PopComponentDefinition | null = selectedComponentId + ? layout.components[selectedComponentId] || null + : null; // ======================================== // 레이아웃 로드 @@ -115,31 +86,27 @@ export default function PopDesigner({ setIsLoading(true); try { - // API가 layout_data 내용을 직접 반환 (언래핑 상태) const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); if (loadedLayout) { - // v1 또는 v2 → v2로 변환 - const v2Layout = ensureV2Layout(loadedLayout); - setLayout(v2Layout); + // v1, v2, v3 → v3로 변환 + const v3Layout = ensureV3Layout(loadedLayout); + setLayout(v3Layout); - const sectionCount = Object.keys(v2Layout.sections).length; - const componentCount = Object.keys(v2Layout.components).length; - console.log(`POP v2 레이아웃 로드 성공: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); + const componentCount = Object.keys(v3Layout.components).length; + console.log(`POP v3 레이아웃 로드 성공: ${componentCount}개 컴포넌트`); - // v1에서 마이그레이션된 경우 알림 - if (!isV2Layout(loadedLayout)) { - console.log("v1 → v2 자동 마이그레이션 완료"); + if (!isV3Layout(loadedLayout)) { + console.log("v1/v2 → v3 자동 마이그레이션 완료"); } } else { - // 레이아웃 없음 - 빈 v2 레이아웃 생성 - console.log("POP 레이아웃 없음, 빈 v2 레이아웃 생성"); - setLayout(createEmptyPopLayoutV2()); + console.log("POP 레이아웃 없음, 빈 v3 레이아웃 생성"); + setLayout(createEmptyPopLayoutV3()); } } catch (error) { console.error("레이아웃 로드 실패:", error); toast.error("레이아웃을 불러오는데 실패했습니다"); - setLayout(createEmptyPopLayoutV2()); + setLayout(createEmptyPopLayoutV3()); } finally { setIsLoading(false); } @@ -167,25 +134,13 @@ export default function PopDesigner({ } }, [selectedScreen?.screenId, layout]); - // ======================================== - // 섹션 추가 (4모드 동기화) - // ======================================== - const handleDropSection = useCallback((gridPosition: GridPosition) => { - const newId = `section-${Date.now()}`; - - setLayout((prev) => addSectionToV2Layout(prev, newId, gridPosition)); - setSelectedSectionId(newId); - setHasChanges(true); - }, []); - // ======================================== // 컴포넌트 추가 (4모드 동기화) // ======================================== const handleDropComponent = useCallback( - (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => { + (type: PopComponentType, gridPosition: GridPosition) => { const newId = `${type}-${Date.now()}`; - - setLayout((prev) => addComponentToV2Layout(prev, sectionId, newId, type, gridPosition)); + setLayout((prev) => addComponentToV3Layout(prev, newId, type, gridPosition)); setSelectedComponentId(newId); setHasChanges(true); }, @@ -193,16 +148,16 @@ export default function PopDesigner({ ); // ======================================== - // 섹션 정의 업데이트 (공유) + // 컴포넌트 정의 업데이트 // ======================================== - const handleUpdateSectionDefinition = useCallback( - (sectionId: string, updates: Partial) => { + const handleUpdateComponentDefinition = useCallback( + (componentId: string, updates: Partial) => { setLayout((prev) => ({ ...prev, - sections: { - ...prev.sections, - [sectionId]: { - ...prev.sections[sectionId], + components: { + ...prev.components, + [componentId]: { + ...prev.components[componentId], ...updates, }, }, @@ -212,63 +167,37 @@ export default function PopDesigner({ [] ); - // ======================================== - // 섹션 위치 업데이트 (현재 모드만) - // ======================================== - const handleUpdateSectionPosition = useCallback( - (sectionId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => { - const targetMode = modeKey || activeModeKey; - setLayout((prev) => updateSectionPositionInMode(prev, targetMode, sectionId, position)); - setHasChanges(true); - }, - [activeModeKey] - ); - // ======================================== // 컴포넌트 위치 업데이트 (현재 모드만) // ======================================== const handleUpdateComponentPosition = useCallback( (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => { const targetMode = modeKey || activeModeKey; - setLayout((prev) => updateComponentPositionInMode(prev, targetMode, componentId, position)); + setLayout((prev) => updateComponentPositionInModeV3(prev, targetMode, componentId, position)); setHasChanges(true); }, [activeModeKey] ); // ======================================== - // 섹션 삭제 (4모드 동기화) + // 컴포넌트 삭제 (4모드 동기화) // ======================================== - const handleDeleteSection = useCallback((sectionId: string) => { - setLayout((prev) => removeSectionFromV2Layout(prev, sectionId)); - setSelectedSectionId(null); + const handleDeleteComponent = useCallback((componentId: string) => { + setLayout((prev) => removeComponentFromV3Layout(prev, componentId)); setSelectedComponentId(null); setHasChanges(true); }, []); - // ======================================== - // 컴포넌트 삭제 (4모드 동기화) - // ======================================== - const handleDeleteComponent = useCallback( - (sectionId: string, componentId: string) => { - setLayout((prev) => removeComponentFromV2Layout(prev, sectionId, componentId)); - setSelectedComponentId(null); - setHasChanges(true); - }, - [] - ); - // ======================================== // 디바이스 전환 // ======================================== const handleDeviceChange = useCallback((device: DeviceType) => { setActiveDevice(device); - // 기본 모드 키 설정 (가로) setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape"); }, []); // ======================================== - // 모드 키 전환 (캔버스 포커스) + // 모드 키 전환 // ======================================== const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => { setActiveModeKey(modeKey); @@ -292,7 +221,6 @@ export default function PopDesigner({ // ======================================== useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // input/textarea 포커스 시 제외 const target = e.target as HTMLElement; if ( target.tagName === "INPUT" || @@ -302,44 +230,17 @@ export default function PopDesigner({ return; } - // Delete 또는 Backspace 키 if (e.key === "Delete" || e.key === "Backspace") { - e.preventDefault(); // 브라우저 뒤로가기 방지 - - // 컴포넌트가 선택되어 있으면 컴포넌트 삭제 + e.preventDefault(); if (selectedComponentId) { - // v2 구조: 컴포넌트가 속한 섹션을 sections의 componentIds에서 찾기 - // (PopComponentDefinition에는 sectionId가 없으므로 섹션을 순회하여 찾음) - let foundSectionId: string | null = null; - for (const [sectionId, sectionDef] of Object.entries(layout.sections)) { - if (sectionDef.componentIds.includes(selectedComponentId)) { - foundSectionId = sectionId; - break; - } - } - - if (foundSectionId) { - handleDeleteComponent(foundSectionId, selectedComponentId); - } - } - // 컴포넌트가 선택되지 않았고 섹션이 선택되어 있으면 섹션 삭제 - else if (selectedSectionId) { - handleDeleteSection(selectedSectionId); + handleDeleteComponent(selectedComponentId); } } }; window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [ - selectedComponentId, - selectedSectionId, - layout.sections, - handleDeleteComponent, - handleDeleteSection, - ]); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedComponentId, handleDeleteComponent]); // ======================================== // 로딩 상태 @@ -374,7 +275,7 @@ export default function PopDesigner({ )}
- {/* 중앙: 디바이스 전환 (가로/세로 전환 버튼 제거 - 캔버스 2개 동시 표시) */} + {/* 중앙: 디바이스 전환 */}
- {/* 메인 영역: 리사이즈 가능한 패널 */} + {/* 메인 영역 */} - {/* 왼쪽: 패널 (컴포넌트/편집 탭) */} + {/* 왼쪽: 패널 */} - {/* 오른쪽: 캔버스 (가로+세로 2개 동시 표시) */} + {/* 오른쪽: 캔버스 */} diff --git a/frontend/components/pop/designer/SectionGrid.tsx b/frontend/components/pop/designer/SectionGrid.tsx deleted file mode 100644 index 5e14fd2a..00000000 --- a/frontend/components/pop/designer/SectionGrid.tsx +++ /dev/null @@ -1,352 +0,0 @@ -"use client"; - -import { useCallback, useMemo, useRef, useState, useEffect } from "react"; -import { useDrop } from "react-dnd"; -import GridLayout, { Layout } from "react-grid-layout"; -import { cn } from "@/lib/utils"; -import { - PopSectionData, - PopComponentData, - PopComponentType, - GridPosition, -} from "./types/pop-layout"; -import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel"; -import { Trash2, Move } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -import "react-grid-layout/css/styles.css"; -import "react-resizable/css/styles.css"; - -interface SectionGridProps { - section: PopSectionData; - isActive: boolean; - selectedComponentId: string | null; - onSelectComponent: (id: string | null) => void; - onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void; - onUpdateComponent: (sectionId: string, componentId: string, updates: Partial) => void; - onDeleteComponent: (sectionId: string, componentId: string) => void; -} - -export function SectionGrid({ - section, - isActive, - selectedComponentId, - onSelectComponent, - onDropComponent, - onUpdateComponent, - onDeleteComponent, -}: SectionGridProps) { - const containerRef = useRef(null); - const { components } = section; - - // 컨테이너 크기 측정 - const [containerSize, setContainerSize] = useState({ width: 300, height: 200 }); - - useEffect(() => { - const updateSize = () => { - if (containerRef.current) { - setContainerSize({ - width: containerRef.current.offsetWidth, - height: containerRef.current.offsetHeight, - }); - } - }; - - updateSize(); - - const resizeObserver = new ResizeObserver(updateSize); - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } - - return () => resizeObserver.disconnect(); - }, []); - - // 셀 크기 계산 - 고정 셀 크기 기반으로 자동 계산 - const padding = 8; // p-2 = 8px - const gap = 4; // 고정 간격 - const availableWidth = containerSize.width - padding * 2; - const availableHeight = containerSize.height - padding * 2; - - // 고정 셀 크기 (40px) 기반으로 열/행 수 자동 계산 - const CELL_SIZE = 40; - const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap))); - const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap))); - const cellHeight = CELL_SIZE; - - // GridLayout용 레이아웃 변환 (자동 계산된 cols/rows 사용) - const gridLayoutItems: Layout[] = useMemo(() => { - return components.map((comp) => { - // 컴포넌트 위치가 그리드 범위를 벗어나지 않도록 조정 - const x = Math.min(Math.max(0, comp.grid.col - 1), Math.max(0, cols - 1)); - const y = Math.min(Math.max(0, comp.grid.row - 1), Math.max(0, rows - 1)); - // colSpan/rowSpan도 범위 제한 - const w = Math.min(Math.max(1, comp.grid.colSpan), Math.max(1, cols - x)); - const h = Math.min(Math.max(1, comp.grid.rowSpan), Math.max(1, rows - y)); - - return { - i: comp.id, - x, - y, - w, - h, - minW: 1, - minH: 1, - }; - }); - }, [components, cols, rows]); - - // 드래그/리사이즈 완료 핸들러 (onDragStop, onResizeStop 사용) - const handleDragStop = useCallback( - (layout: Layout[], oldItem: Layout, newItem: Layout) => { - const comp = components.find((c) => c.id === newItem.i); - if (!comp) return; - - const newGrid: GridPosition = { - col: newItem.x + 1, - row: newItem.y + 1, - colSpan: newItem.w, - rowSpan: newItem.h, - }; - - if ( - comp.grid.col !== newGrid.col || - comp.grid.row !== newGrid.row - ) { - onUpdateComponent(section.id, comp.id, { grid: newGrid }); - } - }, - [components, section.id, onUpdateComponent] - ); - - const handleResizeStop = useCallback( - (layout: Layout[], oldItem: Layout, newItem: Layout) => { - const comp = components.find((c) => c.id === newItem.i); - if (!comp) return; - - const newGrid: GridPosition = { - col: newItem.x + 1, - row: newItem.y + 1, - colSpan: newItem.w, - rowSpan: newItem.h, - }; - - if ( - comp.grid.colSpan !== newGrid.colSpan || - comp.grid.rowSpan !== newGrid.rowSpan - ) { - onUpdateComponent(section.id, comp.id, { grid: newGrid }); - } - }, - [components, section.id, onUpdateComponent] - ); - - // 빈 셀 찾기 (드롭 위치용) - 자동 계산된 cols/rows 사용 - const findEmptyCell = useCallback((): GridPosition => { - const occupied = new Set(); - - components.forEach((comp) => { - for (let c = comp.grid.col; c < comp.grid.col + comp.grid.colSpan; c++) { - for (let r = comp.grid.row; r < comp.grid.row + comp.grid.rowSpan; r++) { - occupied.add(`${c}-${r}`); - } - } - }); - - // 빈 셀 찾기 - for (let r = 1; r <= rows; r++) { - for (let c = 1; c <= cols; c++) { - if (!occupied.has(`${c}-${r}`)) { - return { col: c, row: r, colSpan: 1, rowSpan: 1 }; - } - } - } - - // 빈 셀 없으면 첫 번째 위치에 - return { col: 1, row: 1, colSpan: 1, rowSpan: 1 }; - }, [components, cols, rows]); - - // 컴포넌트 드롭 핸들러 - const [{ isOver, canDrop }, drop] = useDrop(() => ({ - accept: DND_ITEM_TYPES.COMPONENT, - drop: (item: DragItemComponent) => { - if (!isActive) return; - const emptyCell = findEmptyCell(); - onDropComponent(section.id, item.componentType, emptyCell); - return { dropped: true }; - }, - canDrop: () => isActive, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), - }), - }), [isActive, section.id, findEmptyCell, onDropComponent]); - - - return ( -
{ - containerRef.current = node; - drop(node); - }} - className={cn( - "relative h-full w-full p-2 transition-colors", - isOver && canDrop && "bg-blue-50" - )} - onClick={(e) => { - e.stopPropagation(); - onSelectComponent(null); - }} - onMouseDown={(e) => { - e.stopPropagation(); - }} - > - - {/* 빈 상태 안내 텍스트 */} - {components.length === 0 && ( -
- - {isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"} - -
- )} - - {/* 컴포넌트 GridLayout */} - {components.length > 0 && availableWidth > 0 && cols > 0 && ( - - {components.map((comp) => ( -
{ - e.stopPropagation(); - onSelectComponent(comp.id); - }} - onMouseDown={(e) => e.stopPropagation()} - > - {/* 드래그 핸들 바 */} -
- -
- - {/* 컴포넌트 내용 */} -
- -
- - {/* 삭제 버튼 */} - {selectedComponentId === comp.id && ( - - )} -
- ))} -
- )} -
- ); -} - -// 컴포넌트 미리보기 -interface ComponentPreviewProps { - component: PopComponentData; -} - -function ComponentPreview({ component }: ComponentPreviewProps) { - const { type, label } = component; - - // 타입별 미리보기 렌더링 - const renderPreview = () => { - switch (type) { - case "pop-field": - return ( -
- {label || "필드"} -
-
- ); - case "pop-button": - return ( -
- {label || "버튼"} -
- ); - case "pop-list": - return ( -
- {label || "리스트"} -
-
-
-
- ); - case "pop-indicator": - return ( -
- {label || "KPI"} - 0 -
- ); - case "pop-scanner": - return ( -
-
- QR -
- {label || "스캐너"} -
- ); - case "pop-numpad": - return ( -
- {[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => ( -
- {key} -
- ))} -
- ); - default: - return {label || type}; - } - }; - - return
{renderPreview()}
; -} diff --git a/frontend/components/pop/designer/SectionGridV2.tsx b/frontend/components/pop/designer/SectionGridV2.tsx deleted file mode 100644 index 5e97e6f7..00000000 --- a/frontend/components/pop/designer/SectionGridV2.tsx +++ /dev/null @@ -1,373 +0,0 @@ -"use client"; - -import { useCallback, useMemo, useRef, useState, useEffect } from "react"; -import { useDrop } from "react-dnd"; -import GridLayout, { Layout } from "react-grid-layout"; -import { cn } from "@/lib/utils"; -import { - PopSectionDefinition, - PopComponentDefinition, - PopComponentType, - GridPosition, -} from "./types/pop-layout"; -import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel"; -import { Trash2, Move } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -import "react-grid-layout/css/styles.css"; -import "react-resizable/css/styles.css"; - -// ======================================== -// Props -// ======================================== -interface SectionGridV2Props { - sectionId: string; - sectionDef: PopSectionDefinition; - components: Record; - componentPositions: Record; - isActive: boolean; - selectedComponentId: string | null; - onSelectComponent: (id: string | null) => void; - onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void; - onUpdateComponentPosition: (componentId: string, position: GridPosition) => void; - onDeleteComponent: (sectionId: string, componentId: string) => void; -} - -// ======================================== -// 메인 컴포넌트 -// ======================================== -export function SectionGridV2({ - sectionId, - sectionDef, - components, - componentPositions, - isActive, - selectedComponentId, - onSelectComponent, - onDropComponent, - onUpdateComponentPosition, - onDeleteComponent, -}: SectionGridV2Props) { - const containerRef = useRef(null); - - // 이 섹션에 포함된 컴포넌트 ID 목록 - const componentIds = sectionDef.componentIds || []; - - // 컨테이너 크기 측정 - const [containerSize, setContainerSize] = useState({ width: 300, height: 200 }); - - useEffect(() => { - const updateSize = () => { - if (containerRef.current) { - setContainerSize({ - width: containerRef.current.offsetWidth, - height: containerRef.current.offsetHeight, - }); - } - }; - - updateSize(); - - const resizeObserver = new ResizeObserver(updateSize); - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } - - return () => resizeObserver.disconnect(); - }, []); - - // 셀 크기 계산 - 고정 셀 크기 기반으로 자동 계산 - const padding = 8; // p-2 = 8px - const gap = 4; // 고정 간격 - const availableWidth = containerSize.width - padding * 2; - const availableHeight = containerSize.height - padding * 2; - - // 고정 셀 크기 (40px) 기반으로 열/행 수 자동 계산 - const CELL_SIZE = 40; - const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap))); - const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap))); - const cellHeight = CELL_SIZE; - - // GridLayout용 레이아웃 변환 - const gridLayoutItems: Layout[] = useMemo(() => { - return componentIds - .map((compId) => { - const pos = componentPositions[compId]; - if (!pos) return null; - - // 위치가 그리드 범위를 벗어나지 않도록 조정 - const x = Math.min(Math.max(0, pos.col - 1), Math.max(0, cols - 1)); - const y = Math.min(Math.max(0, pos.row - 1), Math.max(0, rows - 1)); - const w = Math.min(Math.max(1, pos.colSpan), Math.max(1, cols - x)); - const h = Math.min(Math.max(1, pos.rowSpan), Math.max(1, rows - y)); - - return { - i: compId, - x, - y, - w, - h, - minW: 1, - minH: 1, - }; - }) - .filter((item): item is Layout => item !== null); - }, [componentIds, componentPositions, cols, rows]); - - // 드래그 완료 핸들러 - const handleDragStop = useCallback( - (layout: Layout[], oldItem: Layout, newItem: Layout) => { - const newPos: GridPosition = { - col: newItem.x + 1, - row: newItem.y + 1, - colSpan: newItem.w, - rowSpan: newItem.h, - }; - - const oldPos = componentPositions[newItem.i]; - if (!oldPos || oldPos.col !== newPos.col || oldPos.row !== newPos.row) { - onUpdateComponentPosition(newItem.i, newPos); - } - }, - [componentPositions, onUpdateComponentPosition] - ); - - // 리사이즈 완료 핸들러 - const handleResizeStop = useCallback( - (layout: Layout[], oldItem: Layout, newItem: Layout) => { - const newPos: GridPosition = { - col: newItem.x + 1, - row: newItem.y + 1, - colSpan: newItem.w, - rowSpan: newItem.h, - }; - - const oldPos = componentPositions[newItem.i]; - if (!oldPos || oldPos.colSpan !== newPos.colSpan || oldPos.rowSpan !== newPos.rowSpan) { - onUpdateComponentPosition(newItem.i, newPos); - } - }, - [componentPositions, onUpdateComponentPosition] - ); - - // 빈 셀 찾기 (드롭 위치용) - const findEmptyCell = useCallback((): GridPosition => { - const occupied = new Set(); - - componentIds.forEach((compId) => { - const pos = componentPositions[compId]; - if (!pos) return; - - for (let c = pos.col; c < pos.col + pos.colSpan; c++) { - for (let r = pos.row; r < pos.row + pos.rowSpan; r++) { - occupied.add(`${c}-${r}`); - } - } - }); - - // 빈 셀 찾기 - for (let r = 1; r <= rows; r++) { - for (let c = 1; c <= cols; c++) { - if (!occupied.has(`${c}-${r}`)) { - return { col: c, row: r, colSpan: 1, rowSpan: 1 }; - } - } - } - - // 빈 셀 없으면 첫 번째 위치 - return { col: 1, row: 1, colSpan: 1, rowSpan: 1 }; - }, [componentIds, componentPositions, cols, rows]); - - // 컴포넌트 드롭 핸들러 - const [{ isOver, canDrop }, drop] = useDrop( - () => ({ - accept: DND_ITEM_TYPES.COMPONENT, - drop: (item: DragItemComponent) => { - if (!isActive) return; - const emptyCell = findEmptyCell(); - onDropComponent(sectionId, item.componentType, emptyCell); - return { dropped: true }; - }, - canDrop: () => isActive, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), - }), - }), - [isActive, sectionId, findEmptyCell, onDropComponent] - ); - - return ( -
{ - containerRef.current = node; - drop(node); - }} - className={cn( - "relative h-full w-full p-2 transition-colors", - isOver && canDrop && "bg-blue-50" - )} - onClick={(e) => { - e.stopPropagation(); - onSelectComponent(null); - }} - onMouseDown={(e) => { - e.stopPropagation(); - }} - > - {/* 빈 상태 안내 텍스트 */} - {componentIds.length === 0 && ( -
- - {isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"} - -
- )} - - {/* 컴포넌트 GridLayout */} - {componentIds.length > 0 && availableWidth > 0 && cols > 0 && ( - - {componentIds.map((compId) => { - const compDef = components[compId]; - if (!compDef) return null; - - return ( -
{ - e.stopPropagation(); - onSelectComponent(compId); - }} - onMouseDown={(e) => e.stopPropagation()} - > - {/* 드래그 핸들 바 */} -
- -
- - {/* 컴포넌트 내용 */} -
- -
- - {/* 삭제 버튼 */} - {selectedComponentId === compId && isActive && ( - - )} -
- ); - })} -
- )} -
- ); -} - -// ======================================== -// 컴포넌트 미리보기 -// ======================================== -interface ComponentPreviewV2Props { - component: PopComponentDefinition; -} - -function ComponentPreviewV2({ component }: ComponentPreviewV2Props) { - const { type, label } = component; - - const renderPreview = () => { - switch (type) { - case "pop-field": - return ( -
- {label || "필드"} -
-
- ); - case "pop-button": - return ( -
- {label || "버튼"} -
- ); - case "pop-list": - return ( -
- {label || "리스트"} -
-
-
-
- ); - case "pop-indicator": - return ( -
- {label || "KPI"} - 0 -
- ); - case "pop-scanner": - return ( -
-
- QR -
- {label || "스캐너"} -
- ); - case "pop-numpad": - return ( -
- {[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => ( -
- {key} -
- ))} -
- ); - default: - return {label || type}; - } - }; - - return
{renderPreview()}
; -} diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx new file mode 100644 index 00000000..967452e7 --- /dev/null +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -0,0 +1,215 @@ +"use client"; + +import React from "react"; +import { cn } from "@/lib/utils"; +import { PopComponentDefinition, PopComponentConfig } from "../types/pop-layout"; +import { Settings, Database, Link2 } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +// ======================================== +// Props 정의 +// ======================================== + +interface ComponentEditorPanelProps { + /** 선택된 컴포넌트 (없으면 null) */ + component: PopComponentDefinition | null; + /** 컴포넌트 설정 변경 시 호출 */ + onConfigChange?: (config: Partial) => void; + /** 컴포넌트 라벨 변경 시 호출 */ + onLabelChange?: (label: string) => void; + /** 추가 className */ + className?: string; +} + +// ======================================== +// 컴포넌트 편집 패널 +// +// 역할: +// - 선택된 컴포넌트의 설정을 편집 +// - 3개 탭: 기본 설정 / 데이터 바인딩 / 데이터 연결 +// +// TODO: +// - 타입별 상세 설정 UI 구현 +// - 데이터 바인딩 UI 구현 +// - 데이터 플로우 UI 구현 +// ======================================== + +export function ComponentEditorPanel({ + component, + onConfigChange, + onLabelChange, + className, +}: ComponentEditorPanelProps) { + // 컴포넌트가 선택되지 않은 경우 + if (!component) { + return ( +
+
+

컴포넌트 편집

+
+
+ 컴포넌트를 선택하세요 +
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+

+ {component.label || getComponentTypeLabel(component.type)} +

+

{component.type}

+
+ + {/* 탭 컨텐츠 */} + + + + + 설정 + + + + 데이터 + + + + 연결 + + + + {/* 기본 설정 탭 */} + + + + + {/* 데이터 바인딩 탭 (뼈대) */} + + + + + {/* 데이터 연결 탭 (뼈대) */} + + + + +
+ ); +} + +// ======================================== +// 컴포넌트 설정 폼 +// ======================================== + +interface ComponentSettingsFormProps { + component: PopComponentDefinition; + onConfigChange?: (config: Partial) => void; + onLabelChange?: (label: string) => void; +} + +function ComponentSettingsForm({ + component, + onConfigChange, + onLabelChange, +}: ComponentSettingsFormProps) { + return ( +
+ {/* 라벨 입력 */} +
+ + onLabelChange?.(e.target.value)} + placeholder="컴포넌트 라벨" + /> +
+ + {/* 타입별 설정 (TODO: 상세 구현) */} +
+

+ {getComponentTypeLabel(component.type)} 상세 설정 +

+

+ (추후 구현 예정) +

+
+
+ ); +} + +// ======================================== +// 데이터 바인딩 플레이스홀더 (뼈대) +// ======================================== + +function DataBindingPlaceholder() { + return ( +
+
+
+ +

+ 데이터 바인딩 설정 +

+

+ 테이블 선택 → 칼럼 선택 → 조인 설정 +

+

+ (추후 구현 예정) +

+
+
+
+ ); +} + +// ======================================== +// 데이터 플로우 플레이스홀더 (뼈대) +// ======================================== + +function DataFlowPlaceholder() { + return ( +
+
+
+ +

+ 데이터 연결 설정 +

+

+ 컴포넌트 간 / 섹션 간 / 화면 간 연결 +

+

+ (추후 구현 예정) +

+
+
+
+ ); +} + +// ======================================== +// 헬퍼 함수 +// ======================================== + +function getComponentTypeLabel(type: string): string { + const labels: Record = { + "pop-field": "필드", + "pop-button": "버튼", + "pop-list": "리스트", + "pop-indicator": "인디케이터", + "pop-scanner": "스캐너", + "pop-numpad": "넘패드", + }; + return labels[type] || type; +} + +export default ComponentEditorPanel; diff --git a/frontend/components/pop/designer/panels/PopPanel.tsx b/frontend/components/pop/designer/panels/PopPanel.tsx index ba9613eb..f51913d5 100644 --- a/frontend/components/pop/designer/panels/PopPanel.tsx +++ b/frontend/components/pop/designer/panels/PopPanel.tsx @@ -6,13 +6,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Collapsible, CollapsibleContent, @@ -21,7 +14,6 @@ import { import { Plus, Settings, - LayoutGrid, Type, MousePointer, List, @@ -34,9 +26,9 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { - PopLayoutDataV2, + PopLayoutDataV3, PopLayoutModeKey, - PopSectionDefinition, + PopComponentDefinition, PopComponentType, MODE_RESOLUTIONS, } from "../types/pop-layout"; @@ -45,14 +37,9 @@ import { // 드래그 아이템 타입 // ======================================== export const DND_ITEM_TYPES = { - SECTION: "section", COMPONENT: "component", } as const; -export interface DragItemSection { - type: typeof DND_ITEM_TYPES.SECTION; -} - export interface DragItemComponent { type: typeof DND_ITEM_TYPES.COMPONENT; componentType: PopComponentType; @@ -83,7 +70,7 @@ const COMPONENT_PALETTE: { type: "pop-list", label: "리스트", icon: List, - description: "데이터 목록 표시", + description: "데이터 목록 (카드 템플릿 지원)", }, { type: "pop-indicator", @@ -106,15 +93,15 @@ const COMPONENT_PALETTE: { ]; // ======================================== -// Props +// Props (v3: 섹션 없음) // ======================================== interface PopPanelProps { - layout: PopLayoutDataV2; + layout: PopLayoutDataV3; activeModeKey: PopLayoutModeKey; - selectedSectionId: string | null; - selectedSection: PopSectionDefinition | null; - onUpdateSectionDefinition: (id: string, updates: Partial) => void; - onDeleteSection: (id: string) => void; + selectedComponentId: string | null; + selectedComponent: PopComponentDefinition | null; + onUpdateComponentDefinition: (id: string, updates: Partial) => void; + onDeleteComponent: (id: string) => void; activeDevice: "mobile" | "tablet"; } @@ -124,18 +111,18 @@ interface PopPanelProps { export function PopPanel({ layout, activeModeKey, - selectedSectionId, - selectedSection, - onUpdateSectionDefinition, - onDeleteSection, + selectedComponentId, + selectedComponent, + onUpdateComponentDefinition, + onDeleteComponent, activeDevice, }: PopPanelProps) { const [activeTab, setActiveTab] = useState("components"); - // 현재 모드의 섹션 위치 + // 현재 모드의 컴포넌트 위치 const currentModeLayout = layout.layouts[activeModeKey]; - const selectedSectionPosition = selectedSectionId - ? currentModeLayout.sectionPositions[selectedSectionId] + const selectedComponentPosition = selectedComponentId + ? currentModeLayout.componentPositions[selectedComponentId] : null; return ( @@ -169,17 +156,6 @@ export function PopPanel({

- {/* 섹션 드래그 아이템 */} -
-

- 레이아웃 -

- -

- 캔버스에 드래그하여 섹션 추가 -

-
- {/* 컴포넌트 팔레트 */}

@@ -197,7 +173,7 @@ export function PopPanel({ ))}

- 섹션 안으로 드래그하여 배치 + 캔버스로 드래그하여 배치

@@ -205,19 +181,19 @@ export function PopPanel({ {/* 편집 탭 */} - {selectedSection && selectedSectionPosition ? ( - - onUpdateSectionDefinition(selectedSection.id, updates) + onUpdateComponentDefinition(selectedComponent.id, updates) } - onDelete={() => onDeleteSection(selectedSection.id)} + onDelete={() => onDeleteComponent(selectedComponent.id)} /> ) : (
- 섹션을 선택하세요 + 컴포넌트를 선택하세요
)}
@@ -239,37 +215,6 @@ function getModeLabel(modeKey: PopLayoutModeKey): string { return labels[modeKey]; } -// ======================================== -// 드래그 가능한 섹션 아이템 -// ======================================== -function DraggableSectionItem() { - const [{ isDragging }, drag] = useDrag(() => ({ - type: DND_ITEM_TYPES.SECTION, - item: { type: DND_ITEM_TYPES.SECTION } as DragItemSection, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - })); - - return ( -
- - -
-

섹션

-

컴포넌트를 그룹화하는 컨테이너

-
-
- ); -} - // ======================================== // 드래그 가능한 컴포넌트 아이템 // ======================================== @@ -314,30 +259,43 @@ function DraggableComponentItem({ } // ======================================== -// v2 섹션 편집기 +// v3 컴포넌트 편집기 // ======================================== -interface SectionEditorV2Props { - section: PopSectionDefinition; +interface ComponentEditorV3Props { + component: PopComponentDefinition; position: { col: number; row: number; colSpan: number; rowSpan: number }; activeModeKey: PopLayoutModeKey; - onUpdateDefinition: (updates: Partial) => void; + onUpdateDefinition: (updates: Partial) => void; onDelete: () => void; } -function SectionEditorV2({ - section, +function ComponentEditorV3({ + component, position, activeModeKey, onUpdateDefinition, onDelete, -}: SectionEditorV2Props) { - const [isGridOpen, setIsGridOpen] = useState(true); +}: ComponentEditorV3Props) { + const [isPositionOpen, setIsPositionOpen] = useState(true); + + // 컴포넌트 타입 라벨 + const typeLabels: Record = { + "pop-field": "필드", + "pop-button": "버튼", + "pop-list": "리스트", + "pop-indicator": "인디케이터", + "pop-scanner": "스캐너", + "pop-numpad": "숫자패드", + }; return (
- {/* 섹션 기본 정보 */} + {/* 컴포넌트 기본 정보 */}
- 섹션 설정 +
+ {typeLabels[component.type]} +

{component.id}

+
- {/* 현재 모드 위치 (읽기 전용 - 드래그로 조정) */} - + {/* 현재 모드 위치 (읽기 전용) */} + 현재 모드 위치 @@ -402,83 +357,12 @@ function SectionEditorV2({ - {/* 내부 그리드 설정 */} -
-

내부 그리드 (공유)

-
-
- - -
-
- - -
-
-

- 내부 그리드 설정은 4개 모드에서 공유됩니다 + {/* TODO: 컴포넌트별 설정 (config) */} +

+

+ 컴포넌트별 상세 설정은 추후 추가 예정

- - {/* 컴포넌트 목록 */} -
-

- 포함된 컴포넌트 ({section.componentIds.length}개) -

- {section.componentIds.length > 0 ? ( -
- {section.componentIds.map((compId) => ( -
- {compId} -
- ))} -
- ) : ( -

- 아직 컴포넌트가 없습니다 -

- )} -
); } diff --git a/frontend/components/pop/designer/panels/index.ts b/frontend/components/pop/designer/panels/index.ts index eaeb0e27..5fc9ebf1 100644 --- a/frontend/components/pop/designer/panels/index.ts +++ b/frontend/components/pop/designer/panels/index.ts @@ -1,2 +1,3 @@ // POP 디자이너 패널 export export { PopPanel } from "./PopPanel"; +export { ComponentEditorPanel } from "./ComponentEditorPanel"; diff --git a/frontend/components/pop/designer/renderers/ComponentRenderer.tsx b/frontend/components/pop/designer/renderers/ComponentRenderer.tsx new file mode 100644 index 00000000..670e48f9 --- /dev/null +++ b/frontend/components/pop/designer/renderers/ComponentRenderer.tsx @@ -0,0 +1,237 @@ +"use client"; + +import React, { forwardRef } from "react"; +import { cn } from "@/lib/utils"; +import { + PopComponentDefinition, + PopComponentType, + GridPosition, +} from "../types/pop-layout"; + +// ======================================== +// Props 정의 +// ======================================== + +interface ComponentRendererProps { + /** 컴포넌트 정의 (타입, 라벨, 설정 등) */ + component: PopComponentDefinition; + /** 컴포넌트의 그리드 위치 (섹션 내부 그리드 기준) */ + position: GridPosition; + /** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */ + isDesignMode?: boolean; + /** 선택된 상태인지 */ + isSelected?: boolean; + /** 컴포넌트 클릭 시 호출 */ + onClick?: () => void; + /** 추가 className */ + className?: string; +} + +// ======================================== +// 컴포넌트 렌더러 +// +// 역할: +// - 관리자가 설정한 GridPosition(col, row, colSpan, rowSpan)을 +// 그대로 CSS Grid에 반영 +// - 디자이너/뷰어 모두에서 동일한 렌더링 보장 +// - 디자인 모드에서는 선택 상태 표시 +// ======================================== + +export const ComponentRenderer = forwardRef( + function ComponentRenderer( + { + component, + position, + isDesignMode = false, + isSelected = false, + onClick, + className, + }, + ref + ) { + const { type, label, config } = component; + + return ( +
{ + e.stopPropagation(); + onClick?.(); + }} + > + {/* 컴포넌트 타입별 미리보기 렌더링 */} + +
+ ); + } +); + +// ======================================== +// 컴포넌트 타입별 미리보기 +// ======================================== + +interface ComponentPreviewProps { + type: PopComponentType; + label?: string; + config?: any; +} + +function ComponentPreview({ type, label, config }: ComponentPreviewProps) { + switch (type) { + case "pop-field": + return ; + case "pop-button": + return ; + case "pop-list": + return ; + case "pop-indicator": + return ; + case "pop-scanner": + return ; + case "pop-numpad": + return ; + default: + return ( +
+ {label || type} +
+ ); + } +} + +// ======================================== +// 개별 컴포넌트 미리보기 +// ======================================== + +function FieldPreview({ label, config }: { label?: string; config?: any }) { + const fieldType = config?.fieldType || "text"; + const placeholder = config?.placeholder || "입력하세요"; + const required = config?.required || false; + + return ( +
+ {/* 라벨 */} + + {label || "필드"} + {required && *} + + {/* 입력 필드 미리보기 */} +
+ {placeholder} +
+
+ ); +} + +function ButtonPreview({ label, config }: { label?: string; config?: any }) { + const buttonType = config?.buttonType || "action"; + const variant = buttonType === "submit" ? "bg-primary text-white" : "bg-gray-100 text-gray-700"; + + return ( +
+
+ {label || "버튼"} +
+
+ ); +} + +function ListPreview({ label, config }: { label?: string; config?: any }) { + const itemCount = config?.itemsPerPage || 5; + + return ( +
+ {/* 라벨 */} + {label || "리스트"} + {/* 리스트 아이템 미리보기 */} +
+ {Array.from({ length: Math.min(3, itemCount) }).map((_, i) => ( +
+ ))} + {itemCount > 3 && ( +
+ +{itemCount - 3} more +
+ )} +
+
+ ); +} + +function IndicatorPreview({ label, config }: { label?: string; config?: any }) { + const indicatorType = config?.indicatorType || "kpi"; + const unit = config?.unit || ""; + + return ( +
+ {/* 라벨 */} + {label || "KPI"} + {/* 값 미리보기 */} + + 0{unit && {unit}} + +
+ ); +} + +function ScannerPreview({ label, config }: { label?: string; config?: any }) { + const scannerType = config?.scannerType || "camera"; + + return ( +
+ {/* QR 아이콘 */} +
+ QR +
+ {/* 라벨 */} + {label || "스캐너"} +
+ ); +} + +function NumpadPreview({ label, config }: { label?: string; config?: any }) { + const keys = [1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"]; + + return ( +
+ {/* 라벨 */} + {label && ( + {label} + )} + {/* 넘패드 미리보기 */} +
+ {keys.map((key) => ( +
+ {key} +
+ ))} +
+
+ ); +} + +export default ComponentRenderer; diff --git a/frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx b/frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx new file mode 100644 index 00000000..ef1572fb --- /dev/null +++ b/frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx @@ -0,0 +1,401 @@ +"use client"; + +import React from "react"; +import { cn } from "@/lib/utils"; +import { + PopLayoutDataV3, + PopLayoutModeKey, + PopModeLayoutV3, + GridPosition, + MODE_RESOLUTIONS, + PopComponentDefinition, +} from "../types/pop-layout"; + +// ======================================== +// Props 정의 +// ======================================== + +interface PopLayoutRendererProps { + /** 레이아웃 데이터 (v3.0) */ + layout: PopLayoutDataV3; + /** 현재 모드 키 (tablet_landscape, tablet_portrait 등) */ + modeKey: PopLayoutModeKey; + /** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */ + isDesignMode?: boolean; + /** 선택된 컴포넌트 ID */ + selectedComponentId?: string | null; + /** 컴포넌트 클릭 시 호출 */ + onComponentClick?: (componentId: string) => void; + /** 배경 클릭 시 호출 (선택 해제용) */ + onBackgroundClick?: () => void; + /** 커스텀 모드 레이아웃 (fallback 등에서 변환된 레이아웃 사용 시) */ + customModeLayout?: PopModeLayoutV3; + /** 추가 className */ + className?: string; + /** 추가 style */ + style?: React.CSSProperties; +} + +// ======================================== +// 컴포넌트 타입별 라벨 +// ======================================== +const COMPONENT_TYPE_LABELS: Record = { + "pop-field": "필드", + "pop-button": "버튼", + "pop-list": "리스트", + "pop-indicator": "인디케이터", + "pop-scanner": "스캐너", + "pop-numpad": "숫자패드", +}; + +// ======================================== +// POP 레이아웃 렌더러 (v3) +// +// 핵심 역할: +// - 디자이너와 뷰어에서 **동일한** 렌더링 결과 보장 +// - 컴포넌트가 캔버스에 직접 배치 (섹션 없음) +// - CSS Grid + 1fr 비율 기반 +// ======================================== + +export function PopLayoutRenderer({ + layout, + modeKey, + isDesignMode = false, + selectedComponentId, + onComponentClick, + onBackgroundClick, + customModeLayout, + className, + style, +}: PopLayoutRendererProps) { + const { components, layouts, settings } = layout; + const canvasGrid = settings.canvasGrid; + + // 현재 모드의 레이아웃 + const modeLayout = customModeLayout || layouts[modeKey]; + + // 컴포넌트가 없으면 빈 상태 표시 + if (!modeLayout || Object.keys(modeLayout.componentPositions).length === 0) { + return ( +
+
+

레이아웃이 설정되지 않았습니다

+ {isDesignMode &&

컴포넌트를 추가해주세요

} +
+
+ ); + } + + // 컴포넌트 ID 목록 + const componentIds = Object.keys(modeLayout.componentPositions); + + return ( +
{ + if (e.target === e.currentTarget) { + onBackgroundClick?.(); + } + }} + > + {/* 컴포넌트들 직접 렌더링 */} + {componentIds.map((componentId) => { + const compDef = components[componentId]; + const compPos = modeLayout.componentPositions[componentId]; + + if (!compDef || !compPos) return null; + + return ( + onComponentClick?.(componentId)} + /> + ); + })} +
+ ); +} + +// ======================================== +// 컴포넌트 렌더러 +// ======================================== + +interface ComponentRendererProps { + componentId: string; + component: PopComponentDefinition; + position: GridPosition; + isDesignMode?: boolean; + isSelected?: boolean; + onComponentClick?: () => void; +} + +function ComponentRenderer({ + componentId, + component, + position, + isDesignMode = false, + isSelected = false, + onComponentClick, +}: ComponentRendererProps) { + const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; + + return ( +
{ + e.stopPropagation(); + onComponentClick?.(); + }} + > + {/* 컴포넌트 라벨 (디자인 모드에서만) */} + {isDesignMode && ( +
+ + {component.label || typeLabel} + +
+ )} + + {/* 컴포넌트 내용 */} +
+ {renderComponentContent(component, isDesignMode)} +
+
+ ); +} + +// ======================================== +// 컴포넌트별 렌더링 +// ======================================== + +function renderComponentContent( + component: PopComponentDefinition, + isDesignMode: boolean +): React.ReactNode { + const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; + + // 디자인 모드에서는 플레이스홀더 표시 + if (isDesignMode) { + return ( +
+ {typeLabel} +
+ ); + } + + // 뷰어 모드: 실제 컴포넌트 렌더링 + switch (component.type) { + case "pop-field": + return ( + + ); + + case "pop-button": + return ( + + ); + + case "pop-list": + return ( +
+
+ 리스트 (데이터 연결 필요) +
+
+ ); + + case "pop-indicator": + return ( +
+
0
+
{component.label || "지표"}
+
+ ); + + case "pop-scanner": + return ( +
+
스캐너
+
탭하여 스캔
+
+ ); + + case "pop-numpad": + return ( +
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => ( + + ))} +
+ ); + + default: + return ( +
+ {typeLabel} +
+ ); + } +} + +// ======================================== +// 헬퍼 함수들 (export) +// ======================================== + +/** + * 특정 모드에 레이아웃이 설정되어 있는지 확인 + */ +export function hasModeLayout( + layout: PopLayoutDataV3, + modeKey: PopLayoutModeKey +): boolean { + const modeLayout = layout.layouts[modeKey]; + return modeLayout && Object.keys(modeLayout.componentPositions).length > 0; +} + +/** + * 태블릿 가로 모드(기준 모드)가 설정되어 있는지 확인 + */ +export function hasBaseLayout(layout: PopLayoutDataV3): boolean { + return hasModeLayout(layout, "tablet_landscape"); +} + +/** + * 태블릿 가로 모드를 기준으로 다른 모드에 맞게 자동 변환 + */ +export function autoConvertLayout( + layout: PopLayoutDataV3, + targetModeKey: PopLayoutModeKey +): PopModeLayoutV3 { + const sourceKey: PopLayoutModeKey = "tablet_landscape"; + const sourceLayout = layout.layouts[sourceKey]; + const sourceRes = MODE_RESOLUTIONS[sourceKey]; + const targetRes = MODE_RESOLUTIONS[targetModeKey]; + + // 비율 계산 + const widthRatio = targetRes.width / sourceRes.width; + const heightRatio = targetRes.height / sourceRes.height; + + // 가로 → 세로 변환인지 확인 + const isOrientationChange = + sourceRes.width > sourceRes.height !== targetRes.width > targetRes.height; + + // 컴포넌트 위치 변환 + const convertedPositions: Record = {}; + let currentRow = 1; + + // 컴포넌트를 row, col 순으로 정렬 + const sortedComponentIds = Object.keys(sourceLayout.componentPositions).sort( + (a, b) => { + const posA = sourceLayout.componentPositions[a]; + const posB = sourceLayout.componentPositions[b]; + if (posA.row !== posB.row) return posA.row - posB.row; + return posA.col - posB.col; + } + ); + + for (const componentId of sortedComponentIds) { + const sourcePos = sourceLayout.componentPositions[componentId]; + + if (isOrientationChange) { + // 가로 → 세로: 세로 스택 방식 + const canvasColumns = layout.settings.canvasGrid.columns; + convertedPositions[componentId] = { + col: 1, + row: currentRow, + colSpan: canvasColumns, + rowSpan: Math.max(3, Math.round(sourcePos.rowSpan * 1.5)), + }; + currentRow += convertedPositions[componentId].rowSpan + 1; + } else { + // 같은 방향: 비율 변환 + convertedPositions[componentId] = { + col: Math.max(1, Math.round(sourcePos.col * widthRatio)), + row: Math.max(1, Math.round(sourcePos.row * heightRatio)), + colSpan: Math.max(1, Math.round(sourcePos.colSpan * widthRatio)), + rowSpan: Math.max(1, Math.round(sourcePos.rowSpan * heightRatio)), + }; + } + } + + return { + componentPositions: convertedPositions, + }; +} + +/** + * 현재 모드에 맞는 레이아웃 반환 (없으면 자동 변환) + */ +export function getEffectiveModeLayout( + layout: PopLayoutDataV3, + targetModeKey: PopLayoutModeKey +): { + modeLayout: PopModeLayoutV3; + isConverted: boolean; + sourceModeKey: PopLayoutModeKey; +} { + // 해당 모드에 레이아웃이 있으면 그대로 사용 + if (hasModeLayout(layout, targetModeKey)) { + return { + modeLayout: layout.layouts[targetModeKey], + isConverted: false, + sourceModeKey: targetModeKey, + }; + } + + // 없으면 태블릿 가로 모드를 기준으로 자동 변환 + return { + modeLayout: autoConvertLayout(layout, targetModeKey), + isConverted: true, + sourceModeKey: "tablet_landscape", + }; +} + +export default PopLayoutRenderer; diff --git a/frontend/components/pop/designer/renderers/index.ts b/frontend/components/pop/designer/renderers/index.ts new file mode 100644 index 00000000..ee36be85 --- /dev/null +++ b/frontend/components/pop/designer/renderers/index.ts @@ -0,0 +1,11 @@ +// POP 레이아웃 렌더러 모듈 (v3) +// 디자이너와 뷰어에서 동일한 렌더링을 보장하기 위한 공용 렌더러 +// 섹션 제거됨, 컴포넌트 직접 배치 + +export { PopLayoutRenderer, default } from "./PopLayoutRenderer"; +export { + hasModeLayout, + hasBaseLayout, + autoConvertLayout, + getEffectiveModeLayout, +} from "./PopLayoutRenderer"; diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index a8125407..7199cd4c 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -1,6 +1,6 @@ // POP 디자이너 레이아웃 타입 정의 +// v3.0: 섹션 제거, 컴포넌트 직접 배치 // 그리드 기반 반응형 레이아웃 (픽셀 좌표 없음, 그리드 셀 기반) -// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로) // ======================================== // 레이아웃 모드 키 (4가지) @@ -22,13 +22,97 @@ export const MODE_RESOLUTIONS: Record; + + // 데이터 흐름 + dataFlow: PopDataFlow; + + // 전역 설정 + settings: PopGlobalSettings; + + // 메타데이터 + metadata?: PopLayoutMetadata; +} + +/** + * 모드별 레이아웃 v3 (컴포넌트 위치만) + */ +export interface PopModeLayoutV3 { + // 컴포넌트별 위치 (컴포넌트 ID → 위치) - 캔버스 그리드 기준 + componentPositions: Record; +} + +// ======================================== +// v2.0 레이아웃 (하위 호환용 - deprecated) +// ======================================== + +/** + * @deprecated v3.0 사용 권장 + */ +export interface PopLayoutDataV2 { + version: "pop-2.0"; + layouts: { + tablet_landscape: PopModeLayout; + tablet_portrait: PopModeLayout; + mobile_landscape: PopModeLayout; + mobile_portrait: PopModeLayout; + }; + sections: Record; + components: Record; + dataFlow: PopDataFlow; + settings: PopGlobalSettings; + metadata?: PopLayoutMetadata; +} + +/** + * @deprecated v3.0에서는 PopModeLayoutV3 사용 + */ +export interface PopModeLayout { + sectionPositions: Record; + componentPositions: Record; +} + +/** + * @deprecated v3.0에서 섹션 제거 + */ +export interface PopSectionDefinition { + id: string; + label?: string; + componentIds: string[]; + innerGrid: PopInnerGrid; + dataSource?: PopDataSource; + connections?: PopConnection[]; + style?: PopSectionStyle; +} + +// ======================================== +// v1.0 레이아웃 (하위 호환용 - deprecated) +// ======================================== + +/** + * @deprecated v3.0 사용 권장 */ export interface PopLayoutDataV1 { version: "pop-1.0"; @@ -40,70 +124,66 @@ export interface PopLayoutDataV1 { } // ======================================== -// v2.0 레이아웃 (신규) +// 통합 타입 +// ======================================== + +export type PopLayoutData = PopLayoutDataV1 | PopLayoutDataV2 | PopLayoutDataV3; + +export function isV3Layout(data: PopLayoutData): data is PopLayoutDataV3 { + return data.version === "pop-3.0"; +} + +export function isV2Layout(data: PopLayoutData): data is PopLayoutDataV2 { + return data.version === "pop-2.0"; +} + +export function isV1Layout(data: PopLayoutData): data is PopLayoutDataV1 { + return data.version === "pop-1.0"; +} + +// ======================================== +// 공통 타입 // ======================================== /** - * POP 레이아웃 v2.0 - * - 4개 모드별 섹션/컴포넌트 위치 분리 - * - 섹션/컴포넌트 정의는 공유 - * - 3단계 데이터 흐름 지원 + * 그리드 위치/크기 + * - col/row: 1-based 시작 위치 + * - colSpan/rowSpan: 차지하는 칸 수 */ -export interface PopLayoutDataV2 { - version: "pop-2.0"; - - // 4개 모드별 레이아웃 (위치/크기만) - layouts: { - tablet_landscape: PopModeLayout; - tablet_portrait: PopModeLayout; - mobile_landscape: PopModeLayout; - mobile_portrait: PopModeLayout; - }; - - // 공유 섹션 정의 (ID → 정의) - sections: Record; - - // 공유 컴포넌트 정의 (ID → 정의) - components: Record; - - // 3단계 데이터 흐름 - dataFlow: PopDataFlow; - - // 전역 설정 - settings: PopGlobalSettings; - - // 메타데이터 - metadata?: PopLayoutMetadata; +export interface GridPosition { + col: number; // 시작 열 (1-based) + row: number; // 시작 행 (1-based) + colSpan: number; // 열 개수 + rowSpan: number; // 행 개수 } /** - * 모드별 레이아웃 (위치/크기만 저장) + * 캔버스 그리드 설정 */ -export interface PopModeLayout { - // 섹션별 위치 (섹션 ID → 위치) - sectionPositions: Record; - // 컴포넌트별 위치 (컴포넌트 ID → 위치) - 섹션 내부 그리드 기준 - componentPositions: Record; +export interface PopCanvasGrid { + columns: number; // 열 개수 (기본 24) + rows: number; // 행 개수 (기본 24) - 비율 기반 + gap: number; // 그리드 간격 (px) } /** - * 공유 섹션 정의 (위치 제외) + * 대상 디바이스 */ -export interface PopSectionDefinition { - id: string; - label?: string; - // 이 섹션에 포함된 컴포넌트 ID 목록 - componentIds: string[]; - // 내부 그리드 설정 - innerGrid: PopInnerGrid; - // 데이터 소스 (섹션 레벨) - dataSource?: PopDataSource; - // 섹션 내 컴포넌트 간 연결 (Level 1) - connections?: PopConnection[]; - // 스타일 - style?: PopSectionStyle; +export type PopDeviceTarget = "mobile" | "tablet" | "both"; + +/** + * 레이아웃 메타데이터 + */ +export interface PopLayoutMetadata { + lastModified?: string; + modifiedBy?: string; + description?: string; } +// ======================================== +// 컴포넌트 정의 +// ======================================== + /** * 공유 컴포넌트 정의 (위치 제외) */ @@ -120,21 +200,40 @@ export interface PopComponentDefinition { } /** - * 데이터 소스 설정 + * POP 컴포넌트 타입 + * - 6개 핵심 컴포넌트 */ -export interface PopDataSource { - type: "api" | "static" | "parent"; - endpoint?: string; - params?: Record; - staticData?: any[]; +export type PopComponentType = + | "pop-field" // 데이터 입력/표시 + | "pop-button" // 액션 실행 + | "pop-list" // 데이터 목록 (카드 템플릿 포함) + | "pop-indicator" // 상태/수치 표시 + | "pop-scanner" // 바코드/QR 입력 + | "pop-numpad"; // 숫자 입력 특화 + +// ======================================== +// 데이터 흐름 +// ======================================== + +export interface PopDataFlow { + // 컴포넌트 간 연결 + connections: PopConnection[]; + + // 화면 로드 시 파라미터 수신 + onScreenLoad?: { + paramMapping: Record; // URL 파라미터 → 컴포넌트 ID + }; + + // 다음 화면으로 데이터 전달 + navigationOutput?: { + screenId: number; + paramMapping: Record; // 컴포넌트 ID → URL 파라미터명 + }; } -/** - * 컴포넌트/섹션 간 연결 - */ export interface PopConnection { - from: string; // 소스 ID (컴포넌트 또는 섹션) - to: string; // 타겟 ID + from: string; // 소스 컴포넌트 ID + to: string; // 타겟 컴포넌트 ID trigger: PopTrigger; // 트리거 이벤트 action: PopAction; // 수행할 액션 } @@ -147,204 +246,37 @@ export type PopTrigger = | "onSelect"; // 선택 시 export type PopAction = - | { type: "setValue"; targetField: string } // 값 설정 - | { type: "filter"; filterField: string } // 필터링 - | { type: "refresh" } // 새로고침 - | { type: "navigate"; screenId: number } // 화면 이동 - | { type: "api"; endpoint: string; method: string }; // API 호출 + | { type: "setValue"; targetField: string } + | { type: "filter"; filterField: string } + | { type: "refresh" } + | { type: "navigate"; screenId: number } + | { type: "api"; endpoint: string; method: string }; -/** - * 3단계 데이터 흐름 - */ -export interface PopDataFlow { - // Level 2: 섹션 간 연결 - sectionConnections: PopConnection[]; - - // Level 3: 화면 로드 시 파라미터 수신 - onScreenLoad?: { - paramMapping: Record; // URL 파라미터 → 컴포넌트 ID - }; - - // Level 3: 다음 화면으로 데이터 전달 - navigationOutput?: { - screenId: number; - paramMapping: Record; // 컴포넌트 ID → URL 파라미터명 - }; -} +// ======================================== +// 전역 설정 +// ======================================== -/** - * 전역 설정 - */ export interface PopGlobalSettings { - // 최소 터치 타겟 크기 (px) - touchTargetMin: number; // 기본 48px, 산업용 60px - // 모드 (일반/산업용) + touchTargetMin: number; // 기본 48px mode: "normal" | "industrial"; - // 캔버스 그리드 설정 canvasGrid: PopCanvasGrid; } // ======================================== -// 통합 타입 (v1 또는 v2) +// 데이터 소스 // ======================================== -/** - * POP 레이아웃 데이터 (v1 또는 v2) - */ -export type PopLayoutData = PopLayoutDataV1 | PopLayoutDataV2; - -/** - * 버전 체크 타입 가드 - */ -export function isV2Layout(data: PopLayoutData): data is PopLayoutDataV2 { - return data.version === "pop-2.0"; -} - -export function isV1Layout(data: PopLayoutData): data is PopLayoutDataV1 { - return data.version === "pop-1.0"; -} - -/** - * 캔버스 그리드 설정 - */ -export interface PopCanvasGrid { - columns: number; // 기본 12열 - rowHeight: number; // 행 높이 (px) - 태블릿 기준 - gap: number; // 그리드 간격 (px) -} - -/** - * 대상 디바이스 - */ -export type PopDeviceTarget = "mobile" | "tablet" | "both"; - -/** - * 레이아웃 메타데이터 - */ -export interface PopLayoutMetadata { - lastModified?: string; - modifiedBy?: string; - description?: string; -} - -// ===== 섹션 타입 ===== - -/** - * 그리드 위치/크기 - * - col/row: 1-based 시작 위치 - * - colSpan/rowSpan: 차지하는 칸 수 - */ -export interface GridPosition { - col: number; // 시작 열 (1-based) - row: number; // 시작 행 (1-based) - colSpan: number; // 열 개수 - rowSpan: number; // 행 개수 +export interface PopDataSource { + type: "api" | "static" | "parent"; + endpoint?: string; + params?: Record; + staticData?: any[]; } // ======================================== -// v1 섹션/컴포넌트 타입 (기존 구조) +// 스타일 // ======================================== -/** - * 섹션 데이터 v1 (기존 구조 - 위치 포함) - * @deprecated v2에서는 PopSectionDefinition 사용 - */ -export interface PopSectionDataV1 { - id: string; - label?: string; - grid: GridPosition; - mobileGrid?: GridPosition; - innerGrid: PopInnerGrid; - components: PopComponentDataV1[]; - style?: PopSectionStyle; -} - -/** - * 컴포넌트 데이터 v1 (기존 구조 - 위치 포함) - * @deprecated v2에서는 PopComponentDefinition 사용 - */ -export interface PopComponentDataV1 { - id: string; - type: PopComponentType; - grid: GridPosition; - mobileGrid?: GridPosition; - label?: string; - dataBinding?: PopDataBinding; - style?: PopStylePreset; - config?: PopComponentConfig; -} - -// 하위 호환을 위한 alias -export type PopSectionData = PopSectionDataV1; -export type PopComponentData = PopComponentDataV1; - -/** - * 섹션 내부 그리드 설정 - */ -export interface PopInnerGrid { - columns: number; // 내부 열 수 (2, 3, 4, 6 등) - rows: number; // 내부 행 수 - gap: number; // 간격 (px) -} - -/** - * 섹션 스타일 - */ -export interface PopSectionStyle { - showBorder?: boolean; - backgroundColor?: string; - padding?: PopPaddingPreset; -} - -// ===== 컴포넌트 타입 ===== - -/** - * POP 컴포넌트 타입 - * - 6개 핵심 컴포넌트 (섹션 제외, 섹션은 별도 타입) - */ -export type PopComponentType = - | "pop-field" // 데이터 입력/표시 - | "pop-button" // 액션 실행 - | "pop-list" // 데이터 목록 - | "pop-indicator" // 상태/수치 표시 - | "pop-scanner" // 바코드/QR 입력 - | "pop-numpad"; // 숫자 입력 특화 - -/** - * POP 컴포넌트 데이터 - * - 섹션 내부 그리드에 배치 - * - grid: 섹션 내부 그리드 위치 - */ -export interface PopComponentData { - id: string; - type: PopComponentType; - - // 섹션 내부 그리드 위치 - grid: GridPosition; - - // 모바일용 그리드 위치 (선택, 없으면 자동) - mobileGrid?: GridPosition; - - // 라벨 - label?: string; - - // 데이터 바인딩 - dataBinding?: PopDataBinding; - - // 스타일 프리셋 - style?: PopStylePreset; - - // 컴포넌트별 설정 (타입별로 다름) - config?: PopComponentConfig; -} - -// ===== 프리셋 ===== - -export type PopGapPreset = "none" | "small" | "medium" | "large"; - -/** - * 스타일 프리셋 - */ export interface PopStylePreset { variant: PopVariant; padding: PopPaddingPreset; @@ -352,10 +284,8 @@ export interface PopStylePreset { export type PopVariant = "default" | "primary" | "success" | "warning" | "danger"; export type PopPaddingPreset = "none" | "small" | "medium" | "large"; +export type PopGapPreset = "none" | "small" | "medium" | "large"; -/** - * 패딩 프리셋 → 실제 값 매핑 - */ export const POP_PADDING_MAP: Record = { none: "0", small: "8px", @@ -363,12 +293,24 @@ export const POP_PADDING_MAP: Record = { large: "24px", }; -// ===== 데이터 바인딩 ===== +// @deprecated - v3에서 섹션 제거 +export interface PopSectionStyle { + showBorder?: boolean; + backgroundColor?: string; + padding?: PopPaddingPreset; +} + +// @deprecated - v3에서 섹션 제거 +export interface PopInnerGrid { + columns: number; + rows: number; + gap: number; +} + +// ======================================== +// 데이터 바인딩 +// ======================================== -/** - * 데이터 바인딩 설정 - * - 기존 데스크톱 시스템과 호환 - */ export interface PopDataBinding { tableName: string; columnName: string; @@ -376,12 +318,10 @@ export interface PopDataBinding { filter?: Record; } +// ======================================== +// 컴포넌트별 설정 +// ======================================== -// ===== 컴포넌트별 설정 ===== - -/** - * 컴포넌트별 설정 (config 필드) - */ export type PopComponentConfig = | PopFieldConfig | PopButtonConfig @@ -390,17 +330,13 @@ export type PopComponentConfig = | PopScannerConfig | PopNumpadConfig; - -/** - * 필드 설정 - */ export interface PopFieldConfig { fieldType: PopFieldType; placeholder?: string; required?: boolean; readonly?: boolean; defaultValue?: any; - options?: PopFieldOption[]; // 드롭다운용 + options?: PopFieldOption[]; validation?: PopFieldValidation; } @@ -425,9 +361,6 @@ export interface PopFieldValidation { message?: string; } -/** - * 버튼 설정 - */ export interface PopButtonConfig { buttonType: PopButtonType; icon?: string; @@ -439,12 +372,12 @@ export type PopButtonType = "submit" | "action" | "navigation" | "cancel"; export interface PopButtonAction { type: "api" | "navigate" | "save" | "delete" | "custom"; - target?: string; // API URL 또는 화면 ID + target?: string; params?: Record; } /** - * 리스트 설정 + * 리스트 설정 (카드 템플릿 포함) */ export interface PopListConfig { listType: PopListType; @@ -452,13 +385,54 @@ export interface PopListConfig { selectable?: boolean; multiSelect?: boolean; displayColumns?: string[]; + // 카드 템플릿 설정 (v3 신규) + cardTemplate?: PopCardTemplate; } export type PopListType = "card" | "simple" | "table"; /** - * 인디케이터 설정 + * 카드 템플릿 (리스트 컴포넌트 내부) + * - 섹션의 역할을 대체 + * - 자유 배치 가능 */ +export interface PopCardTemplate { + // 카드 내부 그리드 설정 + grid: { + columns: number; + rows: number; + gap: number; + }; + // 카드 내 요소들 + elements: PopCardElement[]; + // 카드 스타일 + style?: { + backgroundColor?: string; + borderRadius?: number; + shadow?: boolean; + }; +} + +/** + * 카드 내 요소 (이미지, 텍스트, 숫자, 버튼 등) + */ +export interface PopCardElement { + id: string; + type: "image" | "text" | "number" | "button" | "badge"; + position: GridPosition; + // 데이터 바인딩 (어떤 컬럼과 연결) + dataField?: string; + // 스타일 + style?: { + fontSize?: string; + fontWeight?: string; + color?: string; + textAlign?: "left" | "center" | "right"; + }; + // 액션 (버튼용) + action?: PopButtonAction; +} + export interface PopIndicatorConfig { indicatorType: PopIndicatorType; unit?: string; @@ -474,21 +448,15 @@ export interface PopThreshold { label?: string; } -/** - * 스캐너 설정 - */ export interface PopScannerConfig { scannerType: PopScannerType; - targetField?: string; // 스캔 결과를 입력할 필드 ID + targetField?: string; autoSubmit?: boolean; soundEnabled?: boolean; } export type PopScannerType = "camera" | "external" | "both"; -/** - * 숫자패드 설정 - */ export interface PopNumpadConfig { targetField?: string; showDecimal?: boolean; @@ -496,22 +464,17 @@ export interface PopNumpadConfig { autoSubmit?: boolean; } -// ===== 기본값 및 유틸리티 ===== +// ======================================== +// 기본값 +// ======================================== -/** - * 기본 캔버스 그리드 설정 - * - columns: 24칸 (더 세밀한 가로 조절) - * - rowHeight: 20px (더 세밀한 세로 조절) - */ export const DEFAULT_CANVAS_GRID: PopCanvasGrid = { columns: 24, - rowHeight: 20, // 20px per row - 더 세밀한 높이 조절 + rows: 24, gap: 4, }; -/** - * 기본 섹션 내부 그리드 설정 - */ +// @deprecated export const DEFAULT_INNER_GRID: PopInnerGrid = { columns: 3, rows: 3, @@ -519,75 +482,23 @@ export const DEFAULT_INNER_GRID: PopInnerGrid = { }; // ======================================== -// v1 생성 함수 (기존 - 하위 호환) +// v3 생성 함수 // ======================================== /** - * 빈 v1 레이아웃 생성 - * @deprecated createEmptyPopLayoutV2 사용 권장 + * 빈 v3 레이아웃 생성 */ -export const createEmptyPopLayoutV1 = (): PopLayoutDataV1 => ({ - version: "pop-1.0", - layoutMode: "grid", - deviceTarget: "both", - canvasGrid: { ...DEFAULT_CANVAS_GRID }, - sections: [], -}); - -// 하위 호환을 위한 alias -export const createEmptyPopLayout = createEmptyPopLayoutV1; - -/** - * 새 섹션 생성 (v1) - */ -export const createPopSection = ( - id: string, - grid: GridPosition = { col: 1, row: 1, colSpan: 3, rowSpan: 4 } -): PopSectionDataV1 => ({ - id, - grid, - innerGrid: { ...DEFAULT_INNER_GRID }, - components: [], - style: { - showBorder: true, - padding: "small", - }, -}); - -/** - * 새 컴포넌트 생성 (v1) - */ -export const createPopComponent = ( - id: string, - type: PopComponentType, - grid: GridPosition = { col: 1, row: 1, colSpan: 1, rowSpan: 1 }, - label?: string -): PopComponentDataV1 => ({ - id, - type, - grid, - label, -}); - -// ======================================== -// v2 생성 함수 -// ======================================== - -/** - * 빈 v2 레이아웃 생성 - */ -export const createEmptyPopLayoutV2 = (): PopLayoutDataV2 => ({ - version: "pop-2.0", +export const createEmptyPopLayoutV3 = (): PopLayoutDataV3 => ({ + version: "pop-3.0", layouts: { - tablet_landscape: { sectionPositions: {}, componentPositions: {} }, - tablet_portrait: { sectionPositions: {}, componentPositions: {} }, - mobile_landscape: { sectionPositions: {}, componentPositions: {} }, - mobile_portrait: { sectionPositions: {}, componentPositions: {} }, + tablet_landscape: { componentPositions: {} }, + tablet_portrait: { componentPositions: {} }, + mobile_landscape: { componentPositions: {} }, + mobile_portrait: { componentPositions: {} }, }, - sections: {}, components: {}, dataFlow: { - sectionConnections: [], + connections: [], }, settings: { touchTargetMin: 48, @@ -597,24 +508,7 @@ export const createEmptyPopLayoutV2 = (): PopLayoutDataV2 => ({ }); /** - * v2 섹션 정의 생성 - */ -export const createSectionDefinition = ( - id: string, - label?: string -): PopSectionDefinition => ({ - id, - label, - componentIds: [], - innerGrid: { ...DEFAULT_INNER_GRID }, - style: { - showBorder: true, - padding: "small", - }, -}); - -/** - * v2 컴포넌트 정의 생성 + * v3 컴포넌트 정의 생성 */ export const createComponentDefinition = ( id: string, @@ -627,137 +521,19 @@ export const createComponentDefinition = ( }); // ======================================== -// 마이그레이션 함수 (v1 → v2) +// v3 헬퍼 함수 // ======================================== /** - * v1 레이아웃을 v2로 마이그레이션 - * - 기존 섹션/컴포넌트를 tablet_landscape 기준으로 4모드에 복제 - * - 정의와 위치를 분리 + * v3 레이아웃에 컴포넌트 추가 (4모드 동기화) */ -export const migrateV1ToV2 = (v1: PopLayoutDataV1): PopLayoutDataV2 => { - const v2 = createEmptyPopLayoutV2(); - - // 캔버스 그리드 설정 복사 - v2.settings.canvasGrid = { ...v1.canvasGrid }; - - // 메타데이터 복사 - if (v1.metadata) { - v2.metadata = { ...v1.metadata }; - } - - // 섹션별 마이그레이션 - for (const section of v1.sections) { - // 1. 섹션 정의 생성 - const sectionDef: PopSectionDefinition = { - id: section.id, - label: section.label, - componentIds: section.components.map(c => c.id), - innerGrid: { ...section.innerGrid }, - style: section.style ? { ...section.style } : undefined, - }; - v2.sections[section.id] = sectionDef; - - // 2. 섹션 위치 복사 (4모드 모두 동일하게) - const sectionPos: GridPosition = { ...section.grid }; - v2.layouts.tablet_landscape.sectionPositions[section.id] = { ...sectionPos }; - v2.layouts.tablet_portrait.sectionPositions[section.id] = { ...sectionPos }; - v2.layouts.mobile_landscape.sectionPositions[section.id] = { ...sectionPos }; - v2.layouts.mobile_portrait.sectionPositions[section.id] = { ...sectionPos }; - - // 3. 컴포넌트별 마이그레이션 - for (const comp of section.components) { - // 컴포넌트 정의 생성 - const compDef: PopComponentDefinition = { - id: comp.id, - type: comp.type, - label: comp.label, - dataBinding: comp.dataBinding ? { ...comp.dataBinding } : undefined, - style: comp.style ? { ...comp.style } : undefined, - config: comp.config, - }; - v2.components[comp.id] = compDef; - - // 컴포넌트 위치 복사 (4모드 모두 동일하게) - const compPos: GridPosition = { ...comp.grid }; - v2.layouts.tablet_landscape.componentPositions[comp.id] = { ...compPos }; - v2.layouts.tablet_portrait.componentPositions[comp.id] = { ...compPos }; - v2.layouts.mobile_landscape.componentPositions[comp.id] = { ...compPos }; - v2.layouts.mobile_portrait.componentPositions[comp.id] = { ...compPos }; - } - } - - return v2; -}; - -/** - * 레이아웃 데이터를 v2로 보장 (필요시 마이그레이션) - */ -export const ensureV2Layout = (data: PopLayoutData): PopLayoutDataV2 => { - if (isV2Layout(data)) { - return data; - } - if (isV1Layout(data)) { - return migrateV1ToV2(data); - } - // 알 수 없는 버전 - 빈 v2 반환 - console.warn("알 수 없는 레이아웃 버전, 빈 v2 레이아웃 생성"); - return createEmptyPopLayoutV2(); -}; - -// ======================================== -// v2 헬퍼 함수 -// ======================================== - -/** - * v2 레이아웃에 섹션 추가 (4모드 동기화) - */ -export const addSectionToV2Layout = ( - layout: PopLayoutDataV2, - sectionId: string, - position: GridPosition, - label?: string -): PopLayoutDataV2 => { - const newLayout = { ...layout }; - - // 섹션 정의 추가 - newLayout.sections = { - ...newLayout.sections, - [sectionId]: createSectionDefinition(sectionId, label), - }; - - // 4모드 모두에 위치 추가 - const modeKeys: PopLayoutModeKey[] = [ - "tablet_landscape", "tablet_portrait", - "mobile_landscape", "mobile_portrait" - ]; - - const newLayouts = { ...newLayout.layouts }; - for (const mode of modeKeys) { - newLayouts[mode] = { - ...newLayouts[mode], - sectionPositions: { - ...newLayouts[mode].sectionPositions, - [sectionId]: { ...position }, - }, - }; - } - newLayout.layouts = newLayouts; - - return newLayout; -}; - -/** - * v2 레이아웃에 컴포넌트 추가 (4모드 동기화) - */ -export const addComponentToV2Layout = ( - layout: PopLayoutDataV2, - sectionId: string, +export const addComponentToV3Layout = ( + layout: PopLayoutDataV3, componentId: string, type: PopComponentType, position: GridPosition, label?: string -): PopLayoutDataV2 => { +): PopLayoutDataV3 => { const newLayout = { ...layout }; // 컴포넌트 정의 추가 @@ -766,18 +542,6 @@ export const addComponentToV2Layout = ( [componentId]: createComponentDefinition(componentId, type, label), }; - // 섹션의 componentIds에 추가 - const section = newLayout.sections[sectionId]; - if (section) { - newLayout.sections = { - ...newLayout.sections, - [sectionId]: { - ...section, - componentIds: [...section.componentIds, componentId], - }, - }; - } - // 4모드 모두에 위치 추가 const modeKeys: PopLayoutModeKey[] = [ "tablet_landscape", "tablet_portrait", @@ -800,80 +564,18 @@ export const addComponentToV2Layout = ( }; /** - * v2 레이아웃에서 섹션 삭제 (4모드 동기화) + * v3 레이아웃에서 컴포넌트 삭제 (4모드 동기화) */ -export const removeSectionFromV2Layout = ( - layout: PopLayoutDataV2, - sectionId: string -): PopLayoutDataV2 => { - const newLayout = { ...layout }; - - // 섹션에 포함된 컴포넌트 ID 가져오기 - const section = newLayout.sections[sectionId]; - const componentIds = section?.componentIds || []; - - // 섹션 정의 삭제 - const { [sectionId]: _, ...remainingSections } = newLayout.sections; - newLayout.sections = remainingSections; - - // 컴포넌트 정의 삭제 - let remainingComponents = { ...newLayout.components }; - for (const compId of componentIds) { - const { [compId]: __, ...rest } = remainingComponents; - remainingComponents = rest; - } - newLayout.components = remainingComponents; - - // 4모드 모두에서 위치 삭제 - const modeKeys: PopLayoutModeKey[] = [ - "tablet_landscape", "tablet_portrait", - "mobile_landscape", "mobile_portrait" - ]; - - const newLayouts = { ...newLayout.layouts }; - for (const mode of modeKeys) { - const { [sectionId]: ___, ...remainingSecPos } = newLayouts[mode].sectionPositions; - let remainingCompPos = { ...newLayouts[mode].componentPositions }; - for (const compId of componentIds) { - const { [compId]: ____, ...rest } = remainingCompPos; - remainingCompPos = rest; - } - newLayouts[mode] = { - sectionPositions: remainingSecPos, - componentPositions: remainingCompPos, - }; - } - newLayout.layouts = newLayouts; - - return newLayout; -}; - -/** - * v2 레이아웃에서 컴포넌트 삭제 (4모드 동기화) - */ -export const removeComponentFromV2Layout = ( - layout: PopLayoutDataV2, - sectionId: string, +export const removeComponentFromV3Layout = ( + layout: PopLayoutDataV3, componentId: string -): PopLayoutDataV2 => { +): PopLayoutDataV3 => { const newLayout = { ...layout }; // 컴포넌트 정의 삭제 const { [componentId]: _, ...remainingComponents } = newLayout.components; newLayout.components = remainingComponents; - // 섹션의 componentIds에서 제거 - const section = newLayout.sections[sectionId]; - if (section) { - newLayout.sections = { - ...newLayout.sections, - [sectionId]: { - ...section, - componentIds: section.componentIds.filter(id => id !== componentId), - }, - }; - } - // 4모드 모두에서 위치 삭제 const modeKeys: PopLayoutModeKey[] = [ "tablet_landscape", "tablet_portrait", @@ -882,10 +584,9 @@ export const removeComponentFromV2Layout = ( const newLayouts = { ...newLayout.layouts }; for (const mode of modeKeys) { - const { [componentId]: __, ...remainingCompPos } = newLayouts[mode].componentPositions; + const { [componentId]: __, ...remainingPos } = newLayouts[mode].componentPositions; newLayouts[mode] = { - ...newLayouts[mode], - componentPositions: remainingCompPos, + componentPositions: remainingPos, }; } newLayout.layouts = newLayouts; @@ -894,38 +595,14 @@ export const removeComponentFromV2Layout = ( }; /** - * v2 레이아웃에서 특정 모드의 섹션 위치 업데이트 + * v3 레이아웃에서 특정 모드의 컴포넌트 위치 업데이트 */ -export const updateSectionPositionInMode = ( - layout: PopLayoutDataV2, - modeKey: PopLayoutModeKey, - sectionId: string, - position: GridPosition -): PopLayoutDataV2 => { - return { - ...layout, - layouts: { - ...layout.layouts, - [modeKey]: { - ...layout.layouts[modeKey], - sectionPositions: { - ...layout.layouts[modeKey].sectionPositions, - [sectionId]: position, - }, - }, - }, - }; -}; - -/** - * v2 레이아웃에서 특정 모드의 컴포넌트 위치 업데이트 - */ -export const updateComponentPositionInMode = ( - layout: PopLayoutDataV2, +export const updateComponentPositionInModeV3 = ( + layout: PopLayoutDataV3, modeKey: PopLayoutModeKey, componentId: string, position: GridPosition -): PopLayoutDataV2 => { +): PopLayoutDataV3 => { return { ...layout, layouts: { @@ -942,9 +619,469 @@ export const updateComponentPositionInMode = ( }; // ======================================== -// 타입 가드 +// 마이그레이션 함수 // ======================================== +/** + * v2 → v3 마이그레이션 + * 섹션을 제거하고 컴포넌트만 유지 + */ +export const migrateV2ToV3 = (v2: PopLayoutDataV2): PopLayoutDataV3 => { + const v3 = createEmptyPopLayoutV3(); + + // 설정 복사 + v3.settings = { ...v2.settings }; + v3.metadata = v2.metadata ? { ...v2.metadata } : undefined; + + // 컴포넌트 정의 복사 + v3.components = { ...v2.components }; + + // 4모드별 컴포넌트 위치 복사 (섹션 위치는 무시) + const modeKeys: PopLayoutModeKey[] = [ + "tablet_landscape", "tablet_portrait", + "mobile_landscape", "mobile_portrait" + ]; + + for (const mode of modeKeys) { + v3.layouts[mode] = { + componentPositions: { ...v2.layouts[mode].componentPositions }, + }; + } + + // 데이터 흐름 마이그레이션 (섹션 연결 → 컴포넌트 연결) + v3.dataFlow = { + connections: v2.dataFlow.sectionConnections || [], + onScreenLoad: v2.dataFlow.onScreenLoad, + navigationOutput: v2.dataFlow.navigationOutput, + }; + + return v3; +}; + +/** + * v1 → v3 마이그레이션 + */ +export const migrateV1ToV3 = (v1: PopLayoutDataV1): PopLayoutDataV3 => { + const v3 = createEmptyPopLayoutV3(); + + // 캔버스 그리드 설정 복사 + v3.settings.canvasGrid = { ...v1.canvasGrid }; + v3.metadata = v1.metadata ? { ...v1.metadata } : undefined; + + // 섹션 내 컴포넌트들을 캔버스 직접 배치로 변환 + for (const section of v1.sections) { + for (const comp of section.components) { + // 컴포넌트 정의 생성 + v3.components[comp.id] = { + id: comp.id, + type: comp.type, + label: comp.label, + dataBinding: comp.dataBinding, + style: comp.style, + config: comp.config, + }; + + // 섹션 위치 + 컴포넌트 상대 위치 → 절대 위치로 변환 + const absCol = section.grid.col + comp.grid.col - 1; + const absRow = section.grid.row + comp.grid.row - 1; + + const position: GridPosition = { + col: absCol, + row: absRow, + colSpan: comp.grid.colSpan, + rowSpan: comp.grid.rowSpan, + }; + + // 4모드 동일 위치 + v3.layouts.tablet_landscape.componentPositions[comp.id] = { ...position }; + v3.layouts.tablet_portrait.componentPositions[comp.id] = { ...position }; + v3.layouts.mobile_landscape.componentPositions[comp.id] = { ...position }; + v3.layouts.mobile_portrait.componentPositions[comp.id] = { ...position }; + } + } + + return v3; +}; + +/** + * 레이아웃 데이터를 v3로 보장 (필요시 마이그레이션) + */ +export const ensureV3Layout = (data: PopLayoutData): PopLayoutDataV3 => { + let result: PopLayoutDataV3; + + if (isV3Layout(data)) { + result = data; + } else if (isV2Layout(data)) { + console.log("v2 → v3 마이그레이션 수행"); + result = migrateV2ToV3(data); + } else if (isV1Layout(data)) { + console.log("v1 → v3 마이그레이션 수행"); + result = migrateV1ToV3(data); + } else { + console.warn("알 수 없는 레이아웃 버전, 빈 v3 레이아웃 생성"); + result = createEmptyPopLayoutV3(); + } + + // canvasGrid 정규화 + const { rowHeight, ...restGrid } = result.settings.canvasGrid as any; + + if (rowHeight !== undefined) { + console.warn("구버전 rowHeight 필드 제거"); + } + + if (!restGrid.rows) { + console.warn("canvasGrid.rows 없음, 기본값 24로 설정"); + } + + result.settings.canvasGrid = { + columns: restGrid.columns || DEFAULT_CANVAS_GRID.columns, + rows: restGrid.rows || DEFAULT_CANVAS_GRID.rows, + gap: restGrid.gap || DEFAULT_CANVAS_GRID.gap, + }; + + return result; +}; + +// ======================================== +// 하위 호환 (deprecated) +// ======================================== + +/** @deprecated v3 사용 권장 */ +export const createEmptyPopLayoutV2 = (): PopLayoutDataV2 => ({ + 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: { ...DEFAULT_CANVAS_GRID }, + }, +}); + +/** @deprecated */ +export const createEmptyPopLayoutV1 = (): PopLayoutDataV1 => ({ + version: "pop-1.0", + layoutMode: "grid", + deviceTarget: "both", + canvasGrid: { ...DEFAULT_CANVAS_GRID }, + sections: [], +}); + +/** @deprecated */ +export const createEmptyPopLayout = createEmptyPopLayoutV1; + +/** @deprecated */ +export interface PopSectionDataV1 { + id: string; + label?: string; + grid: GridPosition; + mobileGrid?: GridPosition; + innerGrid: PopInnerGrid; + components: PopComponentDataV1[]; + style?: PopSectionStyle; +} + +/** @deprecated */ +export interface PopComponentDataV1 { + id: string; + type: PopComponentType; + grid: GridPosition; + mobileGrid?: GridPosition; + label?: string; + dataBinding?: PopDataBinding; + style?: PopStylePreset; + config?: PopComponentConfig; +} + +/** @deprecated */ +export type PopSectionData = PopSectionDataV1; +/** @deprecated */ +export type PopComponentData = PopComponentDataV1; + +/** @deprecated */ +export const createPopSection = ( + id: string, + grid: GridPosition = { col: 1, row: 1, colSpan: 3, rowSpan: 4 } +): PopSectionDataV1 => ({ + id, + grid, + innerGrid: { ...DEFAULT_INNER_GRID }, + components: [], + style: { showBorder: true, padding: "small" }, +}); + +/** @deprecated */ +export const createPopComponent = ( + id: string, + type: PopComponentType, + grid: GridPosition = { col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + label?: string +): PopComponentDataV1 => ({ + id, + type, + grid, + label, +}); + +/** @deprecated */ +export const createSectionDefinition = ( + id: string, + label?: string +): PopSectionDefinition => ({ + id, + label, + componentIds: [], + innerGrid: { ...DEFAULT_INNER_GRID }, + style: { showBorder: true, padding: "small" }, +}); + +/** @deprecated - v2 헬퍼 */ +export const addSectionToV2Layout = ( + layout: PopLayoutDataV2, + sectionId: string, + position: GridPosition, + label?: string +): PopLayoutDataV2 => { + const newLayout = { ...layout }; + newLayout.sections = { + ...newLayout.sections, + [sectionId]: createSectionDefinition(sectionId, label), + }; + const modeKeys: PopLayoutModeKey[] = [ + "tablet_landscape", "tablet_portrait", + "mobile_landscape", "mobile_portrait" + ]; + const newLayouts = { ...newLayout.layouts }; + for (const mode of modeKeys) { + newLayouts[mode] = { + ...newLayouts[mode], + sectionPositions: { + ...newLayouts[mode].sectionPositions, + [sectionId]: { ...position }, + }, + }; + } + newLayout.layouts = newLayouts; + return newLayout; +}; + +/** @deprecated - v2 헬퍼 */ +export const removeSectionFromV2Layout = ( + layout: PopLayoutDataV2, + sectionId: string +): PopLayoutDataV2 => { + const newLayout = { ...layout }; + const section = newLayout.sections[sectionId]; + const componentIds = section?.componentIds || []; + const { [sectionId]: _, ...remainingSections } = newLayout.sections; + newLayout.sections = remainingSections; + let remainingComponents = { ...newLayout.components }; + for (const compId of componentIds) { + const { [compId]: __, ...rest } = remainingComponents; + remainingComponents = rest; + } + newLayout.components = remainingComponents; + const modeKeys: PopLayoutModeKey[] = [ + "tablet_landscape", "tablet_portrait", + "mobile_landscape", "mobile_portrait" + ]; + const newLayouts = { ...newLayout.layouts }; + for (const mode of modeKeys) { + const { [sectionId]: ___, ...remainingSecPos } = newLayouts[mode].sectionPositions; + let remainingCompPos = { ...newLayouts[mode].componentPositions }; + for (const compId of componentIds) { + const { [compId]: ____, ...rest } = remainingCompPos; + remainingCompPos = rest; + } + newLayouts[mode] = { + sectionPositions: remainingSecPos, + componentPositions: remainingCompPos, + }; + } + newLayout.layouts = newLayouts; + return newLayout; +}; + +/** @deprecated */ +export const addComponentToV2Layout = ( + layout: PopLayoutDataV2, + sectionId: string, + componentId: string, + type: PopComponentType, + position: GridPosition, + label?: string +): PopLayoutDataV2 => { + const newLayout = { ...layout }; + newLayout.components = { + ...newLayout.components, + [componentId]: { id: componentId, type, label }, + }; + const section = newLayout.sections[sectionId]; + if (section) { + newLayout.sections = { + ...newLayout.sections, + [sectionId]: { + ...section, + componentIds: [...section.componentIds, componentId], + }, + }; + } + const modeKeys: PopLayoutModeKey[] = [ + "tablet_landscape", "tablet_portrait", + "mobile_landscape", "mobile_portrait" + ]; + const newLayouts = { ...newLayout.layouts }; + for (const mode of modeKeys) { + newLayouts[mode] = { + ...newLayouts[mode], + componentPositions: { + ...newLayouts[mode].componentPositions, + [componentId]: { ...position }, + }, + }; + } + newLayout.layouts = newLayouts; + return newLayout; +}; + +/** @deprecated */ +export const removeComponentFromV2Layout = ( + layout: PopLayoutDataV2, + sectionId: string, + componentId: string +): PopLayoutDataV2 => { + const newLayout = { ...layout }; + const { [componentId]: _, ...remainingComponents } = newLayout.components; + newLayout.components = remainingComponents; + const section = newLayout.sections[sectionId]; + if (section) { + newLayout.sections = { + ...newLayout.sections, + [sectionId]: { + ...section, + componentIds: section.componentIds.filter(id => id !== componentId), + }, + }; + } + const modeKeys: PopLayoutModeKey[] = [ + "tablet_landscape", "tablet_portrait", + "mobile_landscape", "mobile_portrait" + ]; + const newLayouts = { ...newLayout.layouts }; + for (const mode of modeKeys) { + const { [componentId]: __, ...remainingCompPos } = newLayouts[mode].componentPositions; + newLayouts[mode] = { + ...newLayouts[mode], + componentPositions: remainingCompPos, + }; + } + newLayout.layouts = newLayouts; + return newLayout; +}; + +/** @deprecated */ +export const updateSectionPositionInMode = ( + layout: PopLayoutDataV2, + modeKey: PopLayoutModeKey, + sectionId: string, + position: GridPosition +): PopLayoutDataV2 => ({ + ...layout, + layouts: { + ...layout.layouts, + [modeKey]: { + ...layout.layouts[modeKey], + sectionPositions: { + ...layout.layouts[modeKey].sectionPositions, + [sectionId]: position, + }, + }, + }, +}); + +/** @deprecated */ +export const updateComponentPositionInMode = ( + layout: PopLayoutDataV2, + modeKey: PopLayoutModeKey, + componentId: string, + position: GridPosition +): PopLayoutDataV2 => ({ + ...layout, + layouts: { + ...layout.layouts, + [modeKey]: { + ...layout.layouts[modeKey], + componentPositions: { + ...layout.layouts[modeKey].componentPositions, + [componentId]: position, + }, + }, + }, +}); + +/** @deprecated */ +export const migrateV1ToV2 = (v1: PopLayoutDataV1): PopLayoutDataV2 => { + const v2 = createEmptyPopLayoutV2(); + v2.settings.canvasGrid = { ...v1.canvasGrid }; + if (v1.metadata) v2.metadata = { ...v1.metadata }; + for (const section of v1.sections) { + v2.sections[section.id] = { + id: section.id, + label: section.label, + componentIds: section.components.map(c => c.id), + innerGrid: { ...section.innerGrid }, + style: section.style, + }; + const sectionPos: GridPosition = { ...section.grid }; + v2.layouts.tablet_landscape.sectionPositions[section.id] = { ...sectionPos }; + v2.layouts.tablet_portrait.sectionPositions[section.id] = { ...sectionPos }; + v2.layouts.mobile_landscape.sectionPositions[section.id] = { ...sectionPos }; + v2.layouts.mobile_portrait.sectionPositions[section.id] = { ...sectionPos }; + for (const comp of section.components) { + v2.components[comp.id] = { + id: comp.id, + type: comp.type, + label: comp.label, + dataBinding: comp.dataBinding, + style: comp.style, + config: comp.config, + }; + const compPos: GridPosition = { ...comp.grid }; + v2.layouts.tablet_landscape.componentPositions[comp.id] = { ...compPos }; + v2.layouts.tablet_portrait.componentPositions[comp.id] = { ...compPos }; + v2.layouts.mobile_landscape.componentPositions[comp.id] = { ...compPos }; + v2.layouts.mobile_portrait.componentPositions[comp.id] = { ...compPos }; + } + } + return v2; +}; + +/** @deprecated - ensureV3Layout 사용 권장 */ +export const ensureV2Layout = (data: PopLayoutData): PopLayoutDataV2 => { + if (isV2Layout(data)) { + const { rowHeight, ...restGrid } = data.settings.canvasGrid as any; + data.settings.canvasGrid = { + columns: restGrid.columns || DEFAULT_CANVAS_GRID.columns, + rows: restGrid.rows || DEFAULT_CANVAS_GRID.rows, + gap: restGrid.gap || DEFAULT_CANVAS_GRID.gap, + }; + return data; + } else if (isV1Layout(data)) { + return migrateV1ToV2(data); + } + return createEmptyPopLayoutV2(); +}; + +// 타입 가드 export const isPopField = (comp: PopComponentDataV1 | PopComponentDefinition): boolean => comp.type === "pop-field"; diff --git a/frontend/components/pop/management/PopCategoryTree.tsx b/frontend/components/pop/management/PopCategoryTree.tsx index c8140821..0689d699 100644 --- a/frontend/components/pop/management/PopCategoryTree.tsx +++ b/frontend/components/pop/management/PopCategoryTree.tsx @@ -100,6 +100,7 @@ interface TreeNodeProps { onMoveGroupDown: (group: PopScreenGroup) => void; onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void; onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void; + onDeleteScreen: (screen: ScreenDefinition) => void; } // ============================================================ @@ -116,6 +117,7 @@ function TreeNode({ onMoveGroupDown, onMoveScreenUp, onMoveScreenDown, + onDeleteScreen, expandedGroups, onToggle, selectedGroupId, @@ -276,6 +278,7 @@ function TreeNode({ onMoveGroupDown={onMoveGroupDown} onMoveScreenUp={onMoveScreenUp} onMoveScreenDown={onMoveScreenDown} + onDeleteScreen={onDeleteScreen} /> ))} @@ -343,12 +346,18 @@ function TreeNode({ onRemoveScreenFromGroup(screen, group.id)} > - + 그룹에서 제거 + onDeleteScreen(screen)} + > + + 화면 삭제 +
@@ -389,10 +398,14 @@ export function PopCategoryTree({ icon: "", }); - // 삭제 다이얼로그 상태 + // 그룹 삭제 다이얼로그 상태 const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [deletingGroup, setDeletingGroup] = useState(null); + // 화면 삭제 다이얼로그 상태 + const [isScreenDeleteDialogOpen, setIsScreenDeleteDialogOpen] = useState(false); + const [deletingScreen, setDeletingScreen] = useState(null); + // 이동 모달 상태 const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); const [movingScreen, setMovingScreen] = useState(null); @@ -612,6 +625,37 @@ export function PopCategoryTree({ } }; + // 화면 삭제 다이얼로그 열기 + const handleDeleteScreen = (screen: ScreenDefinition) => { + setDeletingScreen(screen); + setIsScreenDeleteDialogOpen(true); + }; + + // 화면 삭제 확인 + const confirmDeleteScreen = async () => { + if (!deletingScreen) return; + + try { + // 화면 삭제 API 호출 (휴지통으로 이동) + await apiClient.delete(`/screen-management/screens/${deletingScreen.screenId}`); + toast.success(`"${deletingScreen.screenName}" 화면이 휴지통으로 이동되었습니다.`); + + // 화면 목록 새로고침 (부모 컴포넌트에서 처리해야 함) + loadGroups(); + + // 삭제된 화면이 선택된 상태였다면 선택 해제 + if (selectedScreen?.screenId === deletingScreen.screenId) { + onScreenSelect(null as any); // 선택 해제 + } + } catch (error: any) { + console.error("화면 삭제 실패:", error); + toast.error(error.response?.data?.message || error.message || "화면 삭제에 실패했습니다."); + } finally { + setIsScreenDeleteDialogOpen(false); + setDeletingScreen(null); + } + }; + // 그룹 순서 위로 이동 const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => { try { @@ -857,6 +901,7 @@ export function PopCategoryTree({ onMoveGroupDown={handleMoveGroupDown} onMoveScreenUp={handleMoveScreenUp} onMoveScreenDown={handleMoveScreenDown} + onDeleteScreen={handleDeleteScreen} /> ))} @@ -905,6 +950,14 @@ export function PopCategoryTree({ 카테고리로 이동 + + handleDeleteScreen(screen)} + > + + 화면 삭제 +
@@ -1092,6 +1145,31 @@ export function PopCategoryTree({ + + {/* 화면 삭제 확인 다이얼로그 */} + + + + 화면 삭제 + + "{deletingScreen?.screenName}" 화면을 삭제하시겠습니까? +
+ + 삭제된 화면은 휴지통으로 이동되며, 나중에 복원할 수 있습니다. + +
+
+ + 취소 + + 삭제 + + +
+
); } diff --git a/frontend/hooks/useDeviceOrientation.ts b/frontend/hooks/useDeviceOrientation.ts new file mode 100644 index 00000000..a493e487 --- /dev/null +++ b/frontend/hooks/useDeviceOrientation.ts @@ -0,0 +1,208 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; + +// ======================================== +// 타입 정의 +// ======================================== +export type DeviceType = "mobile" | "tablet"; +export type OrientationType = "landscape" | "portrait"; + +export interface ResponsiveMode { + device: DeviceType; + orientation: OrientationType; + isLandscape: boolean; + modeKey: "tablet_landscape" | "tablet_portrait" | "mobile_landscape" | "mobile_portrait"; +} + +// ======================================== +// 브레이크포인트 (화면 너비 기준) +// ======================================== +const BREAKPOINTS = { + // 모바일: 0 ~ 767px + // 태블릿: 768px 이상 + TABLET_MIN: 768, +}; + +/** + * 반응형 모드 자동 감지 훅 + * + * - 화면 크기와 방향에 따라 4가지 모드 자동 전환 + * - tablet_landscape, tablet_portrait, mobile_landscape, mobile_portrait + * - resize 이벤트와 orientation 변경 모두 감지 + * + * @returns ResponsiveMode 객체 + */ +export function useResponsiveMode(): ResponsiveMode { + const [mode, setMode] = useState({ + device: "tablet", + orientation: "landscape", + isLandscape: true, + modeKey: "tablet_landscape", + }); + + useEffect(() => { + if (typeof window === "undefined") return; + + const detectMode = (): ResponsiveMode => { + const width = window.innerWidth; + const height = window.innerHeight; + + // 디바이스 타입 결정 (화면 너비 기준) + const device: DeviceType = width >= BREAKPOINTS.TABLET_MIN ? "tablet" : "mobile"; + + // 방향 결정 (가로/세로 비율) + const isLandscape = width > height; + const orientation: OrientationType = isLandscape ? "landscape" : "portrait"; + + // 모드 키 생성 + const modeKey = `${device}_${orientation}` as ResponsiveMode["modeKey"]; + + return { device, orientation, isLandscape, modeKey }; + }; + + // 초기값 설정 + setMode(detectMode()); + + const handleChange = () => { + setTimeout(() => { + setMode(detectMode()); + }, 100); + }; + + // 이벤트 리스너 등록 + window.addEventListener("resize", handleChange); + window.addEventListener("orientationchange", handleChange); + + // matchMedia로 orientation 변경 감지 + const landscapeQuery = window.matchMedia("(orientation: landscape)"); + if (landscapeQuery.addEventListener) { + landscapeQuery.addEventListener("change", handleChange); + } + + return () => { + window.removeEventListener("resize", handleChange); + window.removeEventListener("orientationchange", handleChange); + if (landscapeQuery.removeEventListener) { + landscapeQuery.removeEventListener("change", handleChange); + } + }; + }, []); + + return mode; +} + +/** + * 디바이스 방향(orientation) 감지 커스텀 훅 + * + * - 실제 디바이스에서 가로/세로 방향 변경을 감지 + * - window.matchMedia와 orientationchange 이벤트 활용 + * - SSR 호환성 고려 (typeof window !== 'undefined') + * + * @returns isLandscape - true: 가로 모드, false: 세로 모드 + */ +export function useDeviceOrientation(): boolean { + const [isLandscape, setIsLandscape] = useState(false); + + useEffect(() => { + if (typeof window === "undefined") return; + + const detectOrientation = (): boolean => { + if (window.matchMedia) { + const landscapeQuery = window.matchMedia("(orientation: landscape)"); + return landscapeQuery.matches; + } + return window.innerWidth > window.innerHeight; + }; + + setIsLandscape(detectOrientation()); + + const handleOrientationChange = () => { + setTimeout(() => { + setIsLandscape(detectOrientation()); + }, 100); + }; + + const landscapeQuery = window.matchMedia("(orientation: landscape)"); + + if (landscapeQuery.addEventListener) { + landscapeQuery.addEventListener("change", handleOrientationChange); + } else if (landscapeQuery.addListener) { + landscapeQuery.addListener(handleOrientationChange); + } + + window.addEventListener("orientationchange", handleOrientationChange); + window.addEventListener("resize", handleOrientationChange); + + return () => { + if (landscapeQuery.removeEventListener) { + landscapeQuery.removeEventListener("change", handleOrientationChange); + } else if (landscapeQuery.removeListener) { + landscapeQuery.removeListener(handleOrientationChange); + } + window.removeEventListener("orientationchange", handleOrientationChange); + window.removeEventListener("resize", handleOrientationChange); + }; + }, []); + + return isLandscape; +} + +/** + * 수동 방향 전환을 지원하는 확장 훅 + * 프리뷰 모드에서 테스트 목적으로 사용 + * + * @param initialOverride - 초기 수동 설정값 (undefined면 자동 감지) + * @returns [isLandscape, setIsLandscape, isAutoDetect] + */ +export function useDeviceOrientationWithOverride( + initialOverride?: boolean +): [boolean, (value: boolean | undefined) => void, boolean] { + const autoDetectedIsLandscape = useDeviceOrientation(); + const [manualOverride, setManualOverride] = useState(initialOverride); + + const isLandscape = manualOverride !== undefined ? manualOverride : autoDetectedIsLandscape; + const isAutoDetect = manualOverride === undefined; + + const setOrientation = (value: boolean | undefined) => { + setManualOverride(value); + }; + + return [isLandscape, setOrientation, isAutoDetect]; +} + +/** + * 반응형 모드 + 수동 오버라이드 지원 훅 + * 프리뷰 모드에서 디바이스/방향을 수동으로 변경할 때 사용 + */ +export function useResponsiveModeWithOverride( + initialDeviceOverride?: DeviceType, + initialOrientationOverride?: boolean +): { + mode: ResponsiveMode; + setDevice: (device: DeviceType | undefined) => void; + setOrientation: (isLandscape: boolean | undefined) => void; + isAutoDetect: boolean; +} { + const autoMode = useResponsiveMode(); + const [deviceOverride, setDeviceOverride] = useState(initialDeviceOverride); + const [orientationOverride, setOrientationOverride] = useState(initialOrientationOverride); + + const mode = useMemo((): ResponsiveMode => { + const device = deviceOverride ?? autoMode.device; + const isLandscape = orientationOverride ?? autoMode.isLandscape; + const orientation: OrientationType = isLandscape ? "landscape" : "portrait"; + const modeKey = `${device}_${orientation}` as ResponsiveMode["modeKey"]; + + return { device, orientation, isLandscape, modeKey }; + }, [autoMode, deviceOverride, orientationOverride]); + + const isAutoDetect = deviceOverride === undefined && orientationOverride === undefined; + + return { + mode, + setDevice: setDeviceOverride, + setOrientation: setOrientationOverride, + isAutoDetect, + }; +}