POP 레이아웃 v3.0 아키텍처로 전환 - Section 요소 완전 제거

디자이너/뷰어 렌더링 통일 - react-grid-layout 제거, CSS Grid 1fr 단위 사용
반응형 모드 자동 전환 - useResponsiveMode 훅 추가
디자이너 UI 개선 - 컴포넌트 헤더/삭제 아이콘 제거, 전체 영역 드래그
This commit is contained in:
SeongHyun Kim 2026-02-03 19:11:03 +09:00
parent 368d641ae8
commit de2163bcef
17 changed files with 2991 additions and 2194 deletions

227
BUGFIX_CANVAS_ROWS.md Normal file
View File

@ -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모드 반응형 유지
**비율 기반 강제**: 고정 픽셀 제거
**하위 호환**: 구버전 데이터 자동 보정
**안정성 향상**: 타입 일관성 확보

View File

@ -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*

View File

@ -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})`);
}
/**

View File

@ -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<DeviceType, Record<"landscape" | "portrait", { width: number; height: number; label: string }>> = {
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<DeviceType>(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<ScreenDefinition | null>(null);
const [layout, setLayout] = useState<LayoutData | null>(null);
const [popLayoutV3, setPopLayoutV3] = useState<PopLayoutDataV3 | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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() {
<ScreenPreviewProvider isPreviewMode={isPreviewMode}>
<ActiveTabProvider>
<TableOptionsProvider>
<div className="min-h-screen bg-gray-100">
<div className="h-screen bg-gray-100 flex flex-col overflow-hidden">
{/* 상단 툴바 (프리뷰 모드에서만) */}
{isPreviewMode && (
<div className="sticky top-0 z-50 bg-white border-b shadow-sm">
@ -188,27 +218,65 @@ function PopScreenViewPage() {
</Button>
<span className="text-sm font-medium">{screen.screenName}</span>
<span className="text-xs text-gray-400">
({currentModeKey.replace("_", " ")})
</span>
</div>
{/* 디바이스 전환 버튼 */}
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
<Button
variant={deviceType === "mobile" ? "default" : "ghost"}
size="sm"
onClick={() => setDevice("mobile")}
className="gap-1"
>
<Smartphone className="h-4 w-4" />
</Button>
<Button
variant={deviceType === "tablet" ? "default" : "ghost"}
size="sm"
onClick={() => setDevice("tablet")}
className="gap-1"
>
<Tablet className="h-4 w-4" />
릿
</Button>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
<Button
variant={isLandscape ? "default" : "ghost"}
size="sm"
onClick={() => setOrientation(true)}
className="gap-1"
>
<RotateCw className="h-4 w-4" />
</Button>
<Button
variant={!isLandscape ? "default" : "ghost"}
size="sm"
onClick={() => setOrientation(false)}
className="gap-1"
>
<RotateCcw className="h-4 w-4" />
</Button>
</div>
{/* 자동 감지 모드 버튼 */}
<Button
variant={deviceType === "mobile" ? "default" : "ghost"}
variant={isAutoDetect ? "default" : "outline"}
size="sm"
onClick={() => setDeviceType("mobile")}
onClick={() => {
setDevice(undefined);
setOrientation(undefined);
}}
className="gap-1"
>
<Smartphone className="h-4 w-4" />
</Button>
<Button
variant={deviceType === "tablet" ? "default" : "ghost"}
size="sm"
onClick={() => setDeviceType("tablet")}
className="gap-1"
>
<Tablet className="h-4 w-4" />
릿
</Button>
</div>
@ -220,67 +288,30 @@ function PopScreenViewPage() {
)}
{/* POP 화면 컨텐츠 */}
<div className={`flex justify-center ${isPreviewMode ? "py-4" : "py-0"}`}>
<div className={`flex-1 flex flex-col ${isPreviewMode ? "py-4 overflow-auto items-center" : ""}`}>
{/* 현재 모드 표시 (일반 모드) */}
{!isPreviewMode && (
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
{currentModeKey.replace("_", " ")}
</div>
)}
<div
className={`bg-white ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-hidden border-8 border-gray-800" : ""}`}
style={{
width: isPreviewMode ? currentDevice.width : "100%",
minHeight: isPreviewMode ? currentDevice.height : "100vh",
maxWidth: isPreviewMode ? currentDevice.width : "100%",
}}
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-hidden border-8 border-gray-800" : "w-full h-full"}`}
style={isPreviewMode ? {
width: currentDevice.width,
height: currentDevice.height,
flexShrink: 0,
} : undefined}
>
{/* POP 레이아웃: sections 구조 렌더링 */}
{layout && (layout as any).sections && (layout as any).sections.length > 0 ? (
<div className="w-full min-h-full p-2">
{/* 그리드 레이아웃으로 섹션 배치 */}
<div
className="grid gap-1"
style={{
gridTemplateColumns: `repeat(${(layout as any).canvasGrid?.columns || 24}, 1fr)`,
}}
>
{(layout as any).sections.map((section: any) => (
<div
key={section.id}
className="bg-gray-50 border border-gray-200 rounded-lg p-2"
style={{
gridColumn: `${section.grid?.col || 1} / span ${section.grid?.colSpan || 6}`,
gridRow: `${section.grid?.row || 1} / span ${section.grid?.rowSpan || 4}`,
minHeight: `${(section.grid?.rowSpan || 4) * 20}px`,
}}
>
{/* 섹션 라벨 */}
{section.label && (
<div className="text-xs font-medium text-gray-500 mb-1">
{section.label}
</div>
)}
{/* 섹션 내 컴포넌트들 */}
{section.components && section.components.length > 0 ? (
<div className="space-y-1">
{section.components.map((comp: any) => (
<div
key={comp.id}
className="bg-white border border-gray-100 rounded p-2 text-sm"
>
{/* TODO: POP 전용 컴포넌트 렌더러 구현 필요 */}
<span className="text-gray-600">
{comp.label || comp.type || comp.id}
</span>
</div>
))}
</div>
) : (
<div className="text-xs text-gray-400 text-center py-2">
</div>
)}
</div>
))}
</div>
</div>
{/* POP 레이아웃 v3.0 렌더링 */}
{popLayoutV3 ? (
<PopLayoutV3Renderer
layout={popLayoutV3}
modeKey={currentModeKey}
/>
) : layout && layout.components && layout.components.length > 0 ? (
// 이전 형식 (components 구조) - 호환성 유지
// 레거시 형식 (components 구조) - 호환성 유지
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
<div className="relative w-full min-h-full p-4">
{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 (
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center mb-4">
<span className="text-2xl">!</span>
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">
</h3>
<p className="text-sm text-gray-500 max-w-xs">
POP 릿 .
</p>
</div>
);
}
// 현재 모드에 맞는 레이아웃 가져오기
const { modeLayout, isConverted, sourceModeKey } = getEffectiveModeLayout(layout, modeKey);
return (
<div className="w-full h-full flex flex-col">
{isConverted && (
<div className="mx-2 mt-2 px-2 py-1 bg-yellow-50 border border-yellow-200 rounded text-xs text-yellow-700 shrink-0">
{sourceModeKey}
</div>
)}
<PopLayoutRenderer
layout={layout}
modeKey={modeKey}
customModeLayout={isConverted ? modeLayout : undefined}
isDesignMode={false}
className="flex-1"
style={{ height: "100%" }}
/>
</div>
);
}
// Provider 래퍼
export default function PopScreenViewPageWrapper() {
return (

View File

@ -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<PopLayoutModeKey, string> = {
mobile_portrait: "모바일 세로",
};
// 컴포넌트 타입별 라벨
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"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<HTMLDivElement>(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 (
<div
key={modeKey}
className={cn(
"relative shrink-0 cursor-pointer rounded-lg border-4 bg-white shadow-xl transition-all",
isActive
? "border-primary ring-2 ring-primary/30"
: "border-gray-300 hover:border-gray-400"
)}
style={{
width: resolution.width * canvasScale,
height: resolution.height * canvasScale,
}}
onClick={() => {
if (!isActive) {
onModeKeyChange(modeKey);
}
}}
>
{/* 모드 라벨 */}
<div
className={cn(
"absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium",
isActive ? "text-primary" : "text-muted-foreground"
)}
>
{modeLabel}
</div>
{/* 활성 표시 배지 */}
{isActive && (
<div className="absolute right-2 top-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-bold text-white shadow-lg">
</div>
)}
{/* 드롭 영역 */}
<CanvasDropZone
modeKey={modeKey}
isActive={isActive}
resolution={resolution}
scale={canvasScale}
cols={cols}
rowHeight={rowHeight}
margin={margin}
sections={sections}
components={components}
sectionPositions={sectionPositions}
componentPositions={modeLayout.componentPositions}
gridLayoutItems={gridLayoutItems}
selectedSectionId={selectedSectionId}
selectedComponentId={selectedComponentId}
onSelectSection={onSelectSection}
onSelectComponent={onSelectComponent}
onDragResizeStop={handleDragResizeStop}
onDropSection={onDropSection}
onDropComponent={onDropComponent}
onUpdateComponentPosition={(compId, pos) => onUpdateComponentPosition(compId, pos, modeKey)}
onDeleteSection={onDeleteSection}
onDeleteComponent={onDeleteComponent}
/>
</div>
);
};
return (
<div className="relative flex h-full flex-col bg-gray-50">
{/* 줌 컨트롤 바 */}
@ -301,36 +156,18 @@ export function PopCanvas({
<span className="text-xs text-muted-foreground">
: {Math.round(canvasScale * 100)}%
</span>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={handleZoomOut}
title="줌 아웃"
>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomOut} title="줌 아웃">
<ZoomOut className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={handleZoomIn}
title="줌 인"
>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomIn} title="줌 인">
<ZoomIn className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={handleZoomFit}
title="맞춤 (100%)"
>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomFit} title="맞춤 (100%)">
<Maximize2 className="h-4 w-4" />
</Button>
</div>
{/* 캔버스 영역 (패닝 가능) */}
{/* 캔버스 영역 */}
<div
ref={containerRef}
className={cn(
@ -344,21 +181,39 @@ export function PopCanvas({
onMouseLeave={handlePanEnd}
onWheel={handleWheel}
>
{/* 스크롤 가능한 큰 영역 - 빈 공간 클릭 시 패닝 가능 */}
<div
className="canvas-scroll-area flex items-center justify-center gap-16"
style={{
// 캔버스 주변에 충분한 여백 확보 (상하좌우 500px씩)
padding: "500px",
minWidth: "fit-content",
minHeight: "fit-content",
}}
style={{ padding: "500px", minWidth: "fit-content", minHeight: "fit-content" }}
>
{/* 가로 모드 캔버스 */}
{renderDeviceFrame(landscapeModeKey)}
{/* 가로 모드 */}
<DeviceFrame
modeKey={landscapeModeKey}
isActive={landscapeModeKey === activeModeKey}
scale={canvasScale}
canvasGrid={canvasGrid}
layout={layout}
selectedComponentId={selectedComponentId}
onModeKeyChange={onModeKeyChange}
onSelectComponent={onSelectComponent}
onUpdateComponentPosition={onUpdateComponentPosition}
onDropComponent={onDropComponent}
onDeleteComponent={onDeleteComponent}
/>
{/* 세로 모드 캔버스 */}
{renderDeviceFrame(portraitModeKey)}
{/* 세로 모드 */}
<DeviceFrame
modeKey={portraitModeKey}
isActive={portraitModeKey === activeModeKey}
scale={canvasScale}
canvasGrid={canvasGrid}
layout={layout}
selectedComponentId={selectedComponentId}
onModeKeyChange={onModeKeyChange}
onSelectComponent={onSelectComponent}
onUpdateComponentPosition={onUpdateComponentPosition}
onDropComponent={onDropComponent}
onDeleteComponent={onDeleteComponent}
/>
</div>
</div>
</div>
@ -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<string, GridPosition>;
componentPositions: Record<string, GridPosition>;
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<HTMLDivElement>(null);
const dropRef = useRef<HTMLDivElement>(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 (
<div
ref={dropRef}
className={cn(
"h-full w-full overflow-hidden rounded-md bg-gray-100 p-1 transition-colors",
isOver && canDrop && "bg-primary/10 ring-2 ring-primary ring-inset"
)}
style={{
// 내부 컨텐츠를 스케일 조정
transform: `scale(${scale})`,
transformOrigin: "top left",
width: resolution.width,
height: resolution.height,
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
onSelectSection(null);
onSelectComponent(null);
}
}}
>
{sectionIds.length > 0 ? (
<GridLayout
className="layout"
layout={gridLayoutItems}
cols={cols}
rowHeight={rowHeight}
width={resolution.width - 8}
margin={margin}
containerPadding={[0, 0]}
onDragStop={onDragResizeStop}
onResizeStop={onDragResizeStop}
isDraggable={isActive}
isResizable={isActive}
compactType={null}
preventCollision={false}
useCSSTransforms={true}
draggableHandle=".section-drag-handle"
>
{sectionIds.map((sectionId) => {
const sectionDef = sections[sectionId];
if (!sectionDef) return null;
<div className="relative shrink-0">
{/* 모드 라벨 */}
<div
className={cn(
"absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium",
isActive ? "text-primary" : "text-muted-foreground"
)}
>
{modeLabel}
</div>
return (
<div
key={sectionId}
className={cn(
"group relative flex flex-col overflow-hidden rounded-lg border-2 bg-white transition-all",
selectedSectionId === sectionId
? "border-primary ring-2 ring-primary/30"
: "border-gray-200 hover:border-gray-400"
)}
onClick={(e) => {
e.stopPropagation();
onSelectSection(sectionId);
}}
>
{/* 섹션 헤더 */}
<div
className={cn(
"section-drag-handle flex h-7 shrink-0 cursor-move items-center justify-between border-b px-2",
selectedSectionId === sectionId ? "bg-primary/10" : "bg-gray-50"
)}
>
<div className="flex items-center gap-1">
<GripVertical className="h-3 w-3 text-gray-400" />
<span className="text-xs font-medium text-gray-600">
{sectionDef.label || "섹션"}
</span>
</div>
{selectedSectionId === sectionId && isActive && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive hover:bg-destructive/10"
onClick={(e) => {
e.stopPropagation();
onDeleteSection(sectionId);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
{/* 섹션 내부 - 컴포넌트들 */}
<div className="relative flex-1">
<SectionGridV2
sectionId={sectionId}
sectionDef={sectionDef}
components={components}
componentPositions={componentPositions}
isActive={isActive}
selectedComponentId={selectedComponentId}
onSelectComponent={onSelectComponent}
onDropComponent={onDropComponent}
onUpdateComponentPosition={onUpdateComponentPosition}
onDeleteComponent={onDeleteComponent}
/>
</div>
</div>
);
})}
</GridLayout>
) : (
{/* 디바이스 프레임 */}
<div
ref={dropRef}
className={cn(
"relative cursor-pointer overflow-hidden rounded-xl bg-white shadow-lg transition-all",
isActive ? "ring-2 ring-primary ring-offset-2" : "ring-1 ring-gray-200 hover:ring-gray-300",
isOver && canDrop && "ring-2 ring-primary bg-primary/5"
)}
style={{
width: resolution.width * scale,
height: resolution.height * scale,
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
if (!isActive) onModeKeyChange(modeKey);
else onSelectComponent(null);
}
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* CSS Grid (뷰어와 동일) */}
<div
className={cn(
"flex h-full items-center justify-center rounded-lg border-2 border-dashed text-sm",
isOver && canDrop
? "border-primary bg-primary/5 text-primary"
: "border-gray-300 text-gray-400"
)}
ref={gridRef}
className="origin-top-left"
style={{
transform: `scale(${scale})`,
width: resolution.width,
height: resolution.height,
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
gap: `${gap}px`,
padding: `${gap}px`,
}}
>
{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 (
<div
key={componentId}
className={cn(
"group relative flex cursor-move items-center justify-center overflow-hidden rounded-lg border-2 bg-white transition-all",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200 hover:border-gray-300",
(isDragging || isResizing) && "opacity-80 shadow-xl z-50"
)}
style={{
gridColumn: `${pos.col} / span ${pos.colSpan}`,
gridRow: `${pos.row} / span ${pos.rowSpan}`,
}}
onClick={(e) => {
e.stopPropagation();
if (!isActive) onModeKeyChange(modeKey);
onSelectComponent(componentId);
}}
onMouseDown={(e) => handleDragStart(e, componentId)}
>
{/* 컴포넌트 라벨 */}
<span className="text-xs text-gray-500 select-none">
{compDef.label || COMPONENT_TYPE_LABELS[compDef.type]}
</span>
{/* 리사이즈 핸들 */}
{isActive && isSelected && (
<>
<div className="absolute -right-1 -bottom-1 h-3 w-3 cursor-se-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "se")} />
<div className="absolute -left-1 -bottom-1 h-3 w-3 cursor-sw-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "sw")} />
<div className="absolute -right-1 -top-1 h-3 w-3 cursor-ne-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "ne")} />
<div className="absolute -left-1 -top-1 h-3 w-3 cursor-nw-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "nw")} />
<div className="absolute right-0 top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-e-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "e")} />
<div className="absolute left-0 top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-w-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "w")} />
<div className="absolute bottom-0 left-1/2 h-1.5 w-6 -translate-x-1/2 cursor-s-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "s")} />
<div className="absolute top-0 left-1/2 h-1.5 w-6 -translate-x-1/2 cursor-n-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "n")} />
</>
)}
</div>
);
})
) : (
<div
className={cn(
"col-span-full row-span-full flex items-center justify-center text-sm",
isOver && canDrop ? "text-primary" : "text-gray-400"
)}
>
{isOver && canDrop
? "여기에 컴포넌트를 놓으세요"
: isActive
? "왼쪽 패널에서 컴포넌트를 드래그하세요"
: "클릭하여 편집"}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -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<PopLayoutDataV2>(createEmptyPopLayoutV2());
const [layout, setLayout] = useState<PopLayoutDataV3>(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<DeviceType>("tablet");
// 활성 모드 키 (가로/세로 중 현재 포커스된 캔버스)
// 기본값: 태블릿 가로
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
// ========================================
// 선택 상태
// 선택 상태 (v3: 섹션 없음, 컴포넌트만)
// ========================================
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(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<PopSectionDefinition>) => {
const handleUpdateComponentDefinition = useCallback(
(componentId: string, updates: Partial<PopComponentDefinition>) => {
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({
)}
</div>
{/* 중앙: 디바이스 전환 (가로/세로 전환 버튼 제거 - 캔버스 2개 동시 표시) */}
{/* 중앙: 디바이스 전환 */}
<div className="flex items-center gap-2">
<Tabs
value={activeDevice}
@ -406,9 +307,9 @@ export default function PopDesigner({
</div>
</div>
{/* 메인 영역: 리사이즈 가능한 패널 */}
{/* 메인 영역 */}
<ResizablePanelGroup direction="horizontal" className="flex-1">
{/* 왼쪽: 패널 (컴포넌트/편집 탭) */}
{/* 왼쪽: 패널 */}
<ResizablePanel
defaultSize={20}
minSize={15}
@ -418,31 +319,26 @@ export default function PopDesigner({
<PopPanel
layout={layout}
activeModeKey={activeModeKey}
selectedSectionId={selectedSectionId}
selectedSection={selectedSection}
onUpdateSectionDefinition={handleUpdateSectionDefinition}
onDeleteSection={handleDeleteSection}
selectedComponentId={selectedComponentId}
selectedComponent={selectedComponent}
onUpdateComponentDefinition={handleUpdateComponentDefinition}
onDeleteComponent={handleDeleteComponent}
activeDevice={activeDevice}
/>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 캔버스 (가로+세로 2개 동시 표시) */}
{/* 오른쪽: 캔버스 */}
<ResizablePanel defaultSize={80}>
<PopCanvas
layout={layout}
activeDevice={activeDevice}
activeModeKey={activeModeKey}
onModeKeyChange={handleModeKeyChange}
selectedSectionId={selectedSectionId}
selectedComponentId={selectedComponentId}
onSelectSection={setSelectedSectionId}
onSelectComponent={setSelectedComponentId}
onUpdateSectionPosition={handleUpdateSectionPosition}
onUpdateComponentPosition={handleUpdateComponentPosition}
onDeleteSection={handleDeleteSection}
onDropSection={handleDropSection}
onDropComponent={handleDropComponent}
onDeleteComponent={handleDeleteComponent}
/>

View File

@ -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<PopComponentData>) => void;
onDeleteComponent: (sectionId: string, componentId: string) => void;
}
export function SectionGrid({
section,
isActive,
selectedComponentId,
onSelectComponent,
onDropComponent,
onUpdateComponent,
onDeleteComponent,
}: SectionGridProps) {
const containerRef = useRef<HTMLDivElement>(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<string>();
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 (
<div
ref={(node) => {
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 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center z-10">
<span className={cn(
"rounded bg-white/80 px-2 py-1 text-xs",
isOver && canDrop ? "text-primary font-medium" : "text-gray-400"
)}>
{isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"}
</span>
</div>
)}
{/* 컴포넌트 GridLayout */}
{components.length > 0 && availableWidth > 0 && cols > 0 && (
<GridLayout
className="layout relative z-10"
layout={gridLayoutItems}
cols={cols}
rowHeight={cellHeight}
width={availableWidth}
margin={[gap, gap]}
containerPadding={[0, 0]}
onDragStop={handleDragStop}
onResizeStop={handleResizeStop}
isDraggable={isActive}
isResizable={isActive}
compactType={null}
preventCollision={false}
useCSSTransforms={true}
draggableHandle=".component-drag-handle"
resizeHandles={["se", "e", "s"]}
>
{components.map((comp) => (
<div
key={comp.id}
className={cn(
"group relative flex flex-col rounded border bg-white text-xs transition-all overflow-hidden",
selectedComponentId === comp.id
? "border-primary ring-2 ring-primary/30"
: "border-gray-200 hover:border-gray-400"
)}
onClick={(e) => {
e.stopPropagation();
onSelectComponent(comp.id);
}}
onMouseDown={(e) => e.stopPropagation()}
>
{/* 드래그 핸들 바 */}
<div className="component-drag-handle flex h-5 cursor-move items-center justify-center border-b bg-gray-50 opacity-60 hover:opacity-100 transition-opacity">
<Move className="h-3 w-3 text-gray-400" />
</div>
{/* 컴포넌트 내용 */}
<div className="flex flex-1 items-center justify-center p-1">
<ComponentPreview component={comp} />
</div>
{/* 삭제 버튼 */}
{selectedComponentId === comp.id && (
<Button
variant="ghost"
size="icon"
className="absolute -right-2 -top-2 h-5 w-5 rounded-full bg-white shadow text-destructive hover:bg-destructive/10 z-10"
onClick={(e) => {
e.stopPropagation();
onDeleteComponent(section.id, comp.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
))}
</GridLayout>
)}
</div>
);
}
// 컴포넌트 미리보기
interface ComponentPreviewProps {
component: PopComponentData;
}
function ComponentPreview({ component }: ComponentPreviewProps) {
const { type, label } = component;
// 타입별 미리보기 렌더링
const renderPreview = () => {
switch (type) {
case "pop-field":
return (
<div className="flex w-full flex-col gap-1">
<span className="text-[10px] text-gray-500">{label || "필드"}</span>
<div className="h-6 w-full rounded border border-gray-200 bg-gray-50" />
</div>
);
case "pop-button":
return (
<div className="flex h-8 w-full items-center justify-center rounded bg-primary/10 text-primary font-medium">
{label || "버튼"}
</div>
);
case "pop-list":
return (
<div className="flex w-full flex-col gap-0.5">
<span className="text-[10px] text-gray-500">{label || "리스트"}</span>
<div className="h-3 w-full rounded bg-gray-100" />
<div className="h-3 w-3/4 rounded bg-gray-100" />
<div className="h-3 w-full rounded bg-gray-100" />
</div>
);
case "pop-indicator":
return (
<div className="flex w-full flex-col items-center gap-1">
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
<span className="text-lg font-bold text-primary">0</span>
</div>
);
case "pop-scanner":
return (
<div className="flex w-full flex-col items-center gap-1">
<div className="h-8 w-8 rounded border-2 border-dashed border-gray-300 flex items-center justify-center">
<span className="text-[8px] text-gray-400">QR</span>
</div>
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
</div>
);
case "pop-numpad":
return (
<div className="grid grid-cols-3 gap-0.5 w-full">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
<div
key={key}
className="flex h-4 items-center justify-center rounded bg-gray-100 text-[8px]"
>
{key}
</div>
))}
</div>
);
default:
return <span className="text-gray-500">{label || type}</span>;
}
};
return <div className="w-full overflow-hidden">{renderPreview()}</div>;
}

View File

@ -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<string, PopComponentDefinition>;
componentPositions: Record<string, GridPosition>;
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<HTMLDivElement>(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<string>();
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 (
<div
ref={(node) => {
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 && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<span
className={cn(
"rounded bg-white/80 px-2 py-1 text-xs",
isOver && canDrop ? "font-medium text-primary" : "text-gray-400"
)}
>
{isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"}
</span>
</div>
)}
{/* 컴포넌트 GridLayout */}
{componentIds.length > 0 && availableWidth > 0 && cols > 0 && (
<GridLayout
className="layout relative z-10"
layout={gridLayoutItems}
cols={cols}
rowHeight={cellHeight}
width={availableWidth}
margin={[gap, gap]}
containerPadding={[0, 0]}
onDragStop={handleDragStop}
onResizeStop={handleResizeStop}
isDraggable={isActive}
isResizable={isActive}
compactType={null}
preventCollision={false}
useCSSTransforms={true}
draggableHandle=".component-drag-handle"
resizeHandles={["se", "e", "s"]}
>
{componentIds.map((compId) => {
const compDef = components[compId];
if (!compDef) return null;
return (
<div
key={compId}
className={cn(
"group relative flex flex-col overflow-hidden rounded border bg-white text-xs transition-all",
selectedComponentId === compId
? "border-primary ring-2 ring-primary/30"
: "border-gray-200 hover:border-gray-400"
)}
onClick={(e) => {
e.stopPropagation();
onSelectComponent(compId);
}}
onMouseDown={(e) => e.stopPropagation()}
>
{/* 드래그 핸들 바 */}
<div className="component-drag-handle flex h-5 cursor-move items-center justify-center border-b bg-gray-50 opacity-60 transition-opacity hover:opacity-100">
<Move className="h-3 w-3 text-gray-400" />
</div>
{/* 컴포넌트 내용 */}
<div className="flex flex-1 items-center justify-center p-1">
<ComponentPreviewV2 component={compDef} />
</div>
{/* 삭제 버튼 */}
{selectedComponentId === compId && isActive && (
<Button
variant="ghost"
size="icon"
className="absolute -right-2 -top-2 z-10 h-5 w-5 rounded-full bg-white text-destructive shadow hover:bg-destructive/10"
onClick={(e) => {
e.stopPropagation();
onDeleteComponent(sectionId, compId);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
);
})}
</GridLayout>
)}
</div>
);
}
// ========================================
// 컴포넌트 미리보기
// ========================================
interface ComponentPreviewV2Props {
component: PopComponentDefinition;
}
function ComponentPreviewV2({ component }: ComponentPreviewV2Props) {
const { type, label } = component;
const renderPreview = () => {
switch (type) {
case "pop-field":
return (
<div className="flex w-full flex-col gap-1">
<span className="text-[10px] text-gray-500">{label || "필드"}</span>
<div className="h-6 w-full rounded border border-gray-200 bg-gray-50" />
</div>
);
case "pop-button":
return (
<div className="flex h-8 w-full items-center justify-center rounded bg-primary/10 font-medium text-primary">
{label || "버튼"}
</div>
);
case "pop-list":
return (
<div className="flex w-full flex-col gap-0.5">
<span className="text-[10px] text-gray-500">{label || "리스트"}</span>
<div className="h-3 w-full rounded bg-gray-100" />
<div className="h-3 w-3/4 rounded bg-gray-100" />
<div className="h-3 w-full rounded bg-gray-100" />
</div>
);
case "pop-indicator":
return (
<div className="flex w-full flex-col items-center gap-1">
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
<span className="text-lg font-bold text-primary">0</span>
</div>
);
case "pop-scanner":
return (
<div className="flex w-full flex-col items-center gap-1">
<div className="flex h-8 w-8 items-center justify-center rounded border-2 border-dashed border-gray-300">
<span className="text-[8px] text-gray-400">QR</span>
</div>
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
</div>
);
case "pop-numpad":
return (
<div className="grid w-full grid-cols-3 gap-0.5">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
<div
key={key}
className="flex h-4 items-center justify-center rounded bg-gray-100 text-[8px]"
>
{key}
</div>
))}
</div>
);
default:
return <span className="text-gray-500">{label || type}</span>;
}
};
return <div className="w-full overflow-hidden">{renderPreview()}</div>;
}

View File

@ -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<PopComponentConfig>) => void;
/** 컴포넌트 라벨 변경 시 호출 */
onLabelChange?: (label: string) => void;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 편집 패널
//
// 역할:
// - 선택된 컴포넌트의 설정을 편집
// - 3개 탭: 기본 설정 / 데이터 바인딩 / 데이터 연결
//
// TODO:
// - 타입별 상세 설정 UI 구현
// - 데이터 바인딩 UI 구현
// - 데이터 플로우 UI 구현
// ========================================
export function ComponentEditorPanel({
component,
onConfigChange,
onLabelChange,
className,
}: ComponentEditorPanelProps) {
// 컴포넌트가 선택되지 않은 경우
if (!component) {
return (
<div className={cn("flex h-full flex-col", className)}>
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium"> </h3>
</div>
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
</div>
</div>
);
}
return (
<div className={cn("flex h-full flex-col", className)}>
{/* 헤더 */}
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium">
{component.label || getComponentTypeLabel(component.type)}
</h3>
<p className="text-xs text-muted-foreground">{component.type}</p>
</div>
{/* 탭 컨텐츠 */}
<Tabs defaultValue="settings" className="flex-1">
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
<TabsTrigger value="settings" className="gap-1 text-xs">
<Settings className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="data" className="gap-1 text-xs">
<Database className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="flow" className="gap-1 text-xs">
<Link2 className="h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 기본 설정 탭 */}
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
<ComponentSettingsForm
component={component}
onConfigChange={onConfigChange}
onLabelChange={onLabelChange}
/>
</TabsContent>
{/* 데이터 바인딩 탭 (뼈대) */}
<TabsContent value="data" className="flex-1 overflow-auto p-4">
<DataBindingPlaceholder />
</TabsContent>
{/* 데이터 연결 탭 (뼈대) */}
<TabsContent value="flow" className="flex-1 overflow-auto p-4">
<DataFlowPlaceholder />
</TabsContent>
</Tabs>
</div>
);
}
// ========================================
// 컴포넌트 설정 폼
// ========================================
interface ComponentSettingsFormProps {
component: PopComponentDefinition;
onConfigChange?: (config: Partial<PopComponentConfig>) => void;
onLabelChange?: (label: string) => void;
}
function ComponentSettingsForm({
component,
onConfigChange,
onLabelChange,
}: ComponentSettingsFormProps) {
return (
<div className="space-y-4">
{/* 라벨 입력 */}
<div className="space-y-1.5">
<label className="text-xs font-medium"></label>
<input
type="text"
className="h-8 w-full rounded border border-input bg-background px-2 text-sm"
value={component.label || ""}
onChange={(e) => onLabelChange?.(e.target.value)}
placeholder="컴포넌트 라벨"
/>
</div>
{/* 타입별 설정 (TODO: 상세 구현) */}
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
<p className="text-center text-xs text-muted-foreground">
{getComponentTypeLabel(component.type)}
</p>
<p className="mt-1 text-center text-xs text-muted-foreground">
( )
</p>
</div>
</div>
);
}
// ========================================
// 데이터 바인딩 플레이스홀더 (뼈대)
// ========================================
function DataBindingPlaceholder() {
return (
<div className="space-y-4">
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
<div className="flex flex-col items-center gap-2">
<Database className="h-8 w-8 text-gray-400" />
<p className="text-center text-xs text-muted-foreground">
</p>
<p className="text-center text-xs text-muted-foreground">
</p>
<p className="mt-2 text-center text-xs text-gray-400">
( )
</p>
</div>
</div>
</div>
);
}
// ========================================
// 데이터 플로우 플레이스홀더 (뼈대)
// ========================================
function DataFlowPlaceholder() {
return (
<div className="space-y-4">
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
<div className="flex flex-col items-center gap-2">
<Link2 className="h-8 w-8 text-gray-400" />
<p className="text-center text-xs text-muted-foreground">
</p>
<p className="text-center text-xs text-muted-foreground">
/ /
</p>
<p className="mt-2 text-center text-xs text-gray-400">
( )
</p>
</div>
</div>
</div>
);
}
// ========================================
// 헬퍼 함수
// ========================================
function getComponentTypeLabel(type: string): string {
const labels: Record<string, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "넘패드",
};
return labels[type] || type;
}
export default ComponentEditorPanel;

View File

@ -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<PopSectionDefinition>) => void;
onDeleteSection: (id: string) => void;
selectedComponentId: string | null;
selectedComponent: PopComponentDefinition | null;
onUpdateComponentDefinition: (id: string, updates: Partial<PopComponentDefinition>) => 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<string>("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({
</p>
</div>
{/* 섹션 드래그 아이템 */}
<div>
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
</h4>
<DraggableSectionItem />
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
{/* 컴포넌트 팔레트 */}
<div>
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
@ -197,7 +173,7 @@ export function PopPanel({
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
</p>
</div>
</div>
@ -205,19 +181,19 @@ export function PopPanel({
{/* 편집 탭 */}
<TabsContent value="edit" className="flex-1 overflow-auto p-2">
{selectedSection && selectedSectionPosition ? (
<SectionEditorV2
section={selectedSection}
position={selectedSectionPosition}
{selectedComponent && selectedComponentPosition ? (
<ComponentEditorV3
component={selectedComponent}
position={selectedComponentPosition}
activeModeKey={activeModeKey}
onUpdateDefinition={(updates) =>
onUpdateSectionDefinition(selectedSection.id, updates)
onUpdateComponentDefinition(selectedComponent.id, updates)
}
onDelete={() => onDeleteSection(selectedSection.id)}
onDelete={() => onDeleteComponent(selectedComponent.id)}
/>
) : (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
</div>
)}
</TabsContent>
@ -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 (
<div
ref={drag}
className={cn(
"flex cursor-grab items-center gap-3 rounded-lg border p-3 transition-all",
"hover:bg-accent hover:text-accent-foreground",
isDragging && "opacity-50 ring-2 ring-primary"
)}
>
<GripVertical className="h-4 w-4 text-gray-400" />
<LayoutGrid className="h-4 w-4" />
<div className="flex-1">
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground"> </p>
</div>
</div>
);
}
// ========================================
// 드래그 가능한 컴포넌트 아이템
// ========================================
@ -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<PopSectionDefinition>) => void;
onUpdateDefinition: (updates: Partial<PopComponentDefinition>) => 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<PopComponentType, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
};
return (
<div className="space-y-4">
{/* 섹션 기본 정보 */}
{/* 컴포넌트 기본 정보 */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium"> </span>
<div>
<span className="text-sm font-medium">{typeLabels[component.type]}</span>
<p className="text-[10px] text-muted-foreground">{component.id}</p>
</div>
<Button
variant="ghost"
size="icon"
@ -350,26 +308,23 @@ function SectionEditorV2({
{/* 라벨 */}
<div className="space-y-2">
<Label className="text-xs"> ()</Label>
<Label className="text-xs"></Label>
<Input
value={section.label || ""}
value={component.label || ""}
onChange={(e) => onUpdateDefinition({ label: e.target.value })}
placeholder="섹션 이름"
placeholder="컴포넌트 이름"
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
4
</p>
</div>
{/* 현재 모드 위치 (읽기 전용 - 드래그로 조정) */}
<Collapsible open={isGridOpen} onOpenChange={setIsGridOpen}>
{/* 현재 모드 위치 (읽기 전용) */}
<Collapsible open={isPositionOpen} onOpenChange={setIsPositionOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-sm font-medium">
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
isGridOpen && "rotate-180"
isPositionOpen && "rotate-180"
)}
/>
</CollapsibleTrigger>
@ -402,83 +357,12 @@ function SectionEditorV2({
</CollapsibleContent>
</Collapsible>
{/* 내부 그리드 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> ()</h4>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={String(section.innerGrid.columns)}
onValueChange={(v) =>
onUpdateDefinition({
innerGrid: { ...section.innerGrid, columns: parseInt(v) },
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="6">6</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={String(section.innerGrid.rows)}
onValueChange={(v) =>
onUpdateDefinition({
innerGrid: { ...section.innerGrid, rows: parseInt(v) },
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="5">5</SelectItem>
<SelectItem value="6">6</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
4
{/* TODO: 컴포넌트별 설정 (config) */}
<div className="rounded-lg border border-dashed p-3">
<p className="text-xs text-muted-foreground text-center">
</p>
</div>
{/* 컴포넌트 목록 */}
<div className="space-y-2">
<h4 className="text-sm font-medium">
({section.componentIds.length})
</h4>
{section.componentIds.length > 0 ? (
<div className="space-y-1">
{section.componentIds.map((compId) => (
<div
key={compId}
className="rounded border bg-muted/50 px-2 py-1 text-xs"
>
{compId}
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
</p>
)}
</div>
</div>
);
}

View File

@ -1,2 +1,3 @@
// POP 디자이너 패널 export
export { PopPanel } from "./PopPanel";
export { ComponentEditorPanel } from "./ComponentEditorPanel";

View File

@ -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<HTMLDivElement, ComponentRendererProps>(
function ComponentRenderer(
{
component,
position,
isDesignMode = false,
isSelected = false,
onClick,
className,
},
ref
) {
const { type, label, config } = component;
return (
<div
ref={ref}
className={cn(
// 기본 스타일
"relative flex flex-col overflow-hidden rounded border bg-white transition-all",
// 디자인 모드 스타일
isDesignMode && "cursor-pointer",
// 선택 상태 스타일
isSelected
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200 hover:border-gray-300",
className
)}
style={{
// 관리자가 설정한 GridPosition을 그대로 반영
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
}}
onClick={(e) => {
e.stopPropagation();
onClick?.();
}}
>
{/* 컴포넌트 타입별 미리보기 렌더링 */}
<ComponentPreview type={type} label={label} config={config} />
</div>
);
}
);
// ========================================
// 컴포넌트 타입별 미리보기
// ========================================
interface ComponentPreviewProps {
type: PopComponentType;
label?: string;
config?: any;
}
function ComponentPreview({ type, label, config }: ComponentPreviewProps) {
switch (type) {
case "pop-field":
return <FieldPreview label={label} config={config} />;
case "pop-button":
return <ButtonPreview label={label} config={config} />;
case "pop-list":
return <ListPreview label={label} config={config} />;
case "pop-indicator":
return <IndicatorPreview label={label} config={config} />;
case "pop-scanner":
return <ScannerPreview label={label} config={config} />;
case "pop-numpad":
return <NumpadPreview label={label} config={config} />;
default:
return (
<div className="flex h-full items-center justify-center p-2 text-xs text-gray-400">
{label || type}
</div>
);
}
}
// ========================================
// 개별 컴포넌트 미리보기
// ========================================
function FieldPreview({ label, config }: { label?: string; config?: any }) {
const fieldType = config?.fieldType || "text";
const placeholder = config?.placeholder || "입력하세요";
const required = config?.required || false;
return (
<div className="flex h-full w-full flex-col gap-1 p-2">
{/* 라벨 */}
<span className="text-xs font-medium text-gray-600">
{label || "필드"}
{required && <span className="ml-1 text-destructive">*</span>}
</span>
{/* 입력 필드 미리보기 */}
<div className="flex h-8 w-full items-center rounded border border-gray-200 bg-gray-50 px-2 text-xs text-gray-400">
{placeholder}
</div>
</div>
);
}
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 (
<div className="flex h-full w-full items-center justify-center p-2">
<div
className={cn(
"flex h-10 w-full items-center justify-center rounded font-medium",
variant
)}
>
{label || "버튼"}
</div>
</div>
);
}
function ListPreview({ label, config }: { label?: string; config?: any }) {
const itemCount = config?.itemsPerPage || 5;
return (
<div className="flex h-full w-full flex-col gap-1 p-2">
{/* 라벨 */}
<span className="text-xs font-medium text-gray-600">{label || "리스트"}</span>
{/* 리스트 아이템 미리보기 */}
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden">
{Array.from({ length: Math.min(3, itemCount) }).map((_, i) => (
<div key={i} className="h-4 rounded bg-gray-100" />
))}
{itemCount > 3 && (
<div className="text-center text-[10px] text-gray-400">
+{itemCount - 3} more
</div>
)}
</div>
</div>
);
}
function IndicatorPreview({ label, config }: { label?: string; config?: any }) {
const indicatorType = config?.indicatorType || "kpi";
const unit = config?.unit || "";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
{/* 라벨 */}
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
{/* 값 미리보기 */}
<span className="text-xl font-bold text-primary">
0{unit && <span className="text-sm font-normal text-gray-500">{unit}</span>}
</span>
</div>
);
}
function ScannerPreview({ label, config }: { label?: string; config?: any }) {
const scannerType = config?.scannerType || "camera";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
{/* QR 아이콘 */}
<div className="flex h-10 w-10 items-center justify-center rounded border-2 border-dashed border-gray-300">
<span className="text-xs text-gray-400">QR</span>
</div>
{/* 라벨 */}
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
</div>
);
}
function NumpadPreview({ label, config }: { label?: string; config?: any }) {
const keys = [1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"];
return (
<div className="flex h-full w-full flex-col gap-1 p-2">
{/* 라벨 */}
{label && (
<span className="text-[10px] text-gray-500">{label}</span>
)}
{/* 넘패드 미리보기 */}
<div className="grid flex-1 grid-cols-3 gap-0.5">
{keys.map((key) => (
<div
key={key}
className="flex items-center justify-center rounded bg-gray-100 text-[8px]"
>
{key}
</div>
))}
</div>
</div>
);
}
export default ComponentRenderer;

View File

@ -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<string, string> = {
"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 (
<div
className={cn(
"flex h-full w-full items-center justify-center bg-gray-50",
className
)}
style={style}
onClick={onBackgroundClick}
>
<div className="text-center text-sm text-gray-400">
<p> </p>
{isDesignMode && <p className="mt-1"> </p>}
</div>
</div>
);
}
// 컴포넌트 ID 목록
const componentIds = Object.keys(modeLayout.componentPositions);
return (
<div
className={cn("relative w-full h-full bg-white", className)}
style={{
// CSS Grid: 디자이너와 동일
display: "grid",
gridTemplateColumns: `repeat(${canvasGrid.columns}, 1fr)`,
gridTemplateRows: `repeat(${canvasGrid.rows || 24}, 1fr)`,
gap: `${canvasGrid.gap}px`,
padding: `${canvasGrid.gap}px`,
...style,
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{/* 컴포넌트들 직접 렌더링 */}
{componentIds.map((componentId) => {
const compDef = components[componentId];
const compPos = modeLayout.componentPositions[componentId];
if (!compDef || !compPos) return null;
return (
<ComponentRenderer
key={componentId}
componentId={componentId}
component={compDef}
position={compPos}
isDesignMode={isDesignMode}
isSelected={selectedComponentId === componentId}
onComponentClick={() => onComponentClick?.(componentId)}
/>
);
})}
</div>
);
}
// ========================================
// 컴포넌트 렌더러
// ========================================
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 (
<div
className={cn(
"relative flex flex-col overflow-hidden rounded-lg border-2 bg-white transition-all",
isSelected
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200",
isDesignMode && "cursor-pointer hover:border-gray-300"
)}
style={{
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
}}
onClick={(e) => {
e.stopPropagation();
onComponentClick?.();
}}
>
{/* 컴포넌트 라벨 (디자인 모드에서만) */}
{isDesignMode && (
<div
className={cn(
"flex h-5 shrink-0 items-center border-b px-2",
isSelected ? "bg-primary/10" : "bg-gray-50"
)}
>
<span className="text-[10px] font-medium text-gray-600">
{component.label || typeLabel}
</span>
</div>
)}
{/* 컴포넌트 내용 */}
<div className="flex flex-1 items-center justify-center p-2">
{renderComponentContent(component, isDesignMode)}
</div>
</div>
);
}
// ========================================
// 컴포넌트별 렌더링
// ========================================
function renderComponentContent(
component: PopComponentDefinition,
isDesignMode: boolean
): React.ReactNode {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// 디자인 모드에서는 플레이스홀더 표시
if (isDesignMode) {
return (
<div className="text-xs text-gray-400 text-center">
{typeLabel}
</div>
);
}
// 뷰어 모드: 실제 컴포넌트 렌더링
switch (component.type) {
case "pop-field":
return (
<input
type="text"
placeholder={component.label || "입력하세요"}
className="w-full h-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
);
case "pop-button":
return (
<button className="w-full h-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-md hover:bg-primary/90 transition-colors">
{component.label || "버튼"}
</button>
);
case "pop-list":
return (
<div className="w-full h-full overflow-auto p-2">
<div className="text-xs text-gray-500 text-center">
( )
</div>
</div>
);
case "pop-indicator":
return (
<div className="text-center">
<div className="text-2xl font-bold text-primary">0</div>
<div className="text-xs text-gray-500">{component.label || "지표"}</div>
</div>
);
case "pop-scanner":
return (
<div className="text-center text-gray-500">
<div className="text-xs"></div>
<div className="text-[10px]"> </div>
</div>
);
case "pop-numpad":
return (
<div className="grid grid-cols-3 gap-1 p-1 w-full">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
<button
key={key}
className="aspect-square text-xs font-medium bg-gray-100 rounded hover:bg-gray-200"
>
{key}
</button>
))}
</div>
);
default:
return (
<div className="text-xs text-gray-400">
{typeLabel}
</div>
);
}
}
// ========================================
// 헬퍼 함수들 (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<string, GridPosition> = {};
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;

View File

@ -0,0 +1,11 @@
// POP 레이아웃 렌더러 모듈 (v3)
// 디자이너와 뷰어에서 동일한 렌더링을 보장하기 위한 공용 렌더러
// 섹션 제거됨, 컴포넌트 직접 배치
export { PopLayoutRenderer, default } from "./PopLayoutRenderer";
export {
hasModeLayout,
hasBaseLayout,
autoConvertLayout,
getEffectiveModeLayout,
} from "./PopLayoutRenderer";

File diff suppressed because it is too large Load Diff

View File

@ -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({
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onRemoveScreenFromGroup(screen, group.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
<MoveRight className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onDeleteScreen(screen)}
>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -389,10 +398,14 @@ export function PopCategoryTree({
icon: "",
});
// 삭제 다이얼로그 상태
// 그룹 삭제 다이얼로그 상태
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingGroup, setDeletingGroup] = useState<PopScreenGroup | null>(null);
// 화면 삭제 다이얼로그 상태
const [isScreenDeleteDialogOpen, setIsScreenDeleteDialogOpen] = useState(false);
const [deletingScreen, setDeletingScreen] = useState<ScreenDefinition | null>(null);
// 이동 모달 상태
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
const [movingScreen, setMovingScreen] = useState<ScreenDefinition | null>(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({
<MoveRight className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDeleteScreen(screen)}
>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -1092,6 +1145,31 @@ export function PopCategoryTree({
</DialogFooter>
</DialogContent>
</Dialog>
{/* 화면 삭제 확인 다이얼로그 */}
<AlertDialog open={isScreenDeleteDialogOpen} onOpenChange={setIsScreenDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{deletingScreen?.screenName}" ?
<br />
<span className="text-muted-foreground text-xs">
, .
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteScreen}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -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<ResponsiveMode>({
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<boolean>(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<boolean | undefined>(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<DeviceType | undefined>(initialDeviceOverride);
const [orientationOverride, setOrientationOverride] = useState<boolean | undefined>(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,
};
}