diff --git a/POPUPDATE.md b/POPUPDATE.md new file mode 100644 index 00000000..836cdb1f --- /dev/null +++ b/POPUPDATE.md @@ -0,0 +1,1041 @@ +# POP 화면 관리 시스템 개발 기록 + +> **AI 에이전트 안내**: 이 문서는 Progressive Disclosure 방식으로 구성되어 있습니다. +> 1. 먼저 [Quick Reference](#quick-reference)에서 필요한 정보 확인 +> 2. 상세 내용이 필요하면 해당 섹션으로 이동 +> 3. 코드가 필요하면 파일 직접 참조 + +--- + +## Quick Reference + +### POP이란? +Point of Production - 현장 작업자용 모바일/태블릿 화면 시스템 + +### 핵심 결정사항 +- **분리 방식**: 레이아웃 기반 구분 (screen_layouts_pop 테이블) +- **식별 방법**: `screen_layouts_pop`에 레코드 존재 여부로 POP 화면 판별 +- **데스크톱 영향**: 없음 (모든 isPop 기본값 = false) + +### 주요 경로 + +| 용도 | 경로 | +|------|------| +| POP 뷰어 URL | `/pop/screens/{screenId}?preview=true&device=tablet` | +| POP 관리 페이지 | `/admin/screenMng/popScreenMngList` | +| POP 레이아웃 API | `/api/screen-management/layout-pop/:screenId` | + +### 파일 찾기 가이드 + +| 작업 | 파일 | +|------|------| +| POP 레이아웃 DB 스키마 | `db/migrations/052_create_screen_layouts_pop.sql` | +| POP API 서비스 로직 | `backend-node/src/services/screenManagementService.ts` (getLayoutPop, saveLayoutPop) | +| POP API 라우트 | `backend-node/src/routes/screenManagementRoutes.ts` | +| 프론트엔드 API 클라이언트 | `frontend/lib/api/screen.ts` (screenApi.getLayoutPop 등) | +| POP 화면 관리 UI | `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` | +| POP 뷰어 페이지 | `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` | +| 미리보기 URL 분기 | `frontend/components/screen/ScreenSettingModal.tsx` (PreviewTab) | +| POP 컴포넌트 설계서 | `docs/pop/components-spec.md` (13개 컴포넌트 상세) | + +--- + +## 섹션 목차 + +| # | 섹션 | 한 줄 요약 | +|---|------|----------| +| 1 | [아키텍처](#1-아키텍처) | 레이아웃 테이블로 POP/데스크톱 분리 | +| 2 | [데이터베이스](#2-데이터베이스) | screen_layouts_pop 테이블 (FK 없음) | +| 3 | [백엔드 API](#3-백엔드-api) | CRUD 4개 엔드포인트 | +| 4 | [프론트엔드 API](#4-프론트엔드-api) | screenApi에 4개 함수 추가 | +| 5 | [관리 페이지](#5-관리-페이지) | POP 화면만 필터링하여 표시 | +| 6 | [뷰어](#6-뷰어) | 모바일/태블릿 프레임 미리보기 | +| 7 | [미리보기](#7-미리보기) | isPop prop으로 URL 분기 | +| 8 | [파일 목록](#8-파일-목록) | 생성 3개, 수정 9개 | +| 9 | [반응형 전략](#9-반응형-전략-신규-결정사항) | Flow 레이아웃 (세로 쌓기) 채택 | +| 10 | [POP 사용자 앱](#10-pop-사용자-앱-구조-신규-결정사항) | 대시보드 카드 → 화면 뷰어 | +| 11 | [POP 디자이너](#11-pop-디자이너-신규-계획) | 좌(탭패널) + 우(팬캔버스), 반응형 편집 | +| 12 | [데이터 구조](#12-pop-레이아웃-데이터-구조-신규) | PopLayoutData, mobileOverride | +| 13 | [컴포넌트 재사용성](#13-컴포넌트-재사용성-분석-신규) | 2개 재사용, 4개 부분, 7개 신규 | + +--- + +## 1. 아키텍처 + +**결정**: Option B (레이아웃 기반 구분) + +``` +screen_definitions (공용) + ├── screen_layouts_v2 (데스크톱) + └── screen_layouts_pop (POP) +``` + +**선택 이유**: 기존 테이블 변경 없음, 데스크톱 영향 없음, 향후 통합 가능 + +--- + +## 2. 데이터베이스 + +**테이블**: `screen_layouts_pop` + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | SERIAL | PK | +| screen_id | INTEGER | 화면 ID (unique) | +| layout_data | JSONB | 컴포넌트 JSON | + +**특이사항**: FK 없음 (soft-delete 지원) + +**파일**: `db/migrations/052_create_screen_layouts_pop.sql` + +--- + +## 3. 백엔드 API + +| Method | Endpoint | 용도 | +|--------|----------|------| +| GET | `/api/screen-management/layout-pop/:screenId` | 조회 | +| POST | `/api/screen-management/layout-pop/:screenId` | 저장 | +| DELETE | `/api/screen-management/layout-pop/:screenId` | 삭제 | +| GET | `/api/screen-management/pop-layout-screen-ids` | ID 목록 | + +**파일**: `backend-node/src/services/screenManagementService.ts` + +--- + +## 4. 프론트엔드 API + +**파일**: `frontend/lib/api/screen.ts` + +```typescript +screenApi.getLayoutPop(screenId) // 조회 +screenApi.saveLayoutPop(screenId, data) // 저장 +screenApi.deleteLayoutPop(screenId) // 삭제 +screenApi.getScreenIdsWithPopLayout() // ID 목록 +``` + +--- + +## 5. 관리 페이지 + +**파일**: `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` + +**핵심 로직**: +```typescript +const popIds = await screenApi.getScreenIdsWithPopLayout(); +const filteredScreens = screens.filter(s => new Set(popIds).has(s.screenId)); +``` + +**기능**: POP 화면만 표시, 새 POP 화면 생성):, 보기/설계 버튼 + +--- + +## 6. 뷰어 + +**파일**: `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` + +**URL 파라미터**: +| 파라미터 | 값 | 설명 | +|---------|---|------| +| preview | true | 툴바 표시 | +| device | mobile/tablet | 디바이스 크기 (기본: tablet) | + +**디바이스 크기**: mobile(375x812), tablet(768x1024) + +--- + +## 7. 미리보기 + +**핵심**: `isPop` prop으로 URL 분기 + +``` +popScreenMngList + └─► ScreenRelationFlow(isPop=true) + └─► ScreenSettingModal + └─► PreviewTab → /pop/screens/{id} + +screenMngList (데스크톱) + └─► ScreenRelationFlow(isPop=false 기본값) + └─► ScreenSettingModal + └─► PreviewTab → /screens/{id} +``` + +**안전성**: isPop 기본값 = false → 데스크톱 영향 없음 + +--- + +## 8. 파일 목록 + +### 생성 (3개) + +| 파일 | 용도 | +|------|------| +| `db/migrations/052_create_screen_layouts_pop.sql` | DB 스키마 | +| `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` | POP 뷰어 | +| `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` | POP 관리 | + +### 수정 (9개) + +| 파일 | 변경 내용 | +|------|----------| +| `backend-node/src/services/screenManagementService.ts` | POP CRUD 함수 | +| `backend-node/src/controllers/screenManagementController.ts` | 컨트롤러 | +| `backend-node/src/routes/screenManagementRoutes.ts` | 라우트 | +| `frontend/lib/api/screen.ts` | API 클라이언트 | +| `frontend/components/screen/CreateScreenModal.tsx` | isPop prop | +| `frontend/components/screen/ScreenSettingModal.tsx` | isPop, PreviewTab | +| `frontend/components/screen/ScreenRelationFlow.tsx` | isPop 전달 | +| `frontend/components/screen/ScreenDesigner.tsx` | isPop, 미리보기 | +| `frontend/components/screen/toolbar/SlimToolbar.tsx` | POP 미리보기 버튼 | + +--- + +## 9. 반응형 전략 (신규 결정사항) + +### 문제점 +- 데스크톱은 절대 좌표(`position: { x, y }`) 사용 +- 모바일 화면 크기가 달라지면 레이아웃 깨짐 + +### 결정: Flow 레이아웃 채택 + +| 항목 | 데스크톱 | POP | +|-----|---------|-----| +| 배치 방식 | `position: { x, y }` | `order: number` (순서) | +| 컨테이너 | 자유 배치 | 중첩 구조 (섹션 > 필드) | +| 렌더러 | 절대 좌표 계산 | Flexbox column (세로 쌓기) | + +### Flow 레이아웃 데이터 구조 +```typescript +{ + layoutMode: "flow", // flow | absolute + components: [ + { + id: "section-1", + type: "pop-section", + order: 0, // 순서로 배치 + children: [...] + } + ] +} +``` + +--- + +## 10. POP 사용자 앱 구조 (신규 결정사항) + +### 데스크톱 vs POP 진입 구조 + +| | 데스크톱 | POP | +|---|---------|-----| +| 메뉴 | 왼쪽 사이드바 | 대시보드 카드 | +| 네비게이션 | 복잡한 트리 구조 | 화면 → 뒤로가기 | +| URL | `/screens/{id}` | `/pop/screens/{id}` | + +### POP 화면 흐름 +``` +/pop/login (POP 로그인) + ↓ +/pop/dashboard (화면 목록 - 카드형) + ↓ +/pop/screens/{id} (화면 뷰어) +``` + +--- + +## 11. POP 디자이너 (신규 계획) + +### 진입 경로 +``` +popScreenMngList → [설계] 버튼 → PopDesigner 컴포넌트 +``` + +### 레이아웃 구조 (2026-02-02 수정) +데스크톱 Screen Designer와 유사하게 **좌측 탭 패널 + 우측 캔버스**: +``` +┌─────────────────────────────────────────────────────────────────┐ +│ [툴바] ← 목록 | 화면명 | 📱모바일 📱태블릿 | 🔄 | 💾저장 │ +├────────────────┬────────────────────────────────────────────────┤ +│ [패널] │ [캔버스 영역] │ +│ ◀━━━━▶ │ │ +│ (리사이즈) │ ┌────────────────────────┐ │ +│ │ │ 디바이스 프레임 │ ← 드래그로 │ +│ ┌────────────┐ │ │ │ 팬 이동 │ +│ │컴포넌트│편집│ │ │ [섹션 1] │ │ +│ └────────────┘ │ │ ├─ 필드 A │ │ +│ │ │ └─ 필드 B │ │ +│ (컴포넌트 탭) │ │ │ │ +│ 📦 섹션 │ │ [섹션 2] │ │ +│ 📝 필드 │ │ ├─ 버튼1 ─ 버튼2 │ │ +│ 🔘 버튼 │ │ │ │ +│ 📋 리스트 │ └────────────────────────┘ │ +│ 📊 인디케이터 │ │ +│ │ │ +│ (편집 탭) │ │ +│ 선택된 컴포 │ │ +│ 넌트 설정 │ │ +└────────────────┴────────────────────────────────────────────────┘ +``` + +### 패널 기능 +| 기능 | 설명 | +|-----|------| +| **리사이즈** | 드래그로 패널 너비 조절 (min: 200px, max: 400px) | +| **컴포넌트 탭** | POP 전용 컴포넌트만 표시 | +| **편집 탭** | 선택된 컴포넌트 설정 (프리셋 기반) | + +### 캔버스 기능 +| 기능 | 설명 | +|-----|------| +| **팬(Pan)** | 마우스 드래그로 보는 위치 이동 | +| **줌** | 마우스 휠로 확대/축소 (선택사항) | +| **디바이스 탭** | 📱모바일 / 📱태블릿 전환 | +| **나란히 보기** | 옵션으로 둘 다 표시 가능 | +| **실시간 미리보기** | 편집 = 미리보기 (별도 창 불필요) | + +### 캔버스 방식: 블록 쌓기 +- 섹션끼리는 위→아래로 쌓임 +- 섹션 안에서는 가로(row) 또는 세로(column) 선택 가능 +- 드래그앤드롭으로 순서 변경 +- 캔버스 자체가 실시간 미리보기 + +### 기준 해상도 +| 디바이스 | 논리적 크기 (dp) | 용도 | +|---------|-----------------|------| +| 모바일 | 360 x 640 | Zebra TC52/57 등 산업용 핸드헬드 | +| 태블릿 | 768 x 1024 | 8~10인치 산업용 태블릿 | + +### 터치 타겟 (장갑 착용 고려) +- 최소 버튼 크기: **60dp** (일반 앱 48dp보다 큼) +- 버튼 간격: **16dp** 이상 + +### 반응형 편집 방식 +| 모드 | 설명 | +|-----|------| +| **기준 디바이스** | 태블릿 (메인 편집) | +| **자동 조정** | CSS flex-wrap, grid로 모바일 자동 줄바꿈 | +| **수동 조정** | 모바일 탭에서 그리드 열 수, 숨기기 설정 | + +**흐름:** +``` +1. 태블릿 탭에서 편집 (기준) + → 모든 컴포넌트, 섹션, 순서, 데이터 바인딩 설정 + +2. 모바일 탭에서 확인 + A) 자동 조정 OK → 그대로 저장 + B) 배치 어색함 → 그리드 열 수 조정 또는 숨기기 +``` + +### 섹션 내 컴포넌트 배치 옵션 +| 설정 | 옵션 | +|-----|------| +| 배치 방향 | `row` / `column` | +| 순서 | 드래그로 변경 | +| 비율 | flex (1:1, 2:1, 1:2 등) | +| 정렬 | `start` / `center` / `end` | +| 간격 | `none` / `small` / `medium` / `large` | +| 줄바꿈 | `wrap` / `nowrap` | +| **그리드 열 수** | 태블릿용, 모바일용 각각 설정 가능 | + +### 관리자가 설정 가능한 것 +| 항목 | 설정 방식 | +|-----|----------| +| 섹션 순서 | 드래그로 위/아래 이동 | +| 섹션 내 배치 | 가로(row) / 세로(column) | +| 정렬 | 왼쪽/가운데/오른쪽, 위/가운데/아래 | +| 컴포넌트 비율 | 1:1, 2:1, 1:2 등 (flex) | +| 크기 | S/M/L/XL 프리셋 | +| 여백/간격 | 작음/보통/넓음 프리셋 | +| 아이콘 | 선택 가능 | +| 테마/색상 | 프리셋 또는 커스텀 | +| 그리드 열 수 | 태블릿/모바일 각각 | +| 모바일 숨기기 | 특정 컴포넌트 숨김 | + +### 관리자가 설정 불가능한 것 (반응형 유지) +- 정확한 x, y 좌표 +- 정확한 픽셀 크기 (예: 347px) +- 고정 위치 (예: 왼쪽에서 100px) + +### 스타일 분리 원칙 +``` +뼈대 (변경 어려움 - 처음부터 잘 설계): +- 데이터 바인딩 구조 (columnName, dataSource) +- 컴포넌트 계층 (섹션 > 필드) +- 액션 로직 + +옷 (변경 쉬움 - 나중에 조정 가능): +- 색상, 폰트 크기 → CSS 변수/테마 +- 버튼 모양 → 프리셋 +- 아이콘 → 선택 +``` + +### 다국어 연동 (준비) +- 상태: `showMultilangSettingsModal` 미리 추가 +- 버튼: 툴바에 자리만 (비활성) +- 연결: 추후 `MultilangSettingsModal` import + +### 데스크톱 시스템 재사용 +| 기능 | 재사용 | 비고 | +|-----|-------|------| +| formData 관리 | O | 그대로 | +| 필드간 연결 | O | cascading, hierarchy | +| 테이블 참조 | O | dataSource, filter | +| 저장 이벤트 | O | beforeFormSave | +| 집계 | O | 스타일만 변경 | +| 설정 패널 | O | 탭 방식 참고 | +| CRUD API | O | 그대로 | +| buttonActions | O | 그대로 | +| 다국어 | O | MultilangSettingsModal | + +### 파일 구조 (신규 생성 예정) +``` +frontend/components/pop/ +├── PopDesigner.tsx # 메인 (좌: 패널, 우: 캔버스) +├── PopCanvas.tsx # 캔버스 (팬/줌 + 프레임) +├── PopToolbar.tsx # 상단 툴바 +│ +├── panels/ +│ └── PopPanel.tsx # 통합 패널 (컴포넌트/편집 탭) +│ +├── components/ # POP 전용 컴포넌트 +│ ├── PopSection.tsx +│ ├── PopField.tsx +│ ├── PopButton.tsx +│ └── ... +│ +└── types/ + └── pop-layout.ts # PopLayoutData, PopComponentData +``` + +--- + +## 12. POP 레이아웃 데이터 구조 (신규) + +### PopLayoutData +```typescript +interface PopLayoutData { + version: "pop-1.0"; + layoutMode: "flow"; // 항상 flow (절대좌표 없음) + deviceTarget: "mobile" | "tablet" | "both"; + components: PopComponentData[]; +} +``` + +### PopComponentData +```typescript +interface PopComponentData { + id: string; + type: "pop-section" | "pop-field" | "pop-button" | "pop-list" | "pop-indicator"; + order: number; // 순서 (x, y 좌표 대신) + + // 개별 컴포넌트 flex 비율 + flex?: number; // 기본 1 + + // 섹션인 경우: 내부 레이아웃 설정 + layout?: { + direction: "row" | "column"; + justify: "start" | "center" | "end" | "between"; + align: "start" | "center" | "end"; + gap: "none" | "small" | "medium" | "large"; + wrap: boolean; + grid?: number; // 태블릿 기준 열 수 + }; + + // 크기 프리셋 + size?: "S" | "M" | "L" | "XL" | "full"; + + // 데이터 바인딩 + dataBinding?: { + tableName: string; + columnName: string; + displayField?: string; + }; + + // 스타일 프리셋 + style?: { + variant: "default" | "primary" | "success" | "warning" | "danger"; + padding: "none" | "small" | "medium" | "large"; + }; + + // 모바일 오버라이드 (선택사항) + mobileOverride?: { + grid?: number; // 모바일 열 수 (없으면 자동) + hidden?: boolean; // 모바일에서 숨기기 + }; + + // 하위 컴포넌트 (섹션 내부) + children?: PopComponentData[]; + + // 컴포넌트별 설정 + config?: Record; +} +``` + +### 데스크톱 vs POP 데이터 비교 +| 항목 | 데스크톱 (LayoutData) | POP (PopLayoutData) | +|-----|----------------------|---------------------| +| 배치 | `position: { x, y, z }` | `order: number` | +| 크기 | `size: { width, height }` (픽셀) | `size: "S" | "M" | "L"` (프리셋) | +| 컨테이너 | 없음 (자유 배치) | `layout: { direction, grid }` | +| 반응형 | 없음 | `mobileOverride` | + +--- + +## 13. 컴포넌트 재사용성 분석 + +### 최종 분류 + +| 분류 | 개수 | 컴포넌트 | +|-----|-----|---------| +| 완전 재사용 | 2 | form-field, action-button | +| 부분 재사용 | 4 | tab-panel, data-table, kpi-gauge, process-flow | +| 신규 개발 | 7 | section, card-list, status-indicator, number-pad, barcode-scanner, timer, alarm-list | + +### 핵심 컴포넌트 7개 (최소 필수) + +| 컴포넌트 | 역할 | 포함 기능 | +|---------|------|----------| +| **pop-section** | 레이아웃 컨테이너 | 카드, 그룹핑, 접기/펼치기 | +| **pop-field** | 데이터 입력/표시 | 텍스트, 숫자, 드롭다운, 바코드, 숫자패드 | +| **pop-button** | 액션 실행 | 저장, 삭제, API 호출, 화면이동 | +| **pop-list** | 데이터 목록 | 카드리스트, 선택목록, 테이블 참조 | +| **pop-indicator** | 상태/수치 표시 | KPI, 게이지, 신호등, 진행률 | +| **pop-scanner** | 바코드/QR 입력 | 카메라, 외부 스캐너 | +| **pop-numpad** | 숫자 입력 특화 | 큰 버튼, 계산기 모드 | + +--- + +## TODO + +### Phase 1: POP 디자이너 개발 (현재 진행) + +| # | 작업 | 설명 | 상태 | +|---|------|------|------| +| 1 | `PopLayoutData` 타입 정의 | order, layout, mobileOverride | 완료 | +| 2 | `PopDesigner.tsx` | 좌: 리사이즈 패널, 우: 팬 가능 캔버스 | 완료 | +| 3 | `PopPanel.tsx` | 탭 (컴포넌트/편집), POP 컴포넌트만 | 완료 | +| 4 | `PopCanvas.tsx` | 팬/줌 + 디바이스 프레임 + 블록 렌더링 | 완료 | +| 5 | `SectionGrid.tsx` | 섹션 내부 컴포넌트 배치 (react-grid-layout) | 완료 | +| 6 | 드래그앤드롭 | 팔레트→캔버스 (섹션), 팔레트→섹션 (컴포넌트) | 완료 | +| 7 | 컴포넌트 자유 배치/리사이즈 | 고정 셀 크기(40px) 기반 자동 그리드 | 완료 | +| 8 | 편집 탭 | 그리드 설정, 모바일 오버라이드 | 완료 (기본) | +| 9 | 저장/로드 | 기존 API 재사용 (saveLayoutPop) | 완료 | + +### Phase 2: POP 컴포넌트 개발 + +상세: `docs/pop/components-spec.md` + +1단계 (우선): +- [ ] pop-section (레이아웃 컨테이너) +- [ ] pop-field (범용 입력) +- [ ] pop-button (액션) + +2단계: +- [ ] pop-list (카드형 목록) +- [ ] pop-indicator (상태/KPI) +- [ ] pop-numpad (숫자패드) + +3단계: +- [ ] pop-scanner (바코드) +- [ ] pop-timer (타이머) +- [ ] pop-alarm (알람) + +### Phase 3: POP 사용자 앱 +- [ ] `/pop/login` - POP 전용 로그인 +- [ ] `/pop/dashboard` - 화면 목록 (카드형) +- [ ] `/pop/screens/[id]` - Flow 렌더러 적용 + +### 기타 +- [ ] POP 컴포넌트 레지스트리 +- [ ] POP 메뉴/폴더 관리 +- [ ] POP 인증 분리 +- [ ] 다국어 연동 + +--- + +## 핵심 파일 참조 + +### 기존 파일 (참고용) +| 파일 | 용도 | +|------|------| +| `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` | 진입점, PopDesigner 호출 위치 | +| `frontend/components/screen/ScreenDesigner.tsx` | 데스크톱 디자이너 (구조 참고) | +| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 모달 (추후 연동) | +| `frontend/lib/api/screen.ts` | API (getLayoutPop, saveLayoutPop) | +| `backend-node/src/services/screenManagementService.ts` | POP CRUD (4720~4920행) | + +### 신규 생성 예정 +| 파일 | 용도 | +|------|------| +| `frontend/components/pop/PopDesigner.tsx` | 메인 디자이너 | +| `frontend/components/pop/PopCanvas.tsx` | 캔버스 (팬/줌) | +| `frontend/components/pop/PopToolbar.tsx` | 툴바 | +| `frontend/components/pop/panels/PopPanel.tsx` | 통합 패널 | +| `frontend/components/pop/types/pop-layout.ts` | 타입 정의 | +| `frontend/components/pop/components/PopSection.tsx` | 섹션 컴포넌트 | + +--- + +--- + +## 14. 그리드 시스템 단순화 (2026-02-02 변경) + +### 기존 문제: 이중 그리드 구조 +``` +캔버스 (24열, rowHeight 20px) + └─ 섹션 (colSpan/rowSpan으로 크기 지정) + └─ 내부 그리드 (columns/rows로 컴포넌트 배치) +``` + +**문제점:** +1. 섹션 크기와 내부 그리드가 독립적이라 동기화 안됨 +2. 섹션을 늘려도 내부 그리드 점은 그대로 (비례 확대만) +3. 사용자가 두 가지 단위를 이해해야 함 + +### 변경: 단일 자동계산 그리드 + +**핵심 변경사항:** +- 그리드 점(dot) 제거 +- 고정 셀 크기(40px) 기반으로 섹션 크기에 따라 열/행 수 자동 계산 +- 컴포넌트는 react-grid-layout으로 자유롭게 드래그/리사이즈 + +**코드 (SectionGrid.tsx):** +```typescript +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))); +``` + +**결과:** +- 섹션 크기 변경 → 내부 셀 개수 자동 조정 +- 컴포넌트 자유 배치/리사이즈 가능 +- 직관적인 사용자 경험 + +### onLayoutChange 대신 onDragStop/onResizeStop 사용 + +**문제:** onLayoutChange는 드롭 직후에도 호출되어 섹션 크기가 자동 확대됨 + +**해결:** +```typescript +// 변경 전 + + +// 변경 후 + +``` + +상태 업데이트는 드래그/리사이즈 완료 후에만 실행 + +--- + +## POP 화면 관리 페이지 개발 (2026-02-02) + +### POP 카테고리 트리 API 구현 + +**기능:** +- POP 화면을 카테고리별로 관리하는 트리 구조 구현 +- 기존 `screen_groups` 테이블을 `hierarchy_path LIKE 'POP/%'` 조건으로 필터링하여 재사용 +- 데스크탑 화면 관리와 별도로 POP 전용 카테고리 체계 구성 + +**백엔드 API:** +- `GET /api/screen-groups/pop/groups` - POP 그룹 목록 조회 +- `POST /api/screen-groups/pop/groups` - POP 그룹 생성 +- `PUT /api/screen-groups/pop/groups/:id` - POP 그룹 수정 +- `DELETE /api/screen-groups/pop/groups/:id` - POP 그룹 삭제 +- `POST /api/screen-groups/pop/ensure-root` - POP 루트 그룹 자동 생성 + +### 트러블슈팅: API 경로 중복 문제 + +**문제:** 카테고리 생성 시 404 에러 발생 + +**원인:** +- `apiClient`의 baseURL이 이미 `http://localhost:8080/api`로 설정됨 +- API 호출 경로에 `/api/screen-groups/...`를 사용하여 최종 URL이 `/api/api/screen-groups/...`로 중복 + +**해결:** +```typescript +// 변경 전 +const response = await apiClient.post("/api/screen-groups/pop/groups", data); + +// 변경 후 +const response = await apiClient.post("/screen-groups/pop/groups", data); +``` + +### 트러블슈팅: created_by 컬럼 오류 + +**문제:** `column "created_by" of relation "screen_groups" does not exist` + +**원인:** +- 신규 작성 코드에서 `created_by` 컬럼을 사용했으나 +- 기존 `screen_groups` 테이블 스키마에는 `writer` 컬럼이 존재 + +**해결:** +```sql +-- 변경 전 +INSERT INTO screen_groups (..., created_by) VALUES (..., $9) + +-- 변경 후 +INSERT INTO screen_groups (..., writer) VALUES (..., $9) +``` + +### 트러블슈팅: is_active 컬럼 타입 불일치 + +**문제:** `value too long for type character varying(1)` 에러로 카테고리 생성 실패 + +**원인:** +- `is_active` 컬럼이 `VARCHAR(1)` 타입 +- INSERT 쿼리에서 `true`(boolean, 4자)를 직접 사용 + +**해결:** +```sql +-- 변경 전 +INSERT INTO screen_groups (..., is_active) VALUES (..., true) + +-- 변경 후 +INSERT INTO screen_groups (..., is_active) VALUES (..., 'Y') +``` + +**교훈:** +- 기존 테이블 스키마를 반드시 확인 후 쿼리 작성 +- `is_active`는 `VARCHAR(1)` 타입으로 'Y'/'N' 값 사용 +- `created_by` 대신 `writer` 컬럼명 사용 + +### 카테고리 트리 UI 개선 + +**문제:** 하위 폴더와 상위 폴더의 계층 관계가 시각적으로 불명확 + +**해결:** +1. 들여쓰기 증가: `level * 16px` → `level * 24px` +2. 트리 연결 표시: "ㄴ" 문자로 하위 항목 명시 +3. 루트 폴더 강조: 주황색 아이콘 + 볼드 텍스트, 하위는 노란색 아이콘 + +```tsx +// 하위 레벨에 연결 표시 추가 +{level > 0 && ( + +)} + +// 루트와 하위 폴더 시각적 구분 + +{group.group_name} +``` + +### 미분류 화면 이동 기능 추가 + +**기능:** 미분류 화면을 특정 카테고리로 이동하는 드롭다운 메뉴 + +**구현:** +```tsx +// 이동 드롭다운 메뉴 + + + + + + {treeData.map((g) => ( + handleMoveScreenToGroup(screen, g)}> + + {g.group_name} + + ))} + + + +// API 호출 (apiClient 사용) +const handleMoveScreenToGroup = async (screen, group) => { + await apiClient.post("/screen-groups/group-screens", { + group_id: group.id, + screen_id: screen.screenId, + screen_role: "main", + display_order: 0, + is_default: false, + }); +}; +``` + +**주의:** API 호출 시 `apiClient`를 사용해야 환경별 URL이 자동 처리됨 + +### 화면 이동 로직 수정 (복사 → 이동) + +**문제:** 화면을 다른 카테고리로 이동할 때 복사가 되어 중복 발생 + +**원인:** 기존 그룹 연결 삭제 없이 새 그룹에만 연결 추가 + +**해결:** 2단계 처리 - 기존 연결 삭제 후 새 연결 추가 + +```tsx +const handleMoveScreenToGroup = async (screen: ScreenDefinition, targetGroup: PopScreenGroup) => { + // 1. 기존 연결 찾기 및 삭제 + for (const g of groups) { + const existingLink = g.screens?.find((s) => s.screen_id === screen.screenId); + if (existingLink) { + await apiClient.delete(`/screen-groups/group-screens/${existingLink.id}`); + break; + } + } + + // 2. 새 그룹에 연결 추가 + await apiClient.post("/screen-groups/group-screens", { + group_id: targetGroup.id, + screen_id: screen.screenId, + screen_role: "main", + display_order: 0, + is_default: false, + }); + + loadGroups(); // 목록 새로고침 +}; +``` + +### 화면/카테고리 메뉴 UI 개선 + +**변경 사항:** +1. 화면에 "..." 더보기 메뉴 추가 (폴더와 동일한 스타일) +2. 메뉴 항목: 설계, 위로 이동, 아래로 이동, 다른 카테고리로 이동, 그룹에서 제거 +3. 폴더 메뉴에도 위로/아래로 이동 추가 + +**순서 변경 구현:** +```tsx +// 그룹 순서 변경 (display_order 교환) +const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => { + const siblingGroups = groups + .filter((g) => g.parent_id === targetGroup.parent_id) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + + const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); + if (currentIndex <= 0) return; + + const prevGroup = siblingGroups[currentIndex - 1]; + + await Promise.all([ + apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { display_order: prevGroup.display_order }), + apiClient.put(`/screen-groups/groups/${prevGroup.id}`, { display_order: targetGroup.display_order }), + ]); + + loadGroups(); +}; + +// 화면 순서 변경 (screen_group_screens의 display_order 교환) +const handleMoveScreenUp = async (screen: ScreenDefinition, groupId: number) => { + const targetGroup = groups.find((g) => g.id === groupId); + const sortedScreens = [...targetGroup.screens].sort((a, b) => a.display_order - b.display_order); + const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); + + if (currentIndex <= 0) return; + + const currentLink = sortedScreens[currentIndex]; + const prevLink = sortedScreens[currentIndex - 1]; + + await Promise.all([ + apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { display_order: prevLink.display_order }), + apiClient.put(`/screen-groups/group-screens/${prevLink.id}`, { display_order: currentLink.display_order }), + ]); + + loadGroups(); +}; +``` + +### 카테고리 이동 모달 (서브메뉴 → 모달 방식) + +**문제:** 카테고리가 많아지면 서브메뉴 방식은 관리 어려움 + +**해결:** 검색 기능이 있는 모달로 변경 + +**구현:** +```tsx +// 이동 모달 상태 +const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); +const [movingScreen, setMovingScreen] = useState(null); +const [movingFromGroupId, setMovingFromGroupId] = useState(null); +const [moveSearchTerm, setMoveSearchTerm] = useState(""); + +// 필터링된 그룹 목록 +const filteredMoveGroups = useMemo(() => { + if (!moveSearchTerm) return flattenedGroups; + const searchLower = moveSearchTerm.toLowerCase(); + return flattenedGroups.filter((g) => + (g._displayName || g.group_name).toLowerCase().includes(searchLower) + ); +}, [flattenedGroups, moveSearchTerm]); + +// 모달 UI 특징: +// 1. 검색 입력창 (Search 아이콘 포함) +// 2. 트리 구조 표시 (depth에 따라 들여쓰기) +// 3. 현재 소속 그룹 표시 및 선택 불가 처리 +// 4. ScrollArea로 긴 목록 스크롤 지원 +``` + +**모달 구조:** +``` +┌─────────────────────────────┐ +│ 카테고리로 이동 │ +│ "화면명" 화면을 이동할... │ +├─────────────────────────────┤ +│ 🔍 카테고리 검색... │ +├─────────────────────────────┤ +│ 📁 POP 화면 │ +│ 📁 홈 관리 │ +│ 📁 출고관리 │ +│ 📁 수주관리 │ +│ 📁 생산 관리 (현재) │ +├─────────────────────────────┤ +│ [ 취소 ] │ +└─────────────────────────────┘ +``` + +--- + +## 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 + +**문제:** `import apiClient from "@/lib/api/client"` 에러 + +**원인:** `apiClient`가 named export로 정의됨 + +**해결:** `import { apiClient } from "@/lib/api/client"` 사용 + +### 섹션이 매우 얇게 렌더링되는 문제 + +**문제:** 디자이너에서 크게 설정한 섹션이 뷰어에서 20px 높이로 표시 + +**원인:** `canvasGrid.rowHeight = 20` 고정값 + react-grid-layout의 rowSpan 반올림 + +**해결:** 비율 기반 rows 시스템으로 변경 (섹션 14 참조) + +### 화면 삭제 404 에러 + +**문제:** 화면 그룹에서 제거 시 404 에러 + +**원인:** company_code 불일치 (세션 vs DB) + +**해결:** 브라우저 새로고침으로 토큰 갱신 또는 DB 직접 처리 + +### 관련 파일 + +| 파일 | 역할 | +|------|------| +| `frontend/components/pop/management/PopCategoryTree.tsx` | POP 카테고리 트리 (전체 UI) | +| `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-02-03* diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 32ce60c3..88230f48 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -2563,3 +2563,280 @@ export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res } }; +// ============================================================ +// POP 전용 화면 그룹 API +// hierarchy_path LIKE 'POP/%' 필터로 POP 카테고리만 조회 +// ============================================================ + +// POP 화면 그룹 목록 조회 (카테고리 트리용) +export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { searchTerm } = req.query; + + let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'"; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터링 (멀티테넌시) + if (companyCode !== "*") { + whereClause += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + // 검색어 필터링 + if (searchTerm) { + whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`; + params.push(`%${searchTerm}%`); + paramIndex++; + } + + // POP 그룹 조회 (계층 구조를 위해 전체 조회) + const dataQuery = ` + SELECT + sg.*, + (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, + (SELECT json_agg( + json_build_object( + 'id', sgs.id, + 'screen_id', sgs.screen_id, + 'screen_name', sd.screen_name, + 'screen_role', sgs.screen_role, + 'display_order', sgs.display_order, + 'is_default', sgs.is_default, + 'table_name', sd.table_name + ) ORDER BY sgs.display_order + ) FROM screen_group_screens sgs + LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id + WHERE sgs.group_id = sg.id + ) as screens + FROM screen_groups sg + ${whereClause} + ORDER BY sg.display_order ASC, sg.hierarchy_path ASC + `; + + const result = await pool.query(dataQuery, params); + + logger.info("POP 화면 그룹 목록 조회", { companyCode, count: result.rows.length }); + + res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("POP 화면 그룹 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 목록 조회에 실패했습니다.", error: error.message }); + } +}; + +// POP 화면 그룹 생성 (hierarchy_path 자동 설정) +export const createPopScreenGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const userCompanyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || ""; + const { group_name, group_code, description, icon, display_order, parent_group_id, target_company_code } = req.body; + + if (!group_name || !group_code) { + return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." }); + } + + // 회사 코드 결정 + const effectiveCompanyCode = target_company_code || userCompanyCode; + if (userCompanyCode !== "*" && effectiveCompanyCode !== userCompanyCode) { + return res.status(403).json({ success: false, message: "다른 회사의 그룹을 생성할 권한이 없습니다." }); + } + + // hierarchy_path 계산 - POP 하위로 설정 + let hierarchyPath = "POP"; + if (parent_group_id) { + // 부모 그룹의 hierarchy_path 조회 + const parentResult = await pool.query( + `SELECT hierarchy_path FROM screen_groups WHERE id = $1`, + [parent_group_id] + ); + if (parentResult.rows.length > 0) { + hierarchyPath = `${parentResult.rows[0].hierarchy_path}/${group_code}`; + } + } else { + // 최상위 POP 카테고리 + hierarchyPath = `POP/${group_code}`; + } + + // 중복 체크 + const duplicateCheck = await pool.query( + `SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`, + [group_code, effectiveCompanyCode] + ); + if (duplicateCheck.rows.length > 0) { + return res.status(400).json({ success: false, message: "동일한 그룹코드가 이미 존재합니다." }); + } + + // 그룹 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤) + const insertQuery = ` + INSERT INTO screen_groups ( + group_name, group_code, description, icon, display_order, + parent_group_id, hierarchy_path, company_code, writer, is_active + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y') + RETURNING * + `; + const insertParams = [ + group_name, + group_code, + description || null, + icon || null, + display_order || 0, + parent_group_id || null, + hierarchyPath, + effectiveCompanyCode, + userId, + ]; + + const result = await pool.query(insertQuery, insertParams); + + logger.info("POP 화면 그룹 생성", { groupId: result.rows[0].id, groupCode: group_code, companyCode: effectiveCompanyCode }); + + res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 생성되었습니다." }); + } catch (error: any) { + logger.error("POP 화면 그룹 생성 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 생성에 실패했습니다.", error: error.message }); + } +}; + +// POP 화면 그룹 수정 +export const updatePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { group_name, description, icon, display_order, is_active } = req.body; + + // 기존 그룹 확인 + let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`; + const checkParams: any[] = [id]; + if (companyCode !== "*") { + checkQuery += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await pool.query(checkQuery, checkParams); + if (existing.rows.length === 0) { + return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); + } + + // POP 그룹인지 확인 + if (!existing.rows[0].hierarchy_path?.startsWith("POP")) { + return res.status(400).json({ success: false, message: "POP 그룹만 수정할 수 있습니다." }); + } + + // 업데이트 + const updateQuery = ` + UPDATE screen_groups + SET group_name = COALESCE($1, group_name), + description = COALESCE($2, description), + icon = COALESCE($3, icon), + display_order = COALESCE($4, display_order), + is_active = COALESCE($5, is_active), + updated_date = NOW() + WHERE id = $6 + RETURNING * + `; + const updateParams = [group_name, description, icon, display_order, is_active, id]; + const result = await pool.query(updateQuery, updateParams); + + logger.info("POP 화면 그룹 수정", { groupId: id, companyCode }); + + res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 수정되었습니다." }); + } catch (error: any) { + logger.error("POP 화면 그룹 수정 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 수정에 실패했습니다.", error: error.message }); + } +}; + +// POP 화면 그룹 삭제 +export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 기존 그룹 확인 + let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`; + const checkParams: any[] = [id]; + if (companyCode !== "*") { + checkQuery += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await pool.query(checkQuery, checkParams); + if (existing.rows.length === 0) { + return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); + } + + // POP 그룹인지 확인 + if (!existing.rows[0].hierarchy_path?.startsWith("POP")) { + return res.status(400).json({ success: false, message: "POP 그룹만 삭제할 수 있습니다." }); + } + + // 하위 그룹 확인 + const childCheck = await pool.query( + `SELECT COUNT(*) as count FROM screen_groups WHERE parent_group_id = $1`, + [id] + ); + if (parseInt(childCheck.rows[0].count) > 0) { + return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." }); + } + + // 연결된 화면 확인 + const screenCheck = await pool.query( + `SELECT COUNT(*) as count FROM screen_group_screens WHERE group_id = $1`, + [id] + ); + if (parseInt(screenCheck.rows[0].count) > 0) { + return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." }); + } + + // 삭제 + await pool.query(`DELETE FROM screen_groups WHERE id = $1`, [id]); + + logger.info("POP 화면 그룹 삭제", { groupId: id, companyCode }); + + res.json({ success: true, message: "POP 화면 그룹이 삭제되었습니다." }); + } catch (error: any) { + logger.error("POP 화면 그룹 삭제 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 삭제에 실패했습니다.", error: error.message }); + } +}; + +// POP 루트 그룹 확보 (없으면 자동 생성) +export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + + // POP 루트 그룹 확인 + const checkQuery = ` + SELECT * FROM screen_groups + WHERE hierarchy_path = 'POP' AND company_code = $1 + `; + const existing = await pool.query(checkQuery, [companyCode]); + + if (existing.rows.length > 0) { + return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." }); + } + + // 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤) + const insertQuery = ` + INSERT INTO screen_groups ( + group_name, group_code, hierarchy_path, company_code, + description, display_order, is_active, writer + ) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2) + RETURNING * + `; + const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]); + + logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode }); + + res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." }); + } catch (error: any) { + logger.error("POP 루트 그룹 확보 실패:", error); + res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message }); + } +}; diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index bbea81d7..a0521eec 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -732,7 +732,7 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) => } }; -// 🆕 레이어 목록 조회 +// 레이어 목록 조회 export const getScreenLayers = async (req: AuthenticatedRequest, res: Response) => { try { const { screenId } = req.params; @@ -745,7 +745,7 @@ export const getScreenLayers = async (req: AuthenticatedRequest, res: Response) } }; -// 🆕 특정 레이어 레이아웃 조회 +// 특정 레이어 레이아웃 조회 export const getLayerLayout = async (req: AuthenticatedRequest, res: Response) => { try { const { screenId, layerId } = req.params; @@ -758,7 +758,7 @@ export const getLayerLayout = async (req: AuthenticatedRequest, res: Response) = } }; -// 🆕 레이어 삭제 +// 레이어 삭제 export const deleteLayer = async (req: AuthenticatedRequest, res: Response) => { try { const { screenId, layerId } = req.params; @@ -771,7 +771,7 @@ export const deleteLayer = async (req: AuthenticatedRequest, res: Response) => { } }; -// 🆕 레이어 조건 설정 업데이트 +// 레이어 조건 설정 업데이트 export const updateLayerCondition = async (req: AuthenticatedRequest, res: Response) => { try { const { screenId, layerId } = req.params; @@ -787,6 +787,90 @@ export const updateLayerCondition = async (req: AuthenticatedRequest, res: Respo } }; +// ======================================== +// POP 레이아웃 관리 (모바일/태블릿) +// ======================================== + +// POP 레이아웃 조회 +export const getLayoutPop = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode, userType } = req.user as any; + const layout = await screenManagementService.getLayoutPop( + parseInt(screenId), + companyCode, + userType + ); + res.json({ success: true, data: layout }); + } catch (error) { + console.error("POP 레이아웃 조회 실패:", error); + res + .status(500) + .json({ success: false, message: "POP 레이아웃 조회에 실패했습니다." }); + } +}; + +// POP 레이아웃 저장 +export const saveLayoutPop = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode, userId } = req.user as any; + const layoutData = req.body; + + await screenManagementService.saveLayoutPop( + parseInt(screenId), + layoutData, + companyCode, + userId + ); + res.json({ success: true, message: "POP 레이아웃이 저장되었습니다." }); + } catch (error) { + console.error("POP 레이아웃 저장 실패:", error); + res + .status(500) + .json({ success: false, message: "POP 레이아웃 저장에 실패했습니다." }); + } +}; + +// POP 레이아웃 삭제 +export const deleteLayoutPop = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + + await screenManagementService.deleteLayoutPop( + parseInt(screenId), + companyCode + ); + res.json({ success: true, message: "POP 레이아웃이 삭제되었습니다." }); + } catch (error) { + console.error("POP 레이아웃 삭제 실패:", error); + res + .status(500) + .json({ success: false, message: "POP 레이아웃 삭제에 실패했습니다." }); + } +}; + +// POP 레이아웃 존재하는 화면 ID 목록 조회 +export const getScreenIdsWithPopLayout = async (req: AuthenticatedRequest, res: Response) => { + try { + const { companyCode } = req.user as any; + + const screenIds = await screenManagementService.getScreenIdsWithPopLayout(companyCode); + + res.json({ + success: true, + data: screenIds, + count: screenIds.length + }); + } catch (error) { + console.error("POP 레이아웃 화면 ID 목록 조회 실패:", error); + res + .status(500) + .json({ success: false, message: "POP 레이아웃 화면 ID 목록 조회에 실패했습니다." }); + } +}; + // 화면 코드 자동 생성 export const generateScreenCode = async ( req: AuthenticatedRequest, diff --git a/backend-node/src/routes/screenGroupRoutes.ts b/backend-node/src/routes/screenGroupRoutes.ts index 614e6d61..86b97b31 100644 --- a/backend-node/src/routes/screenGroupRoutes.ts +++ b/backend-node/src/routes/screenGroupRoutes.ts @@ -36,6 +36,12 @@ import { syncMenuToScreenGroupsController, getSyncStatusController, syncAllCompaniesController, + // POP 전용 화면 그룹 + getPopScreenGroups, + createPopScreenGroup, + updatePopScreenGroup, + deletePopScreenGroup, + ensurePopRootGroup, } from "../controllers/screenGroupController"; const router = Router(); @@ -106,6 +112,15 @@ router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController); // 전체 회사 동기화 (최고 관리자만) router.post("/sync/all", syncAllCompaniesController); +// ============================================================ +// POP 전용 화면 그룹 (hierarchy_path LIKE 'POP/%') +// ============================================================ +router.get("/pop/groups", getPopScreenGroups); +router.post("/pop/groups", createPopScreenGroup); +router.put("/pop/groups/:id", updatePopScreenGroup); +router.delete("/pop/groups/:id", deletePopScreenGroup); +router.post("/pop/ensure-root", ensurePopRootGroup); + export default router; diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 4d1c1770..08bf57f6 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -26,6 +26,10 @@ import { getLayoutV1, getLayoutV2, saveLayoutV2, + getLayoutPop, + saveLayoutPop, + deleteLayoutPop, + getScreenIdsWithPopLayout, generateScreenCode, generateMultipleScreenCodes, assignScreenToMenu, @@ -88,12 +92,18 @@ router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url + router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides) router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장 -// 🆕 레이어 관리 +// 레이어 관리 router.get("/screens/:screenId/layers", getScreenLayers); // 레이어 목록 router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특정 레이어 레이아웃 router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제 router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정 +// POP 레이아웃 관리 (모바일/태블릿) +router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회 +router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장 +router.delete("/screens/:screenId/layout-pop", deleteLayoutPop); // POP: 레이아웃 삭제 +router.get("/pop-layout-screen-ids", getScreenIdsWithPopLayout); // POP: 레이아웃 존재하는 화면 ID 목록 + // 메뉴-화면 할당 관리 router.post("/screens/:screenId/assign-menu", assignScreenToMenu); router.get("/menus/:menuObjid/screens", getScreensByMenu); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 4ea0e767..244f2b2a 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5348,6 +5348,322 @@ export class ScreenManagementService { params, ); } + + // ======================================== + // POP 레이아웃 관리 (모바일/태블릿) + // v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로) + // ======================================== + + /** + * POP v1 → v2 마이그레이션 (백엔드) + * - 단일 sections 배열 → 4모드별 layouts + 공유 sections/components + */ + private migratePopV1ToV2(v1Data: any): any { + console.log("POP v1 → v2 마이그레이션 시작"); + + // 기본 v2 구조 + const v2Data: any = { + version: "pop-2.0", + layouts: { + tablet_landscape: { sectionPositions: {}, componentPositions: {} }, + tablet_portrait: { sectionPositions: {}, componentPositions: {} }, + mobile_landscape: { sectionPositions: {}, componentPositions: {} }, + mobile_portrait: { sectionPositions: {}, componentPositions: {} }, + }, + sections: {}, + components: {}, + dataFlow: { + sectionConnections: [], + }, + settings: { + touchTargetMin: 48, + mode: "normal", + canvasGrid: v1Data.canvasGrid || { columns: 24, rowHeight: 20, gap: 4 }, + }, + metadata: v1Data.metadata, + }; + + // v1 섹션 배열 처리 + const sections = v1Data.sections || []; + const modeKeys = ["tablet_landscape", "tablet_portrait", "mobile_landscape", "mobile_portrait"]; + + for (const section of sections) { + // 섹션 정의 생성 + v2Data.sections[section.id] = { + id: section.id, + label: section.label, + componentIds: (section.components || []).map((c: any) => c.id), + innerGrid: section.innerGrid || { columns: 3, rows: 3, gap: 4 }, + style: section.style, + }; + + // 섹션 위치 복사 (4모드 모두 동일) + const sectionPos = section.grid || { col: 1, row: 1, colSpan: 3, rowSpan: 4 }; + for (const mode of modeKeys) { + v2Data.layouts[mode].sectionPositions[section.id] = { ...sectionPos }; + } + + // 컴포넌트별 처리 + for (const comp of section.components || []) { + // 컴포넌트 정의 생성 + v2Data.components[comp.id] = { + id: comp.id, + type: comp.type, + label: comp.label, + dataBinding: comp.dataBinding, + style: comp.style, + config: comp.config, + }; + + // 컴포넌트 위치 복사 (4모드 모두 동일) + const compPos = comp.grid || { col: 1, row: 1, colSpan: 1, rowSpan: 1 }; + for (const mode of modeKeys) { + v2Data.layouts[mode].componentPositions[comp.id] = { ...compPos }; + } + } + } + + const sectionCount = Object.keys(v2Data.sections).length; + const componentCount = Object.keys(v2Data.components).length; + console.log(`POP v1 → v2 마이그레이션 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); + + return v2Data; + } + + /** + * POP 레이아웃 조회 + * - screen_layouts_pop 테이블에서 화면당 1개 레코드 조회 + * - v1 데이터는 자동으로 v2로 마이그레이션하여 반환 + */ + async getLayoutPop( + screenId: number, + companyCode: string, + userType?: string, + ): Promise { + console.log(`=== POP 레이아웃 로드 시작 ===`); + console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`); + + // SUPER_ADMIN 여부 확인 + const isSuperAdmin = userType === "SUPER_ADMIN"; + + // 권한 확인 + const screens = await query<{ + company_code: string | null; + table_name: string | null; + }>( + `SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId], + ); + + if (screens.length === 0) { + return null; + } + + const existingScreen = screens[0]; + + // SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음 + if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) { + throw new Error("이 화면의 POP 레이아웃을 조회할 권한이 없습니다."); + } + + let layout: { layout_data: any } | null = null; + + // SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회 + if (isSuperAdmin) { + // 1. 화면 정의의 회사 코드로 레이아웃 조회 + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = $2`, + [screenId, existingScreen.company_code], + ); + + // 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회 + if (!layout) { + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 + ORDER BY updated_at DESC + LIMIT 1`, + [screenId], + ); + } + } else { + // 일반 사용자: 회사별 우선, 없으면 공통(*) 조회 + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + + // 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회 + if (!layout && companyCode !== "*") { + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = '*'`, + [screenId], + ); + } + } + + if (!layout) { + console.log(`POP 레이아웃 없음: screen_id=${screenId}`); + return null; + } + + const layoutData = layout.layout_data; + + // v1 → v2 자동 마이그레이션 + if (layoutData && layoutData.version === "pop-1.0") { + console.log("POP v1 레이아웃 감지, v2로 마이그레이션"); + return this.migratePopV1ToV2(layoutData); + } + + // v2 또는 버전 태그 없는 경우 (버전 태그 없으면 sections 구조 확인) + if (layoutData && !layoutData.version && layoutData.sections && Array.isArray(layoutData.sections)) { + console.log("버전 태그 없는 v1 레이아웃 감지, v2로 마이그레이션"); + return this.migratePopV1ToV2({ ...layoutData, version: "pop-1.0" }); + } + + // v2 레이아웃 그대로 반환 + const sectionCount = layoutData?.sections ? Object.keys(layoutData.sections).length : 0; + const componentCount = layoutData?.components ? Object.keys(layoutData.components).length : 0; + console.log(`POP v2 레이아웃 로드 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); + + return layoutData; + } + + /** + * POP 레이아웃 저장 + * - screen_layouts_pop 테이블에 화면당 1개 레코드 저장 + * - v3 형식 지원 (version: "pop-3.0", 섹션 제거) + * - v2/v1 하위 호환 + */ + async saveLayoutPop( + screenId: number, + layoutData: any, + companyCode: string, + userId?: string, + ): Promise { + console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`); + console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); + + // v5 그리드 레이아웃만 지원 + const componentCount = Object.keys(layoutData.components || {}).length; + console.log(`컴포넌트: ${componentCount}개`); + + // v5 형식 검증 + if (layoutData.version && layoutData.version !== "pop-5.0") { + console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`); + } + + // 권한 확인 + const screens = await query<{ company_code: string | null }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId], + ); + + if (screens.length === 0) { + throw new Error("화면을 찾을 수 없습니다."); + } + + const existingScreen = screens[0]; + + if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다."); + } + + // SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 (로드와 동일하게) + const targetCompanyCode = companyCode === "*" + ? (existingScreen.company_code || "*") + : companyCode; + + console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`); + + // v5 그리드 레이아웃으로 저장 (단일 버전) + const dataToSave = { + ...layoutData, + version: "pop-5.0", + }; + console.log(`저장: gridConfig=${JSON.stringify(dataToSave.gridConfig || 'default')}`) + + // UPSERT (있으면 업데이트, 없으면 삽입) + await query( + `INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by) + 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, targetCompanyCode, JSON.stringify(dataToSave), userId || null], + ); + + console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version}, company: ${targetCompanyCode})`); + } + + /** + * POP 레이아웃이 존재하는 화면 ID 목록 조회 + * - 옵션 B: POP 레이아웃 존재 여부로 화면 구분 + */ + async getScreenIdsWithPopLayout( + companyCode: string, + ): Promise { + console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`); + console.log(`회사 코드: ${companyCode}`); + + let result: { screen_id: number }[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 POP 레이아웃 조회 + result = await query<{ screen_id: number }>( + `SELECT DISTINCT screen_id FROM screen_layouts_pop`, + [], + ); + } else { + // 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회 + result = await query<{ screen_id: number }>( + `SELECT DISTINCT screen_id FROM screen_layouts_pop + WHERE company_code = $1 OR company_code = '*'`, + [companyCode], + ); + } + + const screenIds = result.map((r) => r.screen_id); + console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}개`); + return screenIds; + } + + /** + * POP 레이아웃 삭제 + */ + async deleteLayoutPop( + screenId: number, + companyCode: string, + ): Promise { + console.log(`=== POP 레이아웃 삭제 시작 ===`); + console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); + + // 권한 확인 + const screens = await query<{ company_code: string | null }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId], + ); + + if (screens.length === 0) { + throw new Error("화면을 찾을 수 없습니다."); + } + + const existingScreen = screens[0]; + + if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + throw new Error("이 화면의 POP 레이아웃을 삭제할 권한이 없습니다."); + } + + const result = await query( + `DELETE FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + + console.log(`POP 레이아웃 삭제 완료`); + return true; + } } // 서비스 인스턴스 export diff --git a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx new file mode 100644 index 00000000..d9e289ca --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Plus, + RefreshCw, + Search, + Smartphone, + Eye, + Settings, + LayoutGrid, + GitBranch, +} from "lucide-react"; +import { PopDesigner } from "@/components/pop/designer"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; +import { ScreenDefinition } from "@/types/screen"; +import { screenApi } from "@/lib/api/screen"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import CreateScreenModal from "@/components/screen/CreateScreenModal"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { + PopCategoryTree, + PopScreenPreview, + PopScreenFlowView, + PopScreenSettingModal, +} from "@/components/pop/management"; +import { PopScreenGroup } from "@/lib/api/popScreenGroup"; + +// ============================================================ +// 타입 정의 +// ============================================================ + +type Step = "list" | "design"; +type DevicePreview = "mobile" | "tablet"; +type RightPanelView = "preview" | "flow"; + +// ============================================================ +// 메인 컴포넌트 +// ============================================================ + +export default function PopScreenManagementPage() { + const searchParams = useSearchParams(); + + // 단계 및 화면 상태 + const [currentStep, setCurrentStep] = useState("list"); + const [selectedScreen, setSelectedScreen] = useState(null); + const [selectedGroup, setSelectedGroup] = useState(null); + const [stepHistory, setStepHistory] = useState(["list"]); + + // 화면 데이터 + const [screens, setScreens] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + + // POP 레이아웃 존재 화면 ID + const [popLayoutScreenIds, setPopLayoutScreenIds] = useState>(new Set()); + + // UI 상태 + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); + const [devicePreview, setDevicePreview] = useState("tablet"); + const [rightPanelView, setRightPanelView] = useState("preview"); + + // ============================================================ + // 데이터 로드 + // ============================================================ + + const loadScreens = useCallback(async () => { + try { + setLoading(true); + const [result, popScreenIds] = await Promise.all([ + screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" }), + screenApi.getScreenIdsWithPopLayout(), + ]); + + if (result.data && result.data.length > 0) { + setScreens(result.data); + } + setPopLayoutScreenIds(new Set(popScreenIds)); + } catch (error) { + console.error("POP 화면 목록 로드 실패:", error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadScreens(); + }, [loadScreens]); + + // 화면 목록 새로고침 이벤트 리스너 + useEffect(() => { + const handleScreenListRefresh = () => { + console.log("POP 화면 목록 새로고침 이벤트 수신"); + loadScreens(); + }; + + window.addEventListener("screen-list-refresh", handleScreenListRefresh); + return () => { + window.removeEventListener("screen-list-refresh", handleScreenListRefresh); + }; + }, [loadScreens]); + + // URL 쿼리 파라미터로 화면 디자이너 자동 열기 + useEffect(() => { + const openDesignerId = searchParams.get("openDesigner"); + if (openDesignerId && screens.length > 0) { + const screenId = parseInt(openDesignerId, 10); + const targetScreen = screens.find((s) => s.screenId === screenId); + if (targetScreen) { + setSelectedScreen(targetScreen); + setCurrentStep("design"); + setStepHistory(["list", "design"]); + } + } + }, [searchParams, screens]); + + // ============================================================ + // 핸들러 + // ============================================================ + + const goToNextStep = (nextStep: Step) => { + setStepHistory((prev) => [...prev, nextStep]); + setCurrentStep(nextStep); + }; + + const goToStep = (step: Step) => { + setCurrentStep(step); + const stepIndex = stepHistory.findIndex((s) => s === step); + if (stepIndex !== -1) { + setStepHistory(stepHistory.slice(0, stepIndex + 1)); + } + }; + + // 화면 선택 + const handleScreenSelect = (screen: ScreenDefinition) => { + setSelectedScreen(screen); + setSelectedGroup(null); + }; + + // 그룹 선택 + const handleGroupSelect = (group: PopScreenGroup | null) => { + setSelectedGroup(group); + // 그룹 선택 시 화면 선택 해제하지 않음 (미리보기 유지) + }; + + // 화면 디자인 모드 진입 + const handleDesignScreen = (screen: ScreenDefinition) => { + setSelectedScreen(screen); + goToNextStep("design"); + }; + + // POP 화면 미리보기 (새 탭에서 열기) + const handlePreviewScreen = (screen: ScreenDefinition) => { + const previewUrl = `/pop/screens/${screen.screenId}?preview=true&device=${devicePreview}`; + window.open(previewUrl, "_blank", "width=800,height=900"); + }; + + // 화면 설정 모달 열기 + const handleOpenSettings = () => { + if (selectedScreen) { + setIsSettingModalOpen(true); + } + }; + + // ============================================================ + // 필터링된 데이터 + // ============================================================ + + // POP 레이아웃이 있는 화면만 필터링 + const popScreens = screens.filter((screen) => popLayoutScreenIds.has(screen.screenId)); + + // 검색어 필터링 + const filteredScreens = popScreens.filter((screen) => { + if (!searchTerm) return true; + return ( + screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || + screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }); + + const popScreenCount = popLayoutScreenIds.size; + + // ============================================================ + // 디자인 모드 + // ============================================================ + + const isDesignMode = currentStep === "design"; + + if (isDesignMode && selectedScreen) { + return ( +
+ goToStep("list")} + onScreenUpdate={(updatedFields) => { + setSelectedScreen({ + ...selectedScreen, + ...updatedFields, + }); + }} + /> +
+ ); + } + + // ============================================================ + // 목록 모드 렌더링 + // ============================================================ + + return ( +
+ {/* 페이지 헤더 */} +
+
+
+
+
+

POP 화면 관리

+ + 모바일/태블릿 + +
+

+ POP 화면을 카테고리별로 관리하고 모바일/태블릿에 최적화된 화면을 설계합니다 +

+
+
+ +
+ + +
+
+
+ + {/* 메인 콘텐츠 */} + {popScreenCount === 0 ? ( + // POP 화면이 없을 때 빈 상태 표시 +
+
+ +
+

POP 화면이 없습니다

+

+ 아직 생성된 POP 화면이 없습니다. +
+ "새 POP 화면" 버튼을 클릭하여 모바일/태블릿용 화면을 만들어보세요. +

+ +
+ ) : ( +
+ {/* 왼쪽: 카테고리 트리 + 화면 목록 */} +
+ {/* 검색 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9 h-9" + /> +
+
+ POP 화면 + + {popScreenCount}개 + +
+
+ + {/* 카테고리 트리 */} + +
+ + {/* 오른쪽: 미리보기 / 화면 흐름 */} +
+ {/* 오른쪽 패널 헤더 */} +
+ setRightPanelView(v as RightPanelView)}> + + + + 미리보기 + + + + 화면 흐름 + + + + + {selectedScreen && ( +
+ + + +
+ )} +
+ + {/* 오른쪽 패널 콘텐츠 */} +
+ {rightPanelView === "preview" ? ( + + ) : ( + + )} +
+
+
+ )} + + {/* 화면 생성 모달 */} + { + setIsCreateOpen(open); + if (!open) loadScreens(); + }} + onCreated={() => { + setIsCreateOpen(false); + loadScreens(); + }} + isPop={true} + /> + + {/* 화면 설정 모달 */} + { + if (selectedScreen) { + setSelectedScreen({ ...selectedScreen, ...updatedFields }); + } + loadScreens(); + }} + /> + + {/* Scroll to Top 버튼 */} + +
+ ); +} diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx new file mode 100644 index 00000000..f578b30e --- /dev/null +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -0,0 +1,340 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useParams, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react"; +import { screenApi } from "@/lib/api/screen"; +import { ScreenDefinition } from "@/types/screen"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; +import { useAuth } from "@/hooks/useAuth"; +import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; +import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext"; +import { ScreenContextProvider } from "@/contexts/ScreenContext"; +import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; +import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; +import { + PopLayoutDataV5, + GridMode, + isV5Layout, + createEmptyPopLayoutV5, + GAP_PRESETS, + GRID_BREAKPOINTS, + detectGridMode, +} from "@/components/pop/designer/types/pop-layout"; +import PopRenderer from "@/components/pop/designer/renderers/PopRenderer"; +import { + useResponsiveModeWithOverride, + type DeviceType, +} from "@/hooks/useDeviceOrientation"; + +// 디바이스별 크기 (너비만, 높이는 콘텐츠 기반) +const DEVICE_SIZES: Record> = { + mobile: { + landscape: { width: 600, label: "모바일 가로" }, + portrait: { width: 375, label: "모바일 세로" }, + }, + tablet: { + landscape: { width: 1024, label: "태블릿 가로" }, + portrait: { width: 820, label: "태블릿 세로" }, + }, +}; + +// 모드 키 변환 +const getModeKey = (device: DeviceType, isLandscape: boolean): GridMode => { + if (device === "tablet") { + return isLandscape ? "tablet_landscape" : "tablet_portrait"; + } + return isLandscape ? "mobile_landscape" : "mobile_portrait"; +}; + +// ======================================== +// 메인 컴포넌트 (v5 그리드 시스템 전용) +// ======================================== + +function PopScreenViewPage() { + const params = useParams(); + const searchParams = useSearchParams(); + const router = useRouter(); + const screenId = parseInt(params.screenId as string); + + 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 { user } = useAuth(); + + const [screen, setScreen] = useState(null); + const [layout, setLayout] = useState(createEmptyPopLayoutV5()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px) + const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로 + + // 모드 결정: + // - 프리뷰 모드: 수동 선택한 device/orientation 사용 + // - 일반 모드: 화면 너비 기준으로 자동 결정 (GRID_BREAKPOINTS와 일치) + const currentModeKey = isPreviewMode + ? getModeKey(deviceType, isLandscape) + : detectGridMode(viewportWidth); + + useEffect(() => { + const updateViewportWidth = () => { + setViewportWidth(Math.min(window.innerWidth, 1366)); + }; + + updateViewportWidth(); + window.addEventListener("resize", updateViewportWidth); + return () => window.removeEventListener("resize", updateViewportWidth); + }, []); + + // 화면 및 POP 레이아웃 로드 + useEffect(() => { + const loadScreen = async () => { + try { + setLoading(true); + setError(null); + + const screenData = await screenApi.getScreen(screenId); + setScreen(screenData); + + try { + const popLayout = await screenApi.getLayoutPop(screenId); + + if (popLayout && isV5Layout(popLayout)) { + // v5 레이아웃 로드 + setLayout(popLayout); + const componentCount = Object.keys(popLayout.components).length; + console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`); + } else if (popLayout) { + // 다른 버전 레이아웃은 빈 v5로 처리 + console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version); + setLayout(createEmptyPopLayoutV5()); + } else { + console.log("[POP] 레이아웃 없음"); + setLayout(createEmptyPopLayoutV5()); + } + } catch (layoutError) { + console.warn("[POP] 레이아웃 로드 실패:", layoutError); + setLayout(createEmptyPopLayoutV5()); + } + } catch (error) { + console.error("[POP] 화면 로드 실패:", error); + setError("화면을 불러오는데 실패했습니다."); + toast.error("화면을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + if (screenId) { + loadScreen(); + } + }, [screenId]); + + const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"]; + const hasComponents = Object.keys(layout.components).length > 0; + + if (loading) { + return ( +
+
+ +

POP 화면 로딩 중...

+
+
+ ); + } + + if (error || !screen) { + return ( +
+
+
+ ! +
+

화면을 찾을 수 없습니다

+

{error || "요청하신 POP 화면이 존재하지 않습니다."}

+ +
+
+ ); + } + + return ( + + + +
+ {/* 상단 툴바 (프리뷰 모드에서만) */} + {isPreviewMode && ( +
+
+
+ + {screen.screenName} + + ({currentModeKey.replace("_", " ")}) + +
+ +
+
+ + +
+ +
+ + +
+ + {/* 자동 감지 모드 버튼 */} + +
+ + +
+
+ )} + + {/* POP 화면 컨텐츠 */} +
+ {/* 현재 모드 표시 (일반 모드) */} + {!isPreviewMode && ( +
+ {currentModeKey.replace("_", " ")} +
+ )} + +
+ {/* v5 그리드 렌더러 */} + {hasComponents ? ( +
+ {(() => { + // Gap 프리셋 계산 + const currentGapPreset = layout.settings.gapPreset || "medium"; + const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0; + const breakpoint = GRID_BREAKPOINTS[currentModeKey]; + const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); + const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); + + return ( + + ); + })()} +
+ ) : ( + // 빈 화면 +
+
+ +
+

+ 화면이 비어있습니다 +

+

+ POP 화면 디자이너에서 컴포넌트를 추가하여 화면을 구성하세요. +

+
+ )} +
+
+
+
+
+
+ ); +} + +// Provider 래퍼 +export default function PopScreenViewPageWrapper() { + return ( + + + + + + + + ); +} diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx new file mode 100644 index 00000000..7753a992 --- /dev/null +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -0,0 +1,971 @@ +"use client"; + +import { useCallback, useRef, useState, useEffect, useMemo } from "react"; +import { useDrop } from "react-dnd"; +import { cn } from "@/lib/utils"; +import { + PopLayoutDataV5, + PopComponentDefinitionV5, + PopComponentType, + PopGridPosition, + GridMode, + GapPreset, + GAP_PRESETS, + GRID_BREAKPOINTS, + DEFAULT_COMPONENT_GRID_SIZE, +} from "./types/pop-layout"; +import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff } from "lucide-react"; +import { useDrag } from "react-dnd"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { toast } from "sonner"; +import PopRenderer from "./renderers/PopRenderer"; +import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, needsReview } from "./utils/gridUtils"; +import { DND_ITEM_TYPES } from "./constants"; + +/** + * 캔버스 내 상대 좌표 → 그리드 좌표 변환 + * @param relX 캔버스 내 X 좌표 (패딩 포함) + * @param relY 캔버스 내 Y 좌표 (패딩 포함) + */ +function calcGridPosition( + relX: number, + relY: number, + canvasWidth: number, + columns: number, + rowHeight: number, + gap: number, + padding: number +): { col: number; row: number } { + // 패딩 제외한 좌표 + const x = relX - padding; + const y = relY - padding; + + // 사용 가능한 너비 (패딩과 gap 제외) + const availableWidth = canvasWidth - padding * 2 - gap * (columns - 1); + const colWidth = availableWidth / columns; + + // 셀+gap 단위로 계산 + const cellStride = colWidth + gap; + const rowStride = rowHeight + gap; + + // 그리드 좌표 (1부터 시작) + const col = Math.max(1, Math.min(columns, Math.floor(x / cellStride) + 1)); + const row = Math.max(1, Math.floor(y / rowStride) + 1); + + return { col, row }; +} + +// 드래그 아이템 타입 정의 +interface DragItemComponent { + type: typeof DND_ITEM_TYPES.COMPONENT; + componentType: PopComponentType; +} + +interface DragItemMoveComponent { + componentId: string; + originalPosition: PopGridPosition; +} + +// ======================================== +// 프리셋 해상도 (4개 모드) - 너비만 정의 +// ======================================== +const VIEWPORT_PRESETS = [ + { id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, icon: Smartphone }, + { id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 600, icon: Smartphone }, + { id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 820, icon: Tablet }, + { id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, icon: Tablet }, +] as const; + +type ViewportPreset = GridMode; + +// 기본 프리셋 (태블릿 가로) +const DEFAULT_PRESET: ViewportPreset = "tablet_landscape"; + +// 캔버스 세로 자동 확장 설정 +const MIN_CANVAS_HEIGHT = 600; // 최소 캔버스 높이 (px) +const CANVAS_EXTRA_ROWS = 3; // 여유 행 수 + +// ======================================== +// Props +// ======================================== +interface PopCanvasProps { + layout: PopLayoutDataV5; + selectedComponentId: string | null; + currentMode: GridMode; + onModeChange: (mode: GridMode) => void; + onSelectComponent: (id: string | null) => void; + onDropComponent: (type: PopComponentType, position: PopGridPosition) => void; + onUpdateComponent: (componentId: string, updates: Partial) => void; + onDeleteComponent: (componentId: string) => void; + onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void; + onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void; + onResizeEnd?: (componentId: string) => void; + onHideComponent?: (componentId: string) => void; + onUnhideComponent?: (componentId: string) => void; + onLockLayout?: () => void; + onResetOverride?: (mode: GridMode) => void; + onChangeGapPreset?: (preset: GapPreset) => void; +} + +// ======================================== +// PopCanvas: 그리드 캔버스 +// ======================================== + +export default function PopCanvas({ + layout, + selectedComponentId, + currentMode, + onModeChange, + onSelectComponent, + onDropComponent, + onUpdateComponent, + onDeleteComponent, + onMoveComponent, + onResizeComponent, + onResizeEnd, + onHideComponent, + onUnhideComponent, + onLockLayout, + onResetOverride, + onChangeGapPreset, +}: PopCanvasProps) { + // 줌 상태 + const [canvasScale, setCanvasScale] = useState(0.8); + + // 커스텀 뷰포트 너비 + const [customWidth, setCustomWidth] = useState(1024); + + // 그리드 가이드 표시 여부 + const [showGridGuide, setShowGridGuide] = useState(true); + + // 패닝 상태 + const [isPanning, setIsPanning] = useState(false); + const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + const [isSpacePressed, setIsSpacePressed] = useState(false); + const containerRef = useRef(null); + const canvasRef = useRef(null); + + // 현재 뷰포트 해상도 + const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!; + const breakpoint = GRID_BREAKPOINTS[currentMode]; + + // Gap 프리셋 적용 + const currentGapPreset = layout.settings.gapPreset || "medium"; + const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0; + const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); + const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); + + // 숨김 컴포넌트 ID 목록 + const hiddenComponentIds = layout.overrides?.[currentMode]?.hidden || []; + + // 동적 캔버스 높이 계산 (컴포넌트 배치 기반) + const dynamicCanvasHeight = useMemo(() => { + const visibleComps = Object.values(layout.components).filter( + comp => !hiddenComponentIds.includes(comp.id) + ); + + if (visibleComps.length === 0) return MIN_CANVAS_HEIGHT; + + // 최대 row + rowSpan 찾기 + const maxRowEnd = visibleComps.reduce((max, comp) => { + const overridePos = layout.overrides?.[currentMode]?.positions?.[comp.id]; + const pos = overridePos ? { ...comp.position, ...overridePos } : comp.position; + const rowEnd = pos.row + pos.rowSpan; + return Math.max(max, rowEnd); + }, 1); + + // 높이 계산: (행 수 + 여유) * (행높이 + gap) + padding + const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS; + const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2; + + return Math.max(MIN_CANVAS_HEIGHT, height); + }, [layout.components, layout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]); + + // 그리드 라벨 계산 (동적 행 수) + const gridLabels = useMemo(() => { + const columnLabels = Array.from({ length: breakpoint.columns }, (_, i) => i + 1); + + // 동적 행 수 계산 + const rowCount = Math.ceil(dynamicCanvasHeight / (breakpoint.rowHeight + adjustedGap)); + const rowLabels = Array.from({ length: rowCount }, (_, i) => i + 1); + + return { columnLabels, rowLabels }; + }, [breakpoint.columns, breakpoint.rowHeight, dynamicCanvasHeight, adjustedGap]); + + // 줌 컨트롤 + 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); + + // 모드 변경 + const handleViewportChange = (mode: GridMode) => { + onModeChange(mode); + const presetData = VIEWPORT_PRESETS.find((p) => p.id === mode)!; + setCustomWidth(presetData.width); + // customHeight는 dynamicCanvasHeight로 자동 계산됨 + }; + + // 패닝 + const handlePanStart = (e: React.MouseEvent) => { + const isMiddleButton = e.button === 1; + if (isMiddleButton || isSpacePressed) { + setIsPanning(true); + setPanStart({ x: e.clientX, y: e.clientY }); + e.preventDefault(); + } + }; + + 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); + + // Ctrl + 휠로 줌 조정 + const handleWheel = (e: React.WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + 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); + }; + const handleKeyUp = (e: KeyboardEvent) => { + if (e.code === "Space") setIsSpacePressed(false); + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, [isSpacePressed]); + + // 통합 드롭 핸들러 (팔레트에서 추가 + 컴포넌트 이동) + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: [DND_ITEM_TYPES.COMPONENT, DND_ITEM_TYPES.MOVE_COMPONENT], + drop: (item: DragItemComponent | DragItemMoveComponent, monitor) => { + if (!canvasRef.current) return; + + const canvasRect = canvasRef.current.getBoundingClientRect(); + const itemType = monitor.getItemType(); + + // 팔레트에서 새 컴포넌트 추가 - 마우스 위치 기준 + if (itemType === DND_ITEM_TYPES.COMPONENT) { + const offset = monitor.getClientOffset(); + if (!offset) return; + + // 캔버스 내 상대 좌표 (스케일 보정) + // canvasRect는 scale 적용된 크기이므로, 상대 좌표를 scale로 나눠야 실제 좌표 + const relX = (offset.x - canvasRect.left) / canvasScale; + const relY = (offset.y - canvasRect.top) / canvasScale; + + // 그리드 좌표 계산 + const gridPos = calcGridPosition( + relX, + relY, + customWidth, + breakpoint.columns, + breakpoint.rowHeight, + adjustedGap, + adjustedPadding + ); + + const dragItem = item as DragItemComponent; + const defaultSize = DEFAULT_COMPONENT_GRID_SIZE[dragItem.componentType]; + + const candidatePosition: PopGridPosition = { + col: gridPos.col, + row: gridPos.row, + colSpan: defaultSize.colSpan, + rowSpan: defaultSize.rowSpan, + }; + + // 현재 모드에서의 유효 위치들로 중첩 검사 + const effectivePositions = getAllEffectivePositions(layout, currentMode); + const existingPositions = Array.from(effectivePositions.values()); + + const hasOverlap = existingPositions.some(pos => + isOverlapping(candidatePosition, pos) + ); + + let finalPosition: PopGridPosition; + + if (hasOverlap) { + finalPosition = findNextEmptyPosition( + existingPositions, + defaultSize.colSpan, + defaultSize.rowSpan, + breakpoint.columns + ); + toast.info("겹치는 위치입니다. 빈 위치로 자동 배치됩니다."); + } else { + finalPosition = candidatePosition; + } + + onDropComponent(dragItem.componentType, finalPosition); + } + + // 기존 컴포넌트 이동 - 마우스 위치 기준 + if (itemType === DND_ITEM_TYPES.MOVE_COMPONENT) { + const offset = monitor.getClientOffset(); + if (!offset) return; + + // 캔버스 내 상대 좌표 (스케일 보정) + const relX = (offset.x - canvasRect.left) / canvasScale; + const relY = (offset.y - canvasRect.top) / canvasScale; + + const gridPos = calcGridPosition( + relX, + relY, + customWidth, + breakpoint.columns, + breakpoint.rowHeight, + adjustedGap, + adjustedPadding + ); + + const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean }; + + // 현재 모드에서의 유효 위치들 가져오기 + const effectivePositions = getAllEffectivePositions(layout, currentMode); + + // 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기 + // 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 + const currentEffectivePos = effectivePositions.get(dragItem.componentId); + const componentData = layout.components[dragItem.componentId]; + + if (!currentEffectivePos && !componentData) return; + + const sourcePosition = currentEffectivePos || componentData.position; + + // colSpan이 현재 모드의 columns를 초과하면 제한 + const adjustedColSpan = Math.min(sourcePosition.colSpan, breakpoint.columns); + + // 드롭 위치 + 크기가 범위를 초과하면 드롭 위치를 자동 조정 + let adjustedCol = gridPos.col; + if (adjustedCol + adjustedColSpan - 1 > breakpoint.columns) { + adjustedCol = Math.max(1, breakpoint.columns - adjustedColSpan + 1); + } + + const newPosition: PopGridPosition = { + col: adjustedCol, + row: gridPos.row, + colSpan: adjustedColSpan, + rowSpan: sourcePosition.rowSpan, + }; + + // 자기 자신 제외한 다른 컴포넌트들의 유효 위치와 겹침 체크 + const hasOverlap = Array.from(effectivePositions.entries()).some(([id, pos]) => { + if (id === dragItem.componentId) return false; // 자기 자신 제외 + return isOverlapping(newPosition, pos); + }); + + if (hasOverlap) { + toast.error("이 위치로 이동할 수 없습니다 (다른 컴포넌트와 겹침)"); + return; + } + + // 이동 처리 (숨김 컴포넌트의 경우 handleMoveComponent에서 숨김 해제도 함께 처리됨) + onMoveComponent?.(dragItem.componentId, newPosition); + + // 숨김 패널에서 드래그한 경우 안내 메시지 + if (dragItem.fromHidden) { + toast.info("컴포넌트가 다시 표시됩니다"); + } + } + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }), + [onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding] + ); + + drop(canvasRef); + + // 빈 상태 체크 + const isEmpty = Object.keys(layout.components).length === 0; + + // 숨김 처리된 컴포넌트 객체 목록 (hiddenComponentIds는 라인 166에서 정의됨) + const hiddenComponents = useMemo(() => { + return hiddenComponentIds + .map(id => layout.components[id]) + .filter(Boolean); + }, [hiddenComponentIds, layout.components]); + + // 표시되는 컴포넌트 목록 (숨김 제외) + const visibleComponents = useMemo(() => { + return Object.values(layout.components).filter( + comp => !hiddenComponentIds.includes(comp.id) + ); + }, [layout.components, hiddenComponentIds]); + + // 검토 필요 컴포넌트 목록 + const reviewComponents = useMemo(() => { + return visibleComponents.filter(comp => { + const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id]; + return needsReview(currentMode, hasOverride); + }); + }, [visibleComponents, layout.overrides, currentMode]); + + // 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때) + const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0; + + // 12칸 모드가 아닐 때만 패널 표시 + // 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시 + const hasGridComponents = Object.keys(layout.components).length > 0; + const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents); + const showRightPanel = showReviewPanel || showHiddenPanel; + + return ( +
+ {/* 상단 컨트롤 */} +
+ {/* 모드 프리셋 버튼 */} +
+ {VIEWPORT_PRESETS.map((preset) => { + const Icon = preset.icon; + const isActive = currentMode === preset.id; + const isDefault = preset.id === DEFAULT_PRESET; + + return ( + + ); + })} +
+ +
+ + {/* 고정/되돌리기 버튼 (기본 모드 아닐 때만 표시) */} + {currentMode !== DEFAULT_PRESET && ( + <> + + + {layout.overrides?.[currentMode] && ( + + )} + + )} + +
+ + {/* 해상도 표시 */} +
+ {customWidth} × {Math.round(dynamicCanvasHeight)} +
+ +
+ + {/* Gap 프리셋 선택 */} +
+ 간격: + +
+ +
+ + {/* 줌 컨트롤 */} +
+ + {Math.round(canvasScale * 100)}% + + + + +
+ +
+ + {/* 그리드 가이드 토글 */} + +
+ + {/* 캔버스 영역 */} +
+
+ {/* 그리드 + 라벨 영역 */} +
+ {/* 그리드 라벨 영역 */} + {showGridGuide && ( + <> + {/* 열 라벨 (상단) */} +
+ {gridLabels.columnLabels.map((num) => ( +
+ {num} +
+ ))} +
+ + {/* 행 라벨 (좌측) */} +
+ {gridLabels.rowLabels.map((num) => ( +
+ {num} +
+ ))} +
+ + )} + + {/* 디바이스 스크린 */} +
+ {isEmpty ? ( + // 빈 상태 +
+
+
+ 컴포넌트를 드래그하여 배치하세요 +
+
+ {breakpoint.label} - {breakpoint.columns}칸 그리드 +
+
+
+ ) : ( + // 그리드 렌더러 + onSelectComponent(null)} + onComponentMove={onMoveComponent} + onComponentResize={onResizeComponent} + onComponentResizeEnd={onResizeEnd} + overrideGap={adjustedGap} + overridePadding={adjustedPadding} + /> + )} +
+
+ + {/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */} + {showRightPanel && ( +
+ {/* 검토 필요 패널 */} + {showReviewPanel && ( + + )} + + {/* 숨김 컴포넌트 패널 */} + {showHiddenPanel && ( + + )} +
+ )} +
+
+ + {/* 하단 정보 */} +
+
+ {breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px) +
+
+ Space + 드래그: 패닝 | Ctrl + 휠: 줌 +
+
+
+ ); +} + +// ======================================== +// 검토 필요 영역 (오른쪽 패널) +// ======================================== + +interface ReviewPanelProps { + components: PopComponentDefinitionV5[]; + selectedComponentId: string | null; + onSelectComponent: (id: string | null) => void; +} + +function ReviewPanel({ + components, + selectedComponentId, + onSelectComponent, +}: ReviewPanelProps) { + return ( +
+ {/* 헤더 */} +
+ + + 검토 필요 ({components.length}개) + +
+ + {/* 컴포넌트 목록 */} +
+ {components.map((comp) => ( + onSelectComponent(comp.id)} + /> + ))} +
+ + {/* 안내 문구 */} +
+

+ 자동 배치됨. 클릭하여 확인 후 편집 가능 +

+
+
+ ); +} + +// ======================================== +// 검토 필요 아이템 (ReviewPanel 내부) +// ======================================== + +interface ReviewItemProps { + component: PopComponentDefinitionV5; + isSelected: boolean; + onSelect: () => void; +} + +function ReviewItem({ + component, + isSelected, + onSelect, +}: ReviewItemProps) { + return ( +
{ + e.stopPropagation(); + onSelect(); + }} + > + + {component.label || component.id} + + + 자동 배치됨 + +
+ ); +} + +// ======================================== +// 숨김 컴포넌트 영역 (오른쪽 패널) +// ======================================== + +interface HiddenPanelProps { + components: PopComponentDefinitionV5[]; + selectedComponentId: string | null; + onSelectComponent: (id: string | null) => void; + onHideComponent?: (componentId: string) => void; +} + +function HiddenPanel({ + components, + selectedComponentId, + onSelectComponent, + onHideComponent, +}: HiddenPanelProps) { + // 그리드에서 컴포넌트를 드래그하여 이 패널에 드롭하면 숨김 처리 + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: DND_ITEM_TYPES.MOVE_COMPONENT, + drop: (item: { componentId: string; fromHidden?: boolean }) => { + // 이미 숨김 패널에서 온 아이템은 무시 + if (item.fromHidden) return; + + // 숨김 처리 + onHideComponent?.(item.componentId); + toast.info("컴포넌트가 숨김 처리되었습니다"); + }, + canDrop: (item: { componentId: string; fromHidden?: boolean }) => { + // 숨김 패널에서 온 아이템은 드롭 불가 + return !item.fromHidden; + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }), + [onHideComponent] + ); + + return ( +
+ {/* 헤더 */} +
+ + + 숨김 ({components.length}개) + +
+ + {/* 컴포넌트 목록 */} +
+ {components.map((comp) => ( + onSelectComponent(comp.id)} + /> + ))} +
+ + {/* 안내 문구 */} +
+

+ 그리드로 드래그하여 다시 표시 +

+
+
+ ); +} + + +// ======================================== +// 숨김 컴포넌트 아이템 (드래그 가능) +// ======================================== + +interface HiddenItemProps { + component: PopComponentDefinitionV5; + isSelected: boolean; + onSelect: () => void; +} + +function HiddenItem({ + component, + isSelected, + onSelect, +}: HiddenItemProps) { + const [{ isDragging }, drag] = useDrag( + () => ({ + type: DND_ITEM_TYPES.MOVE_COMPONENT, + item: { + componentId: component.id, + originalPosition: component.position, + fromHidden: true, // 숨김 패널에서 왔음을 표시 + }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [component.id, component.position] + ); + + return ( +
+ {/* 컴포넌트 이름 */} +
+ + {component.label || component.type} +
+ + {/* 원본 위치 정보 */} +
+ 원본: {component.position.col}열, {component.position.row}행 +
+
+ ); +} diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx new file mode 100644 index 00000000..8bcc8f3a --- /dev/null +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -0,0 +1,661 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import { ArrowLeft, Save, Undo2, Redo2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { toast } from "sonner"; + +// POP 컴포넌트 자동 등록 (반드시 다른 import보다 먼저) +import "@/lib/registry/pop-components"; + +import PopCanvas from "./PopCanvas"; +import ComponentEditorPanel from "./panels/ComponentEditorPanel"; +import ComponentPalette from "./panels/ComponentPalette"; +import { + PopLayoutDataV5, + PopComponentType, + PopComponentDefinitionV5, + PopGridPosition, + GridMode, + GapPreset, + createEmptyPopLayoutV5, + isV5Layout, + addComponentToV5Layout, + GRID_BREAKPOINTS, +} from "./types/pop-layout"; +import { getAllEffectivePositions } from "./utils/gridUtils"; +import { screenApi } from "@/lib/api/screen"; +import { ScreenDefinition } from "@/types/screen"; + +// ======================================== +// Props +// ======================================== +interface PopDesignerProps { + selectedScreen: ScreenDefinition; + onBackToList: () => void; + onScreenUpdate?: (updatedScreen: Partial) => void; +} + +// ======================================== +// 메인 컴포넌트 (v5 그리드 시스템 전용) +// ======================================== +export default function PopDesigner({ + selectedScreen, + onBackToList, + onScreenUpdate, +}: PopDesignerProps) { + // ======================================== + // 레이아웃 상태 + // ======================================== + const [layout, setLayout] = useState(createEmptyPopLayoutV5()); + + // 히스토리 + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + + // UI 상태 + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + const [idCounter, setIdCounter] = useState(1); + + // 선택 상태 + const [selectedComponentId, setSelectedComponentId] = useState(null); + + // 그리드 모드 (4개 프리셋) + const [currentMode, setCurrentMode] = useState("tablet_landscape"); + + // 선택된 컴포넌트 + const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId + ? layout.components[selectedComponentId] || null + : null; + + // ======================================== + // 히스토리 관리 + // ======================================== + const saveToHistory = useCallback((newLayout: PopLayoutDataV5) => { + setHistory((prev) => { + const newHistory = prev.slice(0, historyIndex + 1); + newHistory.push(JSON.parse(JSON.stringify(newLayout))); + // 최대 50개 유지 + if (newHistory.length > 50) { + newHistory.shift(); + return newHistory; + } + return newHistory; + }); + setHistoryIndex((prev) => Math.min(prev + 1, 49)); + }, [historyIndex]); + + const undo = useCallback(() => { + if (historyIndex > 0) { + const newIndex = historyIndex - 1; + const previousLayout = history[newIndex]; + if (previousLayout) { + setLayout(JSON.parse(JSON.stringify(previousLayout))); + setHistoryIndex(newIndex); + setHasChanges(true); + toast.success("실행 취소됨"); + } + } + }, [historyIndex, history]); + + const redo = useCallback(() => { + if (historyIndex < history.length - 1) { + const newIndex = historyIndex + 1; + const nextLayout = history[newIndex]; + if (nextLayout) { + setLayout(JSON.parse(JSON.stringify(nextLayout))); + setHistoryIndex(newIndex); + setHasChanges(true); + toast.success("다시 실행됨"); + } + } + }, [historyIndex, history]); + + const canUndo = historyIndex > 0; + const canRedo = historyIndex < history.length - 1; + + // ======================================== + // 레이아웃 로드 + // ======================================== + useEffect(() => { + const loadLayout = async () => { + if (!selectedScreen?.screenId) return; + + setIsLoading(true); + try { + const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); + + if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) { + // v5 레이아웃 로드 + // 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가 + if (!loadedLayout.settings.gapPreset) { + loadedLayout.settings.gapPreset = "medium"; + } + setLayout(loadedLayout); + setHistory([loadedLayout]); + setHistoryIndex(0); + + // 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지) + const existingIds = Object.keys(loadedLayout.components); + const maxId = existingIds.reduce((max, id) => { + const match = id.match(/comp_(\d+)/); + if (match) { + const num = parseInt(match[1], 10); + return num > max ? num : max; + } + return max; + }, 0); + setIdCounter(maxId + 1); + + console.log(`POP 레이아웃 로드: ${existingIds.length}개 컴포넌트, idCounter: ${maxId + 1}`); + } else { + // 새 화면 또는 빈 레이아웃 + const emptyLayout = createEmptyPopLayoutV5(); + setLayout(emptyLayout); + setHistory([emptyLayout]); + setHistoryIndex(0); + console.log("새 POP 화면 생성 (v5 그리드)"); + } + } catch (error) { + console.error("레이아웃 로드 실패:", error); + toast.error("레이아웃을 불러오는데 실패했습니다"); + const emptyLayout = createEmptyPopLayoutV5(); + setLayout(emptyLayout); + setHistory([emptyLayout]); + setHistoryIndex(0); + } finally { + setIsLoading(false); + } + }; + + loadLayout(); + }, [selectedScreen?.screenId]); + + // ======================================== + // 저장 + // ======================================== + const handleSave = useCallback(async () => { + if (!selectedScreen?.screenId) return; + + setIsSaving(true); + try { + await screenApi.saveLayoutPop(selectedScreen.screenId, layout); + toast.success("저장되었습니다"); + setHasChanges(false); + } catch (error) { + console.error("저장 실패:", error); + toast.error("저장에 실패했습니다"); + } finally { + setIsSaving(false); + } + }, [selectedScreen?.screenId, layout]); + + // ======================================== + // 컴포넌트 핸들러 + // ======================================== + const handleDropComponent = useCallback( + (type: PopComponentType, position: PopGridPosition) => { + const componentId = `comp_${idCounter}`; + setIdCounter((prev) => prev + 1); + const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`); + setLayout(newLayout); + saveToHistory(newLayout); + setSelectedComponentId(componentId); + setHasChanges(true); + }, + [idCounter, layout, saveToHistory] + ); + + const handleUpdateComponent = useCallback( + (componentId: string, updates: Partial) => { + const existingComponent = layout.components[componentId]; + if (!existingComponent) return; + + const newLayout = { + ...layout, + components: { + ...layout.components, + [componentId]: { + ...existingComponent, + ...updates, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + }, + [layout, saveToHistory] + ); + + const handleDeleteComponent = useCallback( + (componentId: string) => { + const newComponents = { ...layout.components }; + delete newComponents[componentId]; + + const newLayout = { + ...layout, + components: newComponents, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setSelectedComponentId(null); + setHasChanges(true); + }, + [layout, saveToHistory] + ); + + const handleMoveComponent = useCallback( + (componentId: string, newPosition: PopGridPosition) => { + const component = layout.components[componentId]; + if (!component) return; + + // 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정 + if (currentMode === "tablet_landscape") { + const newLayout = { + ...layout, + components: { + ...layout.components, + [componentId]: { + ...component, + position: newPosition, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + } else { + // 다른 모드인 경우: 오버라이드에 저장 + // 숨김 상태였던 컴포넌트를 이동하면 숨김 해제도 함께 처리 + const currentHidden = layout.overrides?.[currentMode]?.hidden || []; + const isHidden = currentHidden.includes(componentId); + const newHidden = isHidden + ? currentHidden.filter(id => id !== componentId) + : currentHidden; + + const newLayout = { + ...layout, + overrides: { + ...layout.overrides, + [currentMode]: { + ...layout.overrides?.[currentMode], + positions: { + ...layout.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + // 숨김 배열 업데이트 (빈 배열이면 undefined로) + hidden: newHidden.length > 0 ? newHidden : undefined, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + } + }, + [layout, saveToHistory, currentMode] + ); + + const handleResizeComponent = useCallback( + (componentId: string, newPosition: PopGridPosition) => { + const component = layout.components[componentId]; + if (!component) return; + + // 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정 + if (currentMode === "tablet_landscape") { + const newLayout = { + ...layout, + components: { + ...layout.components, + [componentId]: { + ...component, + position: newPosition, + }, + }, + }; + setLayout(newLayout); + // 리사이즈는 드래그 중 계속 호출되므로 히스토리는 마우스업 시에만 저장 + // 현재는 간단히 매번 저장 (최적화 가능) + setHasChanges(true); + } else { + // 다른 모드인 경우: 오버라이드에 저장 + const newLayout = { + ...layout, + overrides: { + ...layout.overrides, + [currentMode]: { + ...layout.overrides?.[currentMode], + positions: { + ...layout.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + setLayout(newLayout); + setHasChanges(true); + } + }, + [layout, currentMode] + ); + + const handleResizeEnd = useCallback( + (componentId: string) => { + // 리사이즈 완료 시 현재 레이아웃을 히스토리에 저장 + saveToHistory(layout); + }, + [layout, saveToHistory] + ); + + // ======================================== + // Gap 프리셋 관리 + // ======================================== + + const handleChangeGapPreset = useCallback((preset: GapPreset) => { + const newLayout = { + ...layout, + settings: { + ...layout.settings, + gapPreset: preset, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + }, [layout, saveToHistory]); + + // ======================================== + // 모드별 오버라이드 관리 + // ======================================== + + const handleLockLayout = useCallback(() => { + // 현재 화면에 보이는 유효 위치들을 저장 (오버라이드 또는 자동 재배치 위치) + const effectivePositions = getAllEffectivePositions(layout, currentMode); + + const positionsToSave: Record = {}; + effectivePositions.forEach((position, componentId) => { + positionsToSave[componentId] = position; + }); + + const newLayout = { + ...layout, + overrides: { + ...layout.overrides, + [currentMode]: { + ...layout.overrides?.[currentMode], + positions: positionsToSave, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + toast.success("현재 배치가 고정되었습니다"); + }, [layout, currentMode, saveToHistory]); + + const handleResetOverride = useCallback((mode: GridMode) => { + const newOverrides = { ...layout.overrides }; + delete newOverrides[mode]; + + const newLayout = { + ...layout, + overrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + toast.success("자동 배치로 되돌렸습니다"); + }, [layout, saveToHistory]); + + // ======================================== + // 숨김 관리 + // ======================================== + + const handleHideComponent = useCallback((componentId: string) => { + // 12칸 모드에서는 숨기기 불가 + if (currentMode === "tablet_landscape") return; + + const currentHidden = layout.overrides?.[currentMode]?.hidden || []; + + // 이미 숨겨져 있으면 무시 + if (currentHidden.includes(componentId)) return; + + const newHidden = [...currentHidden, componentId]; + + const newLayout = { + ...layout, + overrides: { + ...layout.overrides, + [currentMode]: { + ...layout.overrides?.[currentMode], + hidden: newHidden, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + setSelectedComponentId(null); + }, [layout, currentMode, saveToHistory]); + + const handleUnhideComponent = useCallback((componentId: string) => { + const currentHidden = layout.overrides?.[currentMode]?.hidden || []; + + // 숨겨져 있지 않으면 무시 + if (!currentHidden.includes(componentId)) return; + + const newHidden = currentHidden.filter(id => id !== componentId); + + const newLayout = { + ...layout, + overrides: { + ...layout.overrides, + [currentMode]: { + ...layout.overrides?.[currentMode], + hidden: newHidden.length > 0 ? newHidden : undefined, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + }, [layout, currentMode, saveToHistory]); + + // ======================================== + // 뒤로가기 + // ======================================== + const handleBack = useCallback(() => { + if (hasChanges) { + if (confirm("저장하지 않은 변경사항이 있습니다. 정말 나가시겠습니까?")) { + onBackToList(); + } + } else { + onBackToList(); + } + }, [hasChanges, onBackToList]); + + // ======================================== + // 단축키 처리 + // ======================================== + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) { + return; + } + + const key = e.key.toLowerCase(); + const isCtrlOrCmd = e.ctrlKey || e.metaKey; + + // Delete / Backspace: 컴포넌트 삭제 + if (e.key === "Delete" || e.key === "Backspace") { + e.preventDefault(); + if (selectedComponentId) { + handleDeleteComponent(selectedComponentId); + } + } + + // Ctrl+Z: Undo + if (isCtrlOrCmd && key === "z" && !e.shiftKey) { + e.preventDefault(); + if (canUndo) undo(); + return; + } + + // Ctrl+Shift+Z or Ctrl+Y: Redo + if ((isCtrlOrCmd && key === "z" && e.shiftKey) || (isCtrlOrCmd && key === "y")) { + e.preventDefault(); + if (canRedo) redo(); + return; + } + + // Ctrl+S: 저장 + if (isCtrlOrCmd && key === "s") { + e.preventDefault(); + handleSave(); + return; + } + + // H키: 선택된 컴포넌트 숨김 (12칸 모드가 아닐 때만) + if (key === "h" && !isCtrlOrCmd && selectedComponentId) { + e.preventDefault(); + handleHideComponent(selectedComponentId); + return; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedComponentId, handleDeleteComponent, handleHideComponent, canUndo, canRedo, undo, redo, handleSave]); + + // ======================================== + // 로딩 + // ======================================== + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + // ======================================== + // 렌더링 + // ======================================== + return ( + +
+ {/* 헤더 */} +
+ {/* 왼쪽: 뒤로가기 + 화면명 */} +
+ +
+

{selectedScreen?.screenName}

+

+ 그리드 레이아웃 (v5) +

+
+
+ + {/* 오른쪽: Undo/Redo + 저장 */} +
+ {/* Undo/Redo 버튼 */} +
+ + +
+ + {/* 저장 버튼 */} + +
+
+ + {/* 메인 영역 */} + + {/* 왼쪽: 컴포넌트 팔레트 */} + + + + + + + {/* 중앙: 캔버스 */} + + + + + + + {/* 오른쪽: 속성 패널 */} + + handleUpdateComponent(selectedComponentId, updates) + : undefined + } + /> + + +
+
+ ); +} diff --git a/frontend/components/pop/designer/constants/dnd.ts b/frontend/components/pop/designer/constants/dnd.ts new file mode 100644 index 00000000..d73d663e --- /dev/null +++ b/frontend/components/pop/designer/constants/dnd.ts @@ -0,0 +1,14 @@ +/** + * DnD(Drag and Drop) 관련 상수 + */ + +// DnD 아이템 타입 +export const DND_ITEM_TYPES = { + /** 팔레트에서 새 컴포넌트 드래그 */ + COMPONENT: "POP_COMPONENT", + /** 캔버스 내 기존 컴포넌트 이동 */ + MOVE_COMPONENT: "POP_MOVE_COMPONENT", +} as const; + +// 타입 추출 +export type DndItemType = typeof DND_ITEM_TYPES[keyof typeof DND_ITEM_TYPES]; diff --git a/frontend/components/pop/designer/constants/index.ts b/frontend/components/pop/designer/constants/index.ts new file mode 100644 index 00000000..ac8e9724 --- /dev/null +++ b/frontend/components/pop/designer/constants/index.ts @@ -0,0 +1 @@ +export * from "./dnd"; diff --git a/frontend/components/pop/designer/index.ts b/frontend/components/pop/designer/index.ts new file mode 100644 index 00000000..37d86aec --- /dev/null +++ b/frontend/components/pop/designer/index.ts @@ -0,0 +1,31 @@ +// POP 디자이너 컴포넌트 export (v5 그리드 시스템) + +// 타입 +export * from "./types"; + +// 메인 디자이너 +export { default as PopDesigner } from "./PopDesigner"; + +// 캔버스 +export { default as PopCanvas } from "./PopCanvas"; + +// 패널 +export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel"; + +// 렌더러 +export { default as PopRenderer } from "./renderers/PopRenderer"; + +// 유틸리티 +export * from "./utils/gridUtils"; + +// 핵심 타입 재export (편의) +export type { + PopLayoutDataV5, + PopComponentDefinitionV5, + PopComponentType, + PopGridPosition, + GridMode, + PopGridConfig, + PopDataBinding, + PopDataFlow, +} from "./types/pop-layout"; diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx new file mode 100644 index 00000000..ddb7ac79 --- /dev/null +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -0,0 +1,438 @@ +"use client"; + +import React from "react"; +import { cn } from "@/lib/utils"; +import { + PopComponentDefinitionV5, + PopGridPosition, + GridMode, + GRID_BREAKPOINTS, + PopComponentType, +} from "../types/pop-layout"; +import { + Settings, + Database, + Eye, + Grid3x3, + MoveHorizontal, + MoveVertical, +} from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; + +// ======================================== +// Props +// ======================================== + +interface ComponentEditorPanelProps { + /** 선택된 컴포넌트 */ + component: PopComponentDefinitionV5 | null; + /** 현재 모드 */ + currentMode: GridMode; + /** 컴포넌트 업데이트 */ + onUpdateComponent?: (updates: Partial) => void; + /** 추가 className */ + className?: string; +} + +// ======================================== +// 컴포넌트 타입별 라벨 +// ======================================== +const COMPONENT_TYPE_LABELS: Record = { + "pop-field": "필드", + "pop-button": "버튼", + "pop-list": "리스트", + "pop-indicator": "인디케이터", + "pop-scanner": "스캐너", + "pop-numpad": "숫자패드", + "pop-spacer": "스페이서", + "pop-break": "줄바꿈", +}; + +// ======================================== +// 컴포넌트 편집 패널 (v5 그리드 시스템) +// ======================================== + +export default function ComponentEditorPanel({ + component, + currentMode, + onUpdateComponent, + className, +}: ComponentEditorPanelProps) { + const breakpoint = GRID_BREAKPOINTS[currentMode]; + + // 선택된 컴포넌트 없음 + if (!component) { + return ( +
+
+

속성

+
+
+ 컴포넌트를 선택하세요 +
+
+ ); + } + + // 기본 모드 여부 + const isDefaultMode = currentMode === "tablet_landscape"; + + return ( +
+ {/* 헤더 */} +
+

+ {component.label || COMPONENT_TYPE_LABELS[component.type]} +

+

{component.type}

+ {!isDefaultMode && ( +

+ 기본 모드(태블릿 가로)에서만 위치 편집 가능 +

+ )} +
+ + {/* 탭 */} + + + + + 위치 + + + + 설정 + + + + 표시 + + + + 데이터 + + + + {/* 위치 탭 */} + + + + + {/* 설정 탭 */} + + + + + {/* 표시 탭 */} + + + + + {/* 데이터 탭 */} + + + + +
+ ); +} + +// ======================================== +// 위치 편집 폼 +// ======================================== + +interface PositionFormProps { + component: PopComponentDefinitionV5; + currentMode: GridMode; + isDefaultMode: boolean; + columns: number; + onUpdate?: (updates: Partial) => void; +} + +function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) { + const { position } = component; + + const handlePositionChange = (field: keyof PopGridPosition, value: number) => { + // 범위 체크 + let clampedValue = Math.max(1, value); + + if (field === "col" || field === "colSpan") { + clampedValue = Math.min(columns, clampedValue); + } + if (field === "colSpan" && position.col + clampedValue - 1 > columns) { + clampedValue = columns - position.col + 1; + } + + onUpdate?.({ + position: { + ...position, + [field]: clampedValue, + }, + }); + }; + + return ( +
+ {/* 그리드 정보 */} +
+

+ 현재 그리드: {GRID_BREAKPOINTS[currentMode].label} +

+

+ 최대 {columns}칸 × 무제한 행 +

+
+ + {/* 열 위치 */} +
+ +
+ handlePositionChange("col", parseInt(e.target.value) || 1)} + disabled={!isDefaultMode} + className="h-8 w-20 text-xs" + /> + + (1~{columns}) + +
+
+ + {/* 행 위치 */} +
+ +
+ handlePositionChange("row", parseInt(e.target.value) || 1)} + disabled={!isDefaultMode} + className="h-8 w-20 text-xs" + /> + + (1~) + +
+
+ +
+ + {/* 열 크기 */} +
+ +
+ handlePositionChange("colSpan", parseInt(e.target.value) || 1)} + disabled={!isDefaultMode} + className="h-8 w-20 text-xs" + /> + + 칸 (1~{columns}) + +
+

+ {Math.round((position.colSpan / columns) * 100)}% 너비 +

+
+ + {/* 행 크기 */} +
+ +
+ handlePositionChange("rowSpan", parseInt(e.target.value) || 1)} + disabled={!isDefaultMode} + className="h-8 w-20 text-xs" + /> + + 행 + +
+

+ 높이: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px +

+
+ + {/* 비활성화 안내 */} + {!isDefaultMode && ( +
+

+ 위치 편집은 기본 모드(태블릿 가로)에서만 가능합니다. + 다른 모드에서는 자동으로 변환됩니다. +

+
+ )} +
+ ); +} + +// ======================================== +// 설정 폼 +// ======================================== + +interface ComponentSettingsFormProps { + component: PopComponentDefinitionV5; + onUpdate?: (updates: Partial) => void; +} + +function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) { + // PopComponentRegistry에서 configPanel 가져오기 + const registeredComp = PopComponentRegistry.getComponent(component.type); + const ConfigPanel = registeredComp?.configPanel; + + // config 업데이트 핸들러 + const handleConfigUpdate = (newConfig: any) => { + onUpdate?.({ config: newConfig }); + }; + + return ( +
+ {/* 라벨 */} +
+ + onUpdate?.({ label: e.target.value })} + placeholder="컴포넌트 이름" + className="h-8 text-xs" + /> +
+ + {/* 컴포넌트 타입별 설정 패널 */} + {ConfigPanel ? ( + + ) : ( +
+

+ {component.type} 전용 설정이 없습니다 +

+
+ )} +
+ ); +} + +// ======================================== +// 표시/숨김 폼 +// ======================================== + +interface VisibilityFormProps { + component: PopComponentDefinitionV5; + onUpdate?: (updates: Partial) => void; +} + +function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { + const modes: Array<{ key: GridMode; label: string }> = [ + { key: "tablet_landscape", label: "태블릿 가로 (12칸)" }, + { key: "tablet_portrait", label: "태블릿 세로 (8칸)" }, + { key: "mobile_landscape", label: "모바일 가로 (6칸)" }, + { key: "mobile_portrait", label: "모바일 세로 (4칸)" }, + ]; + + const handleVisibilityChange = (mode: GridMode, visible: boolean) => { + onUpdate?.({ + visibility: { + ...component.visibility, + [mode]: visible, + }, + }); + }; + + return ( +
+
+ + + {modes.map((mode) => { + const isVisible = component.visibility?.[mode.key] !== false; + + return ( +
+ + handleVisibilityChange(mode.key, checked === true) + } + /> + +
+ ); + })} +
+ +
+

+ 체크 해제하면 해당 모드에서 컴포넌트가 숨겨집니다 +

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

데이터 바인딩

+

+ Phase 4에서 구현 예정 +

+
+
+ ); +} diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx new file mode 100644 index 00000000..05db0aab --- /dev/null +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useDrag } from "react-dnd"; +import { cn } from "@/lib/utils"; +import { PopComponentType } from "../types/pop-layout"; +import { Square, FileText } from "lucide-react"; +import { DND_ITEM_TYPES } from "../constants"; + +// 컴포넌트 정의 +interface PaletteItem { + type: PopComponentType; + label: string; + icon: React.ElementType; + description: string; +} + +const PALETTE_ITEMS: PaletteItem[] = [ + { + type: "pop-sample", + label: "샘플 박스", + icon: Square, + description: "크기 조정 테스트용", + }, + { + type: "pop-text", + label: "텍스트", + icon: FileText, + description: "텍스트, 시간, 이미지 표시", + }, +]; + +// 드래그 가능한 컴포넌트 아이템 +function DraggablePaletteItem({ item }: { item: PaletteItem }) { + const [{ isDragging }, drag] = useDrag( + () => ({ + type: DND_ITEM_TYPES.COMPONENT, + item: { type: DND_ITEM_TYPES.COMPONENT, componentType: item.type }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [item.type] + ); + + const Icon = item.icon; + + return ( +
+
+ +
+
+
{item.label}
+
+ {item.description} +
+
+
+ ); +} + +// 컴포넌트 팔레트 패널 +export default function ComponentPalette() { + return ( +
+ {/* 헤더 */} +
+

컴포넌트

+

+ 드래그하여 캔버스에 배치 +

+
+ + {/* 컴포넌트 목록 */} +
+
+ {PALETTE_ITEMS.map((item) => ( + + ))} +
+
+ + {/* 하단 안내 */} +
+

+ Tip: 캔버스의 그리드 칸에 드롭하세요 +

+
+
+ ); +} diff --git a/frontend/components/pop/designer/panels/index.ts b/frontend/components/pop/designer/panels/index.ts new file mode 100644 index 00000000..f2a70880 --- /dev/null +++ b/frontend/components/pop/designer/panels/index.ts @@ -0,0 +1,3 @@ +// POP 디자이너 패널 export (v5 그리드 시스템) +export { default as ComponentEditorPanel } from "./ComponentEditorPanel"; +export { default as ComponentPalette } from "./ComponentPalette"; diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx new file mode 100644 index 00000000..b0299813 --- /dev/null +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -0,0 +1,564 @@ +"use client"; + +import React, { useMemo } from "react"; +import { useDrag } from "react-dnd"; +import { cn } from "@/lib/utils"; +import { DND_ITEM_TYPES } from "../constants"; +import { + PopLayoutDataV5, + PopComponentDefinitionV5, + PopGridPosition, + GridMode, + GRID_BREAKPOINTS, + GridBreakpoint, + detectGridMode, + PopComponentType, +} from "../types/pop-layout"; +import { + convertAndResolvePositions, + isOverlapping, + getAllEffectivePositions, +} from "../utils/gridUtils"; +import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; + +// ======================================== +// Props +// ======================================== + +interface PopRendererProps { + /** v5 레이아웃 데이터 */ + layout: PopLayoutDataV5; + /** 현재 뷰포트 너비 */ + viewportWidth: number; + /** 현재 모드 (자동 감지 또는 수동 지정) */ + currentMode?: GridMode; + /** 디자인 모드 여부 */ + isDesignMode?: boolean; + /** 그리드 가이드 표시 여부 */ + showGridGuide?: boolean; + /** 선택된 컴포넌트 ID */ + selectedComponentId?: string | null; + /** 컴포넌트 클릭 */ + onComponentClick?: (componentId: string) => void; + /** 배경 클릭 */ + onBackgroundClick?: () => void; + /** 컴포넌트 이동 */ + onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void; + /** 컴포넌트 크기 조정 */ + onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void; + /** 컴포넌트 크기 조정 완료 (히스토리 저장용) */ + onComponentResizeEnd?: (componentId: string) => void; + /** Gap 오버라이드 (Gap 프리셋 적용된 값) */ + overrideGap?: number; + /** Padding 오버라이드 (Gap 프리셋 적용된 값) */ + overridePadding?: number; + /** 추가 className */ + className?: string; +} + +// ======================================== +// 컴포넌트 타입별 라벨 +// ======================================== + +const COMPONENT_TYPE_LABELS: Record = { + "pop-sample": "샘플", +}; + +// ======================================== +// PopRenderer: v5 그리드 렌더러 +// ======================================== + +export default function PopRenderer({ + layout, + viewportWidth, + currentMode, + isDesignMode = false, + showGridGuide = true, + selectedComponentId, + onComponentClick, + onBackgroundClick, + onComponentMove, + onComponentResize, + onComponentResizeEnd, + overrideGap, + overridePadding, + className, +}: PopRendererProps) { + const { gridConfig, components, overrides } = layout; + + // 현재 모드 (자동 감지 또는 지정) + const mode = currentMode || detectGridMode(viewportWidth); + const breakpoint = GRID_BREAKPOINTS[mode]; + + // Gap/Padding: 오버라이드 우선, 없으면 기본값 사용 + const finalGap = overrideGap !== undefined ? overrideGap : breakpoint.gap; + const finalPadding = overridePadding !== undefined ? overridePadding : breakpoint.padding; + + // 숨김 컴포넌트 ID 목록 + const hiddenIds = overrides?.[mode]?.hidden || []; + + // 동적 행 수 계산 (가이드 셀 + Grid 스타일 공유, 숨김 컴포넌트 제외) + const dynamicRowCount = useMemo(() => { + const visibleComps = Object.values(components).filter( + comp => !hiddenIds.includes(comp.id) + ); + const maxRowEnd = visibleComps.reduce((max, comp) => { + const override = overrides?.[mode]?.positions?.[comp.id]; + const pos = override ? { ...comp.position, ...override } : comp.position; + return Math.max(max, pos.row + pos.rowSpan); + }, 1); + return Math.max(10, maxRowEnd + 3); + }, [components, overrides, mode, hiddenIds]); + + // CSS Grid 스타일 (행 높이 강제 고정: 셀 크기 = 컴포넌트 크기의 기준) + const gridStyle = useMemo((): React.CSSProperties => ({ + display: "grid", + gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, + gridTemplateRows: `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`, + gridAutoRows: `${breakpoint.rowHeight}px`, + gap: `${finalGap}px`, + padding: `${finalPadding}px`, + minHeight: "100%", + backgroundColor: "#ffffff", + position: "relative", + }), [breakpoint, finalGap, finalPadding, dynamicRowCount]); + + // 그리드 가이드 셀 생성 (동적 행 수) + const gridCells = useMemo(() => { + if (!isDesignMode || !showGridGuide) return []; + + const cells = []; + for (let row = 1; row <= dynamicRowCount; row++) { + for (let col = 1; col <= breakpoint.columns; col++) { + cells.push({ + id: `cell-${col}-${row}`, + col, + row + }); + } + } + return cells; + }, [isDesignMode, showGridGuide, breakpoint.columns, dynamicRowCount]); + + // visibility 체크 + const isVisible = (comp: PopComponentDefinitionV5): boolean => { + if (!comp.visibility) return true; + const modeVisibility = comp.visibility[mode]; + return modeVisibility !== false; + }; + + // 자동 재배치된 위치 계산 (오버라이드 없을 때) + const autoResolvedPositions = useMemo(() => { + const componentsArray = Object.entries(components).map(([id, comp]) => ({ + id, + position: comp.position, + })); + + return convertAndResolvePositions(componentsArray, mode); + }, [components, mode]); + + // 위치 변환 (12칸 기준 → 현재 모드 칸 수) + const convertPosition = (position: PopGridPosition): React.CSSProperties => { + return { + gridColumn: `${position.col} / span ${position.colSpan}`, + gridRow: `${position.row} / span ${position.rowSpan}`, + }; + }; + + // 오버라이드 적용 또는 자동 재배치 + const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => { + // 1순위: 오버라이드가 있으면 사용 + const override = overrides?.[mode]?.positions?.[comp.id]; + if (override) { + return { ...comp.position, ...override }; + } + + // 2순위: 자동 재배치된 위치 사용 + const autoResolved = autoResolvedPositions.find(p => p.id === comp.id); + if (autoResolved) { + return autoResolved.position; + } + + // 3순위: 원본 위치 (12칸 모드) + return comp.position; + }; + + // 오버라이드 숨김 체크 + const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => { + return overrides?.[mode]?.hidden?.includes(comp.id) ?? false; + }; + + // 모든 컴포넌트의 유효 위치 계산 (리사이즈 겹침 검사용) + const effectivePositionsMap = useMemo(() => + getAllEffectivePositions(layout, mode), + [layout, mode] + ); + + return ( +
{ + if (e.target === e.currentTarget) { + onBackgroundClick?.(); + } + }} + > + {/* 그리드 가이드 셀 (실제 DOM) */} + {gridCells.map(cell => ( +
+ ))} + + {/* 컴포넌트 렌더링 (z-index로 위에 표시) */} + {/* v5.1: 자동 줄바꿈으로 모든 컴포넌트가 그리드 안에 배치됨 */} + {Object.values(components).map((comp) => { + // visibility 체크 + if (!isVisible(comp)) return null; + + // 오버라이드 숨김 체크 + if (isHiddenByOverride(comp)) return null; + + const position = getEffectivePosition(comp); + const positionStyle = convertPosition(position); + const isSelected = selectedComponentId === comp.id; + + // 디자인 모드에서는 드래그 가능한 컴포넌트, 뷰어 모드에서는 일반 컴포넌트 + if (isDesignMode) { + return ( + + ); + } + + // 뷰어 모드: 드래그 없는 일반 렌더링 + return ( +
+ +
+ ); + })} +
+ ); +} + +// ======================================== +// 드래그 가능한 컴포넌트 래퍼 +// ======================================== + +interface DraggableComponentProps { + component: PopComponentDefinitionV5; + position: PopGridPosition; + positionStyle: React.CSSProperties; + isSelected: boolean; + isDesignMode: boolean; + breakpoint: GridBreakpoint; + viewportWidth: number; + allEffectivePositions: Map; + effectiveGap: number; + effectivePadding: number; + onComponentClick?: (componentId: string) => void; + onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void; + onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void; + onComponentResizeEnd?: (componentId: string) => void; +} + +function DraggableComponent({ + component, + position, + positionStyle, + isSelected, + isDesignMode, + breakpoint, + viewportWidth, + allEffectivePositions, + effectiveGap, + effectivePadding, + onComponentClick, + onComponentMove, + onComponentResize, + onComponentResizeEnd, +}: DraggableComponentProps) { + const [{ isDragging }, drag] = useDrag( + () => ({ + type: DND_ITEM_TYPES.MOVE_COMPONENT, + item: { + componentId: component.id, + originalPosition: position + }, + canDrag: isDesignMode, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [component.id, position, isDesignMode] + ); + + return ( +
{ + e.stopPropagation(); + onComponentClick?.(component.id); + }} + > + + + {/* 리사이즈 핸들 (선택된 컴포넌트만) */} + {isDesignMode && isSelected && onComponentResize && ( + + )} +
+ ); +} + +// ======================================== +// 리사이즈 핸들 +// ======================================== + +interface ResizeHandlesProps { + component: PopComponentDefinitionV5; + position: PopGridPosition; + breakpoint: GridBreakpoint; + viewportWidth: number; + allEffectivePositions: Map; + effectiveGap: number; + effectivePadding: number; + onResize: (componentId: string, newPosition: PopGridPosition) => void; + onResizeEnd?: (componentId: string) => void; +} + +function ResizeHandles({ + component, + position, + breakpoint, + viewportWidth, + allEffectivePositions, + effectiveGap, + effectivePadding, + onResize, + onResizeEnd, +}: ResizeHandlesProps) { + const handleMouseDown = (direction: 'right' | 'bottom' | 'corner') => (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + const startX = e.clientX; + const startY = e.clientY; + const startColSpan = position.colSpan; + const startRowSpan = position.rowSpan; + + // 그리드 셀 크기 동적 계산 (Gap 프리셋 적용된 값 사용) + // 사용 가능한 너비 = 뷰포트 너비 - 양쪽 패딩 - gap*(칸수-1) + const availableWidth = viewportWidth - effectivePadding * 2 - effectiveGap * (breakpoint.columns - 1); + const cellWidth = availableWidth / breakpoint.columns + effectiveGap; // 셀 너비 + gap 단위 + const cellHeight = breakpoint.rowHeight + effectiveGap; + + const handleMouseMove = (e: MouseEvent) => { + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + let newColSpan = startColSpan; + let newRowSpan = startRowSpan; + + if (direction === 'right' || direction === 'corner') { + const colDelta = Math.round(deltaX / cellWidth); + newColSpan = Math.max(1, startColSpan + colDelta); + // 최대 칸 수 제한 + newColSpan = Math.min(newColSpan, breakpoint.columns - position.col + 1); + } + + if (direction === 'bottom' || direction === 'corner') { + const rowDelta = Math.round(deltaY / cellHeight); + newRowSpan = Math.max(1, startRowSpan + rowDelta); + } + + // 변경사항이 있으면 업데이트 + if (newColSpan !== position.colSpan || newRowSpan !== position.rowSpan) { + const newPosition: PopGridPosition = { + ...position, + colSpan: newColSpan, + rowSpan: newRowSpan, + }; + + // 유효 위치 기반 겹침 검사 (다른 컴포넌트와) + const hasOverlap = Array.from(allEffectivePositions.entries()).some( + ([id, pos]) => { + if (id === component.id) return false; // 자기 자신 제외 + return isOverlapping(newPosition, pos); + } + ); + + // 겹치지 않을 때만 리사이즈 적용 + if (!hasOverlap) { + onResize(component.id, newPosition); + } + } + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + // 리사이즈 완료 알림 (히스토리 저장용) + onResizeEnd?.(component.id); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + return ( + <> + {/* 오른쪽 핸들 (가로 크기) */} +
+ + {/* 아래쪽 핸들 (세로 크기) */} +
+ + {/* 오른쪽 아래 모서리 (가로+세로) */} +
+ + ); +} + +// ======================================== +// 컴포넌트 내용 렌더링 +// ======================================== + +interface ComponentContentProps { + component: PopComponentDefinitionV5; + effectivePosition: PopGridPosition; + isDesignMode: boolean; + isSelected: boolean; +} + +function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) { + const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; + + // PopComponentRegistry에서 등록된 컴포넌트 가져오기 + const registeredComp = PopComponentRegistry.getComponent(component.type); + const PreviewComponent = registeredComp?.preview; + + // 디자인 모드: 미리보기 컴포넌트 또는 플레이스홀더 표시 + if (isDesignMode) { + return ( +
+ {/* 헤더 */} +
+ + {component.label || typeLabel} + +
+ + {/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */} +
+ {PreviewComponent ? ( + + ) : ( + + {typeLabel} + + )} +
+ + {/* 위치 정보 표시 (유효 위치 사용) */} +
+ {effectivePosition.col},{effectivePosition.row} + ({effectivePosition.colSpan}×{effectivePosition.rowSpan}) +
+
+ ); + } + + // 실제 모드: 컴포넌트 렌더링 + return renderActualComponent(component); +} + +// ======================================== +// 실제 컴포넌트 렌더링 (뷰어 모드) +// ======================================== + +function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode { + const typeLabel = COMPONENT_TYPE_LABELS[component.type]; + + // 샘플 박스 렌더링 + return ( +
+ {component.label || typeLabel} +
+ ); +} diff --git a/frontend/components/pop/designer/renderers/index.ts b/frontend/components/pop/designer/renderers/index.ts new file mode 100644 index 00000000..bf82b0d2 --- /dev/null +++ b/frontend/components/pop/designer/renderers/index.ts @@ -0,0 +1,4 @@ +// POP 레이아웃 렌더러 모듈 (v5 그리드 시스템) +// 디자이너와 뷰어에서 동일한 렌더링을 보장하기 위한 공용 렌더러 + +export { default as PopRenderer } from "./PopRenderer"; diff --git a/frontend/components/pop/designer/types/index.ts b/frontend/components/pop/designer/types/index.ts new file mode 100644 index 00000000..011f6dc1 --- /dev/null +++ b/frontend/components/pop/designer/types/index.ts @@ -0,0 +1,2 @@ +// POP 디자이너 타입 export +export * from "./pop-layout"; diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts new file mode 100644 index 00000000..1a8335ec --- /dev/null +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -0,0 +1,395 @@ +// POP 디자이너 레이아웃 타입 정의 +// v5.0: CSS Grid 기반 그리드 시스템 +// 2024-02 버전 통합: v1~v4 제거, v5 단일 버전 + +// ======================================== +// 공통 타입 +// ======================================== + +/** + * POP 컴포넌트 타입 + */ +export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트 + +/** + * 데이터 흐름 정의 + */ +export interface PopDataFlow { + connections: PopDataConnection[]; +} + +export interface PopDataConnection { + id: string; + sourceComponent: string; + sourceField: string; + targetComponent: string; + targetField: string; + transformType?: "direct" | "calculate" | "lookup"; +} + +/** + * 데이터 바인딩 + */ +export interface PopDataBinding { + entityField?: string; + defaultValue?: any; + format?: string; + validation?: { + required?: boolean; + min?: number; + max?: number; + pattern?: string; + }; +} + +/** + * 스타일 프리셋 + */ +export interface PopStylePreset { + theme?: "default" | "primary" | "success" | "warning" | "danger"; + size?: "sm" | "md" | "lg"; + variant?: "solid" | "outline" | "ghost"; +} + +/** + * 컴포넌트 설정 + */ +export interface PopComponentConfig { + // 필드 설정 + inputType?: "text" | "number" | "date" | "select" | "barcode"; + placeholder?: string; + readonly?: boolean; + + // 버튼 설정 + action?: "submit" | "scan" | "navigate" | "custom"; + targetScreen?: string; + + // 리스트 설정 + columns?: { field: string; label: string; width?: number }[]; + selectable?: boolean; + + // 인디케이터 설정 + indicatorType?: "status" | "progress" | "count"; + + // 스캐너 설정 + scanType?: "barcode" | "qr" | "both"; + autoSubmit?: boolean; +} + +/** + * 메타데이터 + */ +export interface PopLayoutMetadata { + createdAt?: string; + updatedAt?: string; + author?: string; + description?: string; + tags?: string[]; +} + +// ======================================== +// v5 그리드 기반 레이아웃 +// ======================================== +// 핵심: CSS Grid로 정확한 위치 지정 +// - 열/행 좌표로 배치 (col, row) +// - 칸 단위 크기 (colSpan, rowSpan) +// - Material Design 브레이크포인트 기반 + +/** + * 그리드 모드 (4가지) + */ +export type GridMode = + | "mobile_portrait" // 4칸 + | "mobile_landscape" // 6칸 + | "tablet_portrait" // 8칸 + | "tablet_landscape"; // 12칸 (기본) + +/** + * 그리드 브레이크포인트 설정 + */ +export interface GridBreakpoint { + minWidth?: number; + maxWidth?: number; + columns: number; + rowHeight: number; + gap: number; + padding: number; + label: string; +} + +/** + * 브레이크포인트 상수 + * 업계 표준 (768px, 1024px) + 실제 기기 커버리지 기반 + */ +export const GRID_BREAKPOINTS: Record = { + // 스마트폰 세로 (iPhone SE ~ Galaxy S25 Ultra) + mobile_portrait: { + maxWidth: 479, + columns: 4, + rowHeight: 40, + gap: 8, + padding: 12, + label: "모바일 세로 (4칸)", + }, + + // 스마트폰 가로 + 소형 태블릿 + mobile_landscape: { + minWidth: 480, + maxWidth: 767, + columns: 6, + rowHeight: 44, + gap: 8, + padding: 16, + label: "모바일 가로 (6칸)", + }, + + // 태블릿 세로 (iPad Mini ~ iPad Pro) + tablet_portrait: { + minWidth: 768, + maxWidth: 1023, + columns: 8, + rowHeight: 48, + gap: 12, + padding: 16, + label: "태블릿 세로 (8칸)", + }, + + // 태블릿 가로 + 데스크톱 (기본) + tablet_landscape: { + minWidth: 1024, + columns: 12, + rowHeight: 48, + gap: 16, + padding: 24, + label: "태블릿 가로 (12칸)", + }, +} as const; + +/** + * 기본 그리드 모드 + */ +export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape"; + +/** + * 뷰포트 너비로 모드 감지 + * GRID_BREAKPOINTS와 일치하는 브레이크포인트 사용 + */ +export function detectGridMode(viewportWidth: number): GridMode { + if (viewportWidth < 480) return "mobile_portrait"; + if (viewportWidth < 768) return "mobile_landscape"; + if (viewportWidth < 1024) return "tablet_portrait"; + return "tablet_landscape"; +} + +/** + * v5 레이아웃 (그리드 기반) + */ +export interface PopLayoutDataV5 { + version: "pop-5.0"; + + // 그리드 설정 + gridConfig: PopGridConfig; + + // 컴포넌트 정의 (ID → 정의) + components: Record; + + // 데이터 흐름 + dataFlow: PopDataFlow; + + // 전역 설정 + settings: PopGlobalSettingsV5; + + // 메타데이터 + metadata?: PopLayoutMetadata; + + // 모드별 오버라이드 (위치 변경용) + overrides?: { + mobile_portrait?: PopModeOverrideV5; + mobile_landscape?: PopModeOverrideV5; + tablet_portrait?: PopModeOverrideV5; + }; +} + +/** + * 그리드 설정 + */ +export interface PopGridConfig { + // 행 높이 (px) - 1행의 기본 높이 + rowHeight: number; // 기본 48px + + // 간격 (px) + gap: number; // 기본 8px + + // 패딩 (px) + padding: number; // 기본 16px +} + +/** + * 그리드 위치 (열/행 좌표) + */ +export interface PopGridPosition { + col: number; // 시작 열 (1부터, 최대 12) + row: number; // 시작 행 (1부터) + colSpan: number; // 차지할 열 수 (1~12) + rowSpan: number; // 차지할 행 수 (1~) +} + +/** + * v5 컴포넌트 정의 + */ +export interface PopComponentDefinitionV5 { + id: string; + type: PopComponentType; + label?: string; + + // 위치 (열/행 좌표) - 기본 모드(태블릿 가로 12칸) 기준 + position: PopGridPosition; + + // 모드별 표시/숨김 + visibility?: { + tablet_landscape?: boolean; + tablet_portrait?: boolean; + mobile_landscape?: boolean; + mobile_portrait?: boolean; + }; + + // 기존 속성 + dataBinding?: PopDataBinding; + style?: PopStylePreset; + config?: PopComponentConfig; +} + +/** + * Gap 프리셋 타입 + */ +export type GapPreset = "narrow" | "medium" | "wide"; + +/** + * Gap 프리셋 설정 + */ +export interface GapPresetConfig { + multiplier: number; + label: string; +} + +/** + * Gap 프리셋 상수 + */ +export const GAP_PRESETS: Record = { + narrow: { multiplier: 0.5, label: "좁게" }, + medium: { multiplier: 1.0, label: "보통" }, + wide: { multiplier: 1.5, label: "넓게" }, +}; + +/** + * v5 전역 설정 + */ +export interface PopGlobalSettingsV5 { + // 터치 최소 크기 (px) + touchTargetMin: number; // 기본 48 + + // 모드 + mode: "normal" | "industrial"; + + // Gap 프리셋 + gapPreset: GapPreset; // 기본 "medium" +} + +/** + * v5 모드별 오버라이드 + */ +export interface PopModeOverrideV5 { + // 컴포넌트별 위치 오버라이드 + positions?: Record>; + + // 컴포넌트별 숨김 + hidden?: string[]; +} + +// ======================================== +// v5 유틸리티 함수 +// ======================================== + +/** + * 빈 v5 레이아웃 생성 + */ +export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ + version: "pop-5.0", + gridConfig: { + rowHeight: 48, + gap: 8, + padding: 16, + }, + components: {}, + dataFlow: { connections: [] }, + settings: { + touchTargetMin: 48, + mode: "normal", + gapPreset: "medium", + }, +}); + +/** + * v5 레이아웃 여부 확인 + */ +export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { + return layout?.version === "pop-5.0"; +}; + +/** + * 컴포넌트 타입별 기본 크기 (칸 단위) + */ +export const DEFAULT_COMPONENT_GRID_SIZE: Record = { + "pop-sample": { colSpan: 2, rowSpan: 1 }, + "pop-text": { colSpan: 3, rowSpan: 1 }, +}; + +/** + * v5 컴포넌트 정의 생성 + */ +export const createComponentDefinitionV5 = ( + id: string, + type: PopComponentType, + position: PopGridPosition, + label?: string +): PopComponentDefinitionV5 => ({ + id, + type, + label, + position, +}); + +/** + * v5 레이아웃에 컴포넌트 추가 + */ +export const addComponentToV5Layout = ( + layout: PopLayoutDataV5, + componentId: string, + type: PopComponentType, + position: PopGridPosition, + label?: string +): PopLayoutDataV5 => { + const newLayout = { ...layout }; + + // 컴포넌트 정의 추가 + newLayout.components = { + ...newLayout.components, + [componentId]: createComponentDefinitionV5(componentId, type, position, label), + }; + + return newLayout; +}; + +// ======================================== +// 레거시 타입 별칭 (하위 호환 - 추후 제거) +// ======================================== +// 기존 코드에서 import 오류 방지용 + +/** @deprecated v5에서는 PopLayoutDataV5 사용 */ +export type PopLayoutData = PopLayoutDataV5; + +/** @deprecated v5에서는 PopComponentDefinitionV5 사용 */ +export type PopComponentDefinition = PopComponentDefinitionV5; + +/** @deprecated v5에서는 PopGridPosition 사용 */ +export type GridPosition = PopGridPosition; diff --git a/frontend/components/pop/designer/utils/gridUtils.ts b/frontend/components/pop/designer/utils/gridUtils.ts new file mode 100644 index 00000000..308ce730 --- /dev/null +++ b/frontend/components/pop/designer/utils/gridUtils.ts @@ -0,0 +1,562 @@ +import { + PopGridPosition, + GridMode, + GRID_BREAKPOINTS, + GridBreakpoint, + GapPreset, + GAP_PRESETS, + PopLayoutDataV5, + PopComponentDefinitionV5, +} from "../types/pop-layout"; + +// ======================================== +// Gap/Padding 조정 +// ======================================== + +/** + * Gap 프리셋에 따라 breakpoint의 gap/padding 조정 + * + * @param base 기본 breakpoint 설정 + * @param preset Gap 프리셋 ("narrow" | "medium" | "wide") + * @returns 조정된 breakpoint (gap, padding 계산됨) + */ +export function getAdjustedBreakpoint( + base: GridBreakpoint, + preset: GapPreset +): GridBreakpoint { + const multiplier = GAP_PRESETS[preset]?.multiplier || 1.0; + + return { + ...base, + gap: Math.round(base.gap * multiplier), + padding: Math.max(8, Math.round(base.padding * multiplier)), // 최소 8px + }; +} + +// ======================================== +// 그리드 위치 변환 +// ======================================== + +/** + * 12칸 기준 위치를 다른 모드로 변환 + */ +export function convertPositionToMode( + position: PopGridPosition, + targetMode: GridMode +): PopGridPosition { + const sourceColumns = 12; + const targetColumns = GRID_BREAKPOINTS[targetMode].columns; + + // 같은 칸 수면 그대로 반환 + if (sourceColumns === targetColumns) { + return position; + } + + const ratio = targetColumns / sourceColumns; + + // 열 위치 변환 + let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1); + let newColSpan = Math.max(1, Math.round(position.colSpan * ratio)); + + // 범위 초과 방지 + if (newCol > targetColumns) { + newCol = 1; + } + if (newCol + newColSpan - 1 > targetColumns) { + newColSpan = targetColumns - newCol + 1; + } + + return { + col: newCol, + row: position.row, + colSpan: Math.max(1, newColSpan), + rowSpan: position.rowSpan, + }; +} + +/** + * 여러 컴포넌트를 모드별로 변환하고 겹침 해결 + * + * v5.1 자동 줄바꿈: + * - 원본 col > targetColumns인 컴포넌트는 자동으로 맨 아래에 배치 + * - 정보 손실 방지: 모든 컴포넌트가 그리드 안에 배치됨 + */ +export function convertAndResolvePositions( + components: Array<{ id: string; position: PopGridPosition }>, + targetMode: GridMode +): Array<{ id: string; position: PopGridPosition }> { + // 엣지 케이스: 빈 배열 + if (components.length === 0) { + return []; + } + + const targetColumns = GRID_BREAKPOINTS[targetMode].columns; + + // 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존) + const converted = components.map(comp => ({ + id: comp.id, + position: convertPositionToMode(comp.position, targetMode), + originalCol: comp.position.col, // 원본 col 보존 + })); + + // 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리 + const normalComponents = converted.filter(c => c.originalCol <= targetColumns); + const overflowComponents = converted.filter(c => c.originalCol > targetColumns); + + // 3단계: 정상 컴포넌트의 최대 row 계산 + const maxRow = normalComponents.length > 0 + ? Math.max(...normalComponents.map(c => c.position.row + c.position.rowSpan - 1)) + : 0; + + // 4단계: 초과 컴포넌트들을 맨 아래에 순차 배치 + let currentRow = maxRow + 1; + const wrappedComponents = overflowComponents.map(comp => { + const wrappedPosition: PopGridPosition = { + col: 1, // 왼쪽 끝부터 시작 + row: currentRow, + colSpan: Math.min(comp.position.colSpan, targetColumns), // 최대 칸 수 제한 + rowSpan: comp.position.rowSpan, + }; + currentRow += comp.position.rowSpan; // 다음 행으로 이동 + + return { + id: comp.id, + position: wrappedPosition, + }; + }); + + // 5단계: 정상 + 줄바꿈 컴포넌트 병합 + const adjusted = [ + ...normalComponents.map(c => ({ id: c.id, position: c.position })), + ...wrappedComponents, + ]; + + // 6단계: 겹침 해결 (아래로 밀기) + return resolveOverlaps(adjusted, targetColumns); +} + +// ======================================== +// 검토 필요 판별 +// ======================================== + +/** + * 컴포넌트가 현재 모드에서 "검토 필요" 상태인지 확인 + * + * v5.1 검토 필요 기준: + * - 12칸 모드(기본 모드)가 아님 + * - 해당 모드에서 오버라이드가 없음 (아직 편집 안 함) + * + * @param currentMode 현재 그리드 모드 + * @param hasOverride 해당 모드에서 오버라이드 존재 여부 + * @returns true = 검토 필요, false = 검토 완료 또는 불필요 + */ +export function needsReview( + currentMode: GridMode, + hasOverride: boolean +): boolean { + const targetColumns = GRID_BREAKPOINTS[currentMode].columns; + + // 12칸 모드는 기본 모드이므로 검토 불필요 + if (targetColumns === 12) { + return false; + } + + // 오버라이드가 있으면 이미 편집함 → 검토 완료 + if (hasOverride) { + return false; + } + + // 오버라이드 없으면 → 검토 필요 + return true; +} + +/** + * @deprecated v5.1부터 needsReview() 사용 권장 + * + * 기존 isOutOfBounds는 "화면 밖" 개념이었으나, + * v5.1 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 배치됩니다. + * 대신 needsReview()로 "검토 필요" 여부를 판별하세요. + */ +export function isOutOfBounds( + originalPosition: PopGridPosition, + currentMode: GridMode, + overridePosition?: PopGridPosition | null +): boolean { + const targetColumns = GRID_BREAKPOINTS[currentMode].columns; + + // 12칸 모드면 초과 불가 + if (targetColumns === 12) { + return false; + } + + // 오버라이드가 있으면 오버라이드 위치로 판단 + if (overridePosition) { + return overridePosition.col > targetColumns; + } + + // 오버라이드 없으면 원본 col로 판단 + return originalPosition.col > targetColumns; +} + +// ======================================== +// 겹침 감지 및 해결 +// ======================================== + +/** + * 두 위치가 겹치는지 확인 + */ +export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean { + // 열 겹침 체크 + const aColEnd = a.col + a.colSpan - 1; + const bColEnd = b.col + b.colSpan - 1; + const colOverlap = !(aColEnd < b.col || bColEnd < a.col); + + // 행 겹침 체크 + const aRowEnd = a.row + a.rowSpan - 1; + const bRowEnd = b.row + b.rowSpan - 1; + const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row); + + return colOverlap && rowOverlap; +} + +/** + * 겹침 해결 (아래로 밀기) + */ +export function resolveOverlaps( + positions: Array<{ id: string; position: PopGridPosition }>, + columns: number +): Array<{ id: string; position: PopGridPosition }> { + // row, col 순으로 정렬 + const sorted = [...positions].sort((a, b) => + a.position.row - b.position.row || a.position.col - b.position.col + ); + + const resolved: Array<{ id: string; position: PopGridPosition }> = []; + + sorted.forEach((item) => { + let { row, col, colSpan, rowSpan } = item.position; + + // 열이 범위를 초과하면 조정 + if (col + colSpan - 1 > columns) { + colSpan = columns - col + 1; + } + + // 기존 배치와 겹치면 아래로 이동 + let attempts = 0; + const maxAttempts = 100; + + while (attempts < maxAttempts) { + const currentPos: PopGridPosition = { col, row, colSpan, rowSpan }; + const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position)); + + if (!hasOverlap) break; + + row++; + attempts++; + } + + resolved.push({ + id: item.id, + position: { col, row, colSpan, rowSpan }, + }); + }); + + return resolved; +} + +// ======================================== +// 좌표 변환 +// ======================================== + +/** + * 마우스 좌표 → 그리드 좌표 변환 + * + * CSS Grid 계산 방식: + * - 사용 가능 너비 = 캔버스 너비 - 패딩*2 - gap*(columns-1) + * - 각 칸 너비 = 사용 가능 너비 / columns + * - 셀 N의 시작 X = padding + (N-1) * (칸너비 + gap) + */ +export function mouseToGridPosition( + mouseX: number, + mouseY: number, + canvasRect: DOMRect, + columns: number, + rowHeight: number, + gap: number, + padding: number +): { col: number; row: number } { + // 캔버스 내 상대 위치 (패딩 영역 포함) + const relX = mouseX - canvasRect.left - padding; + const relY = mouseY - canvasRect.top - padding; + + // CSS Grid 1fr 계산과 동일하게 + // 사용 가능 너비 = 전체 너비 - 양쪽 패딩 - (칸 사이 gap) + const availableWidth = canvasRect.width - padding * 2 - gap * (columns - 1); + const colWidth = availableWidth / columns; + + // 각 셀의 실제 간격 (셀 너비 + gap) + const cellStride = colWidth + gap; + + // 그리드 좌표 계산 (1부터 시작) + // relX를 cellStride로 나누면 몇 번째 칸인지 알 수 있음 + const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1)); + const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1); + + return { col, row }; +} + +/** + * 그리드 좌표 → 픽셀 좌표 변환 + */ +export function gridToPixelPosition( + col: number, + row: number, + colSpan: number, + rowSpan: number, + canvasWidth: number, + columns: number, + rowHeight: number, + gap: number, + padding: number +): { x: number; y: number; width: number; height: number } { + const totalGap = gap * (columns - 1); + const colWidth = (canvasWidth - padding * 2 - totalGap) / columns; + + return { + x: padding + (col - 1) * (colWidth + gap), + y: padding + (row - 1) * (rowHeight + gap), + width: colWidth * colSpan + gap * (colSpan - 1), + height: rowHeight * rowSpan + gap * (rowSpan - 1), + }; +} + +// ======================================== +// 위치 검증 +// ======================================== + +/** + * 위치가 그리드 범위 내에 있는지 확인 + */ +export function isValidPosition( + position: PopGridPosition, + columns: number +): boolean { + return ( + position.col >= 1 && + position.row >= 1 && + position.colSpan >= 1 && + position.rowSpan >= 1 && + position.col + position.colSpan - 1 <= columns + ); +} + +/** + * 위치를 그리드 범위 내로 조정 + */ +export function clampPosition( + position: PopGridPosition, + columns: number +): PopGridPosition { + let { col, row, colSpan, rowSpan } = position; + + // 최소값 보장 + col = Math.max(1, col); + row = Math.max(1, row); + colSpan = Math.max(1, colSpan); + rowSpan = Math.max(1, rowSpan); + + // 열 범위 초과 방지 + if (col + colSpan - 1 > columns) { + if (col > columns) { + col = 1; + } + colSpan = columns - col + 1; + } + + return { col, row, colSpan, rowSpan }; +} + +// ======================================== +// 자동 배치 +// ======================================== + +/** + * 다음 빈 위치 찾기 + */ +export function findNextEmptyPosition( + existingPositions: PopGridPosition[], + colSpan: number, + rowSpan: number, + columns: number +): PopGridPosition { + let row = 1; + let col = 1; + + const maxAttempts = 1000; + let attempts = 0; + + while (attempts < maxAttempts) { + const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan }; + + // 범위 체크 + if (col + colSpan - 1 > columns) { + col = 1; + row++; + continue; + } + + // 겹침 체크 + const hasOverlap = existingPositions.some(pos => + isOverlapping(candidatePos, pos) + ); + + if (!hasOverlap) { + return candidatePos; + } + + // 다음 위치로 이동 + col++; + if (col + colSpan - 1 > columns) { + col = 1; + row++; + } + + attempts++; + } + + // 실패 시 마지막 행에 배치 + return { col: 1, row: row + 1, colSpan, rowSpan }; +} + +/** + * 컴포넌트들을 자동으로 배치 + */ +export function autoLayoutComponents( + components: Array<{ id: string; colSpan: number; rowSpan: number }>, + columns: number +): Array<{ id: string; position: PopGridPosition }> { + const result: Array<{ id: string; position: PopGridPosition }> = []; + + let currentRow = 1; + let currentCol = 1; + + components.forEach(comp => { + // 현재 행에 공간이 부족하면 다음 행으로 + if (currentCol + comp.colSpan - 1 > columns) { + currentRow++; + currentCol = 1; + } + + result.push({ + id: comp.id, + position: { + col: currentCol, + row: currentRow, + colSpan: comp.colSpan, + rowSpan: comp.rowSpan, + }, + }); + + currentCol += comp.colSpan; + }); + + return result; +} + +// ======================================== +// 유효 위치 계산 (통합 함수) +// ======================================== + +/** + * 컴포넌트의 유효 위치를 계산합니다. + * 우선순위: 1. 오버라이드 → 2. 자동 재배치 → 3. 원본 위치 + * + * @param componentId 컴포넌트 ID + * @param layout 전체 레이아웃 데이터 + * @param mode 현재 그리드 모드 + * @param autoResolvedPositions 미리 계산된 자동 재배치 위치 (선택적) + */ +export function getEffectiveComponentPosition( + componentId: string, + layout: PopLayoutDataV5, + mode: GridMode, + autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }> +): PopGridPosition | null { + const component = layout.components[componentId]; + if (!component) return null; + + // 1순위: 오버라이드가 있으면 사용 + const override = layout.overrides?.[mode]?.positions?.[componentId]; + if (override) { + return { ...component.position, ...override }; + } + + // 2순위: 자동 재배치된 위치 사용 + if (autoResolvedPositions) { + const autoResolved = autoResolvedPositions.find(p => p.id === componentId); + if (autoResolved) { + return autoResolved.position; + } + } else { + // 자동 재배치 직접 계산 + const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({ + id, + position: comp.position, + })); + const resolved = convertAndResolvePositions(componentsArray, mode); + const autoResolved = resolved.find(p => p.id === componentId); + if (autoResolved) { + return autoResolved.position; + } + } + + // 3순위: 원본 위치 (12칸 모드) + return component.position; +} + +/** + * 모든 컴포넌트의 유효 위치를 일괄 계산합니다. + * 숨김 처리된 컴포넌트는 제외됩니다. + * + * v5.1: 자동 줄바꿈 시스템으로 인해 모든 컴포넌트가 그리드 안에 배치되므로 + * "화면 밖" 개념이 제거되었습니다. + */ +export function getAllEffectivePositions( + layout: PopLayoutDataV5, + mode: GridMode +): Map { + const result = new Map(); + + // 숨김 처리된 컴포넌트 ID 목록 + const hiddenIds = layout.overrides?.[mode]?.hidden || []; + + // 자동 재배치 위치 미리 계산 + const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({ + id, + position: comp.position, + })); + const autoResolvedPositions = convertAndResolvePositions(componentsArray, mode); + + // 각 컴포넌트의 유효 위치 계산 + Object.keys(layout.components).forEach(componentId => { + // 숨김 처리된 컴포넌트는 제외 + if (hiddenIds.includes(componentId)) { + return; + } + + const position = getEffectiveComponentPosition( + componentId, + layout, + mode, + autoResolvedPositions + ); + + // v5.1: 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 있음 + // 따라서 추가 필터링 불필요 + if (position) { + result.set(componentId, position); + } + }); + + return result; +} diff --git a/frontend/components/pop/management/PopCategoryTree.tsx b/frontend/components/pop/management/PopCategoryTree.tsx new file mode 100644 index 00000000..0689d699 --- /dev/null +++ b/frontend/components/pop/management/PopCategoryTree.tsx @@ -0,0 +1,1175 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { + ChevronRight, + ChevronDown, + ChevronUp, + Folder, + FolderOpen, + Monitor, + Plus, + MoreVertical, + Edit, + Trash2, + Loader2, + RefreshCw, + FolderPlus, + MoveRight, + ArrowUp, + ArrowDown, + Search, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { ScreenDefinition } from "@/types/screen"; +import { apiClient } from "@/lib/api/client"; +import { + PopScreenGroup, + getPopScreenGroups, + createPopScreenGroup, + updatePopScreenGroup, + deletePopScreenGroup, + ensurePopRootGroup, + buildPopGroupTree, +} from "@/lib/api/popScreenGroup"; + +// ============================================================ +// 타입 정의 +// ============================================================ + +interface PopCategoryTreeProps { + screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록 + selectedScreen: ScreenDefinition | null; + onScreenSelect: (screen: ScreenDefinition) => void; + onScreenDesign: (screen: ScreenDefinition) => void; + onGroupSelect?: (group: PopScreenGroup | null) => void; + searchTerm?: string; +} + +interface TreeNodeProps { + group: PopScreenGroup; + level: number; + expandedGroups: Set; + onToggle: (groupId: number) => void; + selectedGroupId: number | null; + selectedScreenId: number | null; + onGroupSelect: (group: PopScreenGroup) => void; + onScreenSelect: (screen: ScreenDefinition) => void; + onScreenDesign: (screen: ScreenDefinition) => void; + onEditGroup: (group: PopScreenGroup) => void; + onDeleteGroup: (group: PopScreenGroup) => void; + onAddSubGroup: (parentGroup: PopScreenGroup) => void; + screensMap: Map; + // 화면 이동/삭제 관련 + onOpenMoveModal: (screen: ScreenDefinition, fromGroupId: number | null) => void; + onRemoveScreenFromGroup: (screen: ScreenDefinition, groupId: number) => void; + // 순서 변경 관련 + siblingGroups: PopScreenGroup[]; // 같은 레벨의 그룹들 + onMoveGroupUp: (group: PopScreenGroup) => void; + onMoveGroupDown: (group: PopScreenGroup) => void; + onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void; + onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void; + onDeleteScreen: (screen: ScreenDefinition) => void; +} + +// ============================================================ +// 트리 노드 컴포넌트 +// ============================================================ + +function TreeNode({ + group, + level, + onOpenMoveModal, + onRemoveScreenFromGroup, + siblingGroups, + onMoveGroupUp, + onMoveGroupDown, + onMoveScreenUp, + onMoveScreenDown, + onDeleteScreen, + expandedGroups, + onToggle, + selectedGroupId, + selectedScreenId, + onGroupSelect, + onScreenSelect, + onScreenDesign, + onEditGroup, + onDeleteGroup, + onAddSubGroup, + screensMap, +}: TreeNodeProps) { + const isExpanded = expandedGroups.has(group.id); + const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0); + const isSelected = selectedGroupId === group.id; + + // 그룹에 연결된 화면 목록 + const groupScreens = useMemo(() => { + if (!group.screens) return []; + return group.screens + .map((gs) => screensMap.get(gs.screen_id)) + .filter((s): s is ScreenDefinition => s !== undefined); + }, [group.screens, screensMap]); + + // 루트 레벨(POP 화면)인지 확인 + const isRootLevel = level === 0; + + // 그룹 순서 변경 가능 여부 계산 + const groupIndex = siblingGroups.findIndex((g) => g.id === group.id); + const canMoveGroupUp = groupIndex > 0; + const canMoveGroupDown = groupIndex < siblingGroups.length - 1; + + return ( +
+ {/* 그룹 노드 */} +
onGroupSelect(group)} + > + {/* 트리 연결 표시 (하위 레벨만) */} + {level > 0 && ( + + )} + + {/* 확장/축소 버튼 */} + + + {/* 폴더 아이콘 - 루트는 다른 색상 */} + {isExpanded && hasChildren ? ( + + ) : ( + + )} + + {/* 그룹명 - 루트는 볼드체 */} + {group.group_name} + + {/* 화면 수 배지 */} + {group.screen_count && group.screen_count > 0 && ( + + {group.screen_count} + + )} + + {/* 더보기 메뉴 */} + + + + + + onAddSubGroup(group)}> + + 하위 그룹 추가 + + onEditGroup(group)}> + + 수정 + + + onMoveGroupUp(group)} + disabled={!canMoveGroupUp} + > + + 위로 이동 + + onMoveGroupDown(group)} + disabled={!canMoveGroupDown} + > + + 아래로 이동 + + + onDeleteGroup(group)} + > + + 삭제 + + + +
+ + {/* 확장된 경우 하위 요소 렌더링 */} + {isExpanded && ( + <> + {/* 하위 그룹 */} + {group.children?.map((child) => ( + + ))} + + {/* 그룹에 연결된 화면 */} + {groupScreens.map((screen, screenIndex) => { + const canMoveScreenUp = screenIndex > 0; + const canMoveScreenDown = screenIndex < groupScreens.length - 1; + + return ( +
onScreenSelect(screen)} + onDoubleClick={() => onScreenDesign(screen)} + > + {/* 트리 연결 표시 */} + + + {screen.screenName} + #{screen.screenId} + + {/* 더보기 메뉴 (폴더와 동일한 스타일) */} + + + + + + onScreenDesign(screen)}> + + 설계 + + + onMoveScreenUp(screen, group.id)} + disabled={!canMoveScreenUp} + > + + 위로 이동 + + onMoveScreenDown(screen, group.id)} + disabled={!canMoveScreenDown} + > + + 아래로 이동 + + + onOpenMoveModal(screen, group.id)}> + + 다른 카테고리로 이동 + + + onRemoveScreenFromGroup(screen, group.id)} + > + + 그룹에서 제거 + + onDeleteScreen(screen)} + > + + 화면 삭제 + + + +
+ ); + })} + + )} +
+ ); +} + +// ============================================================ +// 메인 컴포넌트 +// ============================================================ + +export function PopCategoryTree({ + screens, + selectedScreen, + onScreenSelect, + onScreenDesign, + onGroupSelect, + searchTerm = "", +}: PopCategoryTreeProps) { + // 상태 관리 + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [selectedGroupId, setSelectedGroupId] = useState(null); + + // 그룹 모달 상태 + const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); + const [editingGroup, setEditingGroup] = useState(null); + const [parentGroupId, setParentGroupId] = useState(null); + const [groupFormData, setGroupFormData] = useState({ + group_name: "", + group_code: "", + description: "", + icon: "", + }); + + // 그룹 삭제 다이얼로그 상태 + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [deletingGroup, setDeletingGroup] = useState(null); + + // 화면 삭제 다이얼로그 상태 + const [isScreenDeleteDialogOpen, setIsScreenDeleteDialogOpen] = useState(false); + const [deletingScreen, setDeletingScreen] = useState(null); + + // 이동 모달 상태 + const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); + const [movingScreen, setMovingScreen] = useState(null); + const [movingFromGroupId, setMovingFromGroupId] = useState(null); + const [moveSearchTerm, setMoveSearchTerm] = useState(""); + + // 화면 맵 생성 (screen_id로 빠르게 조회) + const screensMap = useMemo(() => { + const map = new Map(); + screens.forEach((s) => map.set(s.screenId, s)); + return map; + }, [screens]); + + // 그룹 데이터 로드 + const loadGroups = useCallback(async () => { + try { + setLoading(true); + + // 먼저 POP 루트 그룹 확보 + await ensurePopRootGroup(); + + // 그룹 목록 조회 + const data = await getPopScreenGroups(searchTerm); + setGroups(data); + + // 첫 로드 시 루트 그룹들 자동 확장 + if (expandedGroups.size === 0 && data.length > 0) { + const rootIds = data + .filter((g) => g.hierarchy_path === "POP" || g.hierarchy_path?.split("/").length === 2) + .map((g) => g.id); + setExpandedGroups(new Set(rootIds)); + } + } catch (error) { + console.error("POP 그룹 로드 실패:", error); + toast.error("그룹 목록 로드에 실패했습니다."); + } finally { + setLoading(false); + } + }, [searchTerm]); + + useEffect(() => { + loadGroups(); + }, [loadGroups]); + + // 트리 구조로 변환 + const treeData = useMemo(() => buildPopGroupTree(groups), [groups]); + + // 그룹 토글 + const handleToggle = (groupId: number) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }; + + // 그룹 선택 + const handleGroupSelect = (group: PopScreenGroup) => { + setSelectedGroupId(group.id); + onGroupSelect?.(group); + }; + + // 그룹 생성/수정 모달 열기 + const openGroupModal = (parentGroup?: PopScreenGroup, editGroup?: PopScreenGroup) => { + if (editGroup) { + setEditingGroup(editGroup); + setParentGroupId(editGroup.parent_group_id || null); + setGroupFormData({ + group_name: editGroup.group_name, + group_code: editGroup.group_code, + description: editGroup.description || "", + icon: editGroup.icon || "", + }); + } else { + setEditingGroup(null); + setParentGroupId(parentGroup?.id || null); + setGroupFormData({ + group_name: "", + group_code: "", + description: "", + icon: "", + }); + } + setIsGroupModalOpen(true); + }; + + // 그룹 저장 + const handleSaveGroup = async () => { + if (!groupFormData.group_name || !groupFormData.group_code) { + toast.error("그룹명과 그룹코드는 필수입니다."); + return; + } + + try { + if (editingGroup) { + // 수정 + const result = await updatePopScreenGroup(editingGroup.id, { + group_name: groupFormData.group_name, + description: groupFormData.description, + icon: groupFormData.icon, + }); + if (result.success) { + toast.success("그룹이 수정되었습니다."); + loadGroups(); + } else { + toast.error(result.message || "수정에 실패했습니다."); + } + } else { + // 생성 + const result = await createPopScreenGroup({ + group_name: groupFormData.group_name, + group_code: groupFormData.group_code, + description: groupFormData.description, + icon: groupFormData.icon, + parent_group_id: parentGroupId, + }); + if (result.success) { + toast.success("그룹이 생성되었습니다."); + loadGroups(); + } else { + toast.error(result.message || "생성에 실패했습니다."); + } + } + setIsGroupModalOpen(false); + } catch (error) { + console.error("그룹 저장 실패:", error); + toast.error("그룹 저장에 실패했습니다."); + } + }; + + // 그룹 삭제 + const handleDeleteGroup = async () => { + if (!deletingGroup) return; + + try { + const result = await deletePopScreenGroup(deletingGroup.id); + if (result.success) { + toast.success("그룹이 삭제되었습니다."); + loadGroups(); + if (selectedGroupId === deletingGroup.id) { + setSelectedGroupId(null); + onGroupSelect?.(null); + } + } else { + toast.error(result.message || "삭제에 실패했습니다."); + } + } catch (error) { + console.error("그룹 삭제 실패:", error); + toast.error("그룹 삭제에 실패했습니다."); + } finally { + setIsDeleteDialogOpen(false); + setDeletingGroup(null); + } + }; + + // 화면을 그룹으로 이동 (기존 연결 삭제 후 새 연결 추가) + const handleMoveScreenToGroup = async (screen: ScreenDefinition, targetGroup: PopScreenGroup) => { + try { + // 1. 기존 연결 정보 찾기 (모든 그룹에서 해당 화면의 연결 찾기) + let existingLinkId: number | null = null; + for (const g of groups) { + const screenLink = g.screens?.find((s) => s.screen_id === screen.screenId); + if (screenLink) { + existingLinkId = screenLink.id; + break; + } + } + + // 2. 기존 연결이 있으면 삭제 + if (existingLinkId) { + await apiClient.delete(`/screen-groups/group-screens/${existingLinkId}`); + } + + // 3. 새 그룹에 연결 추가 + const response = await apiClient.post("/screen-groups/group-screens", { + group_id: targetGroup.id, + screen_id: screen.screenId, + screen_role: "main", + display_order: 0, + is_default: false, + }); + + if (response.data.success) { + toast.success(`"${screen.screenName}"을(를) "${(targetGroup as any)._displayName || targetGroup.group_name}"으로 이동했습니다.`); + loadGroups(); // 그룹 목록 새로고침 + } else { + throw new Error(response.data.message || "이동 실패"); + } + } catch (error: any) { + console.error("화면 이동 실패:", error); + toast.error(error.response?.data?.message || error.message || "화면 이동에 실패했습니다."); + } + }; + + // 그룹에서 화면 제거 + const handleRemoveScreenFromGroup = async (screen: ScreenDefinition, groupId: number) => { + try { + // 해당 그룹에서 화면 연결 정보 찾기 + const targetGroup = groups.find((g) => g.id === groupId); + const screenLink = targetGroup?.screens?.find((s) => s.screen_id === screen.screenId); + + if (!screenLink) { + toast.error("연결 정보를 찾을 수 없습니다."); + return; + } + + await apiClient.delete(`/screen-groups/group-screens/${screenLink.id}`); + toast.success(`"${screen.screenName}"을(를) 그룹에서 제거했습니다.`); + loadGroups(); + } catch (error: any) { + console.error("화면 제거 실패:", error); + toast.error(error.response?.data?.message || error.message || "화면 제거에 실패했습니다."); + } + }; + + // 화면 삭제 다이얼로그 열기 + 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 { + // 같은 부모의 형제 그룹들 찾기 + const parentId = targetGroup.parent_id; + const siblingGroups = groups + .filter((g) => g.parent_id === parentId) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + + const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); + if (currentIndex <= 0) return; + + const prevGroup = siblingGroups[currentIndex - 1]; + + // 두 그룹의 display_order 교환 + await Promise.all([ + apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { + display_order: prevGroup.display_order || currentIndex - 1 + }), + apiClient.put(`/screen-groups/groups/${prevGroup.id}`, { + display_order: targetGroup.display_order || currentIndex + }), + ]); + + loadGroups(); + } catch (error: any) { + console.error("그룹 순서 변경 실패:", error); + toast.error("순서 변경에 실패했습니다."); + } + }; + + // 그룹 순서 아래로 이동 + const handleMoveGroupDown = async (targetGroup: PopScreenGroup) => { + try { + // 같은 부모의 형제 그룹들 찾기 + const parentId = targetGroup.parent_id; + const siblingGroups = groups + .filter((g) => g.parent_id === parentId) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + + const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); + if (currentIndex >= siblingGroups.length - 1) return; + + const nextGroup = siblingGroups[currentIndex + 1]; + + // 두 그룹의 display_order 교환 + await Promise.all([ + apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { + display_order: nextGroup.display_order || currentIndex + 1 + }), + apiClient.put(`/screen-groups/groups/${nextGroup.id}`, { + display_order: targetGroup.display_order || currentIndex + }), + ]); + + loadGroups(); + } catch (error: any) { + console.error("그룹 순서 변경 실패:", error); + toast.error("순서 변경에 실패했습니다."); + } + }; + + // 화면 순서 위로 이동 + const handleMoveScreenUp = async (screen: ScreenDefinition, groupId: number) => { + try { + const targetGroup = groups.find((g) => g.id === groupId); + if (!targetGroup?.screens) return; + + const sortedScreens = [...targetGroup.screens].sort( + (a, b) => (a.display_order || 0) - (b.display_order || 0) + ); + const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); + if (currentIndex <= 0) return; + + const currentLink = sortedScreens[currentIndex]; + const prevLink = sortedScreens[currentIndex - 1]; + + // 두 화면의 display_order 교환 + await Promise.all([ + apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { + display_order: prevLink.display_order || currentIndex - 1 + }), + apiClient.put(`/screen-groups/group-screens/${prevLink.id}`, { + display_order: currentLink.display_order || currentIndex + }), + ]); + + loadGroups(); + } catch (error: any) { + console.error("화면 순서 변경 실패:", error); + toast.error("순서 변경에 실패했습니다."); + } + }; + + // 화면 순서 아래로 이동 + const handleMoveScreenDown = async (screen: ScreenDefinition, groupId: number) => { + try { + const targetGroup = groups.find((g) => g.id === groupId); + if (!targetGroup?.screens) return; + + const sortedScreens = [...targetGroup.screens].sort( + (a, b) => (a.display_order || 0) - (b.display_order || 0) + ); + const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); + if (currentIndex >= sortedScreens.length - 1) return; + + const currentLink = sortedScreens[currentIndex]; + const nextLink = sortedScreens[currentIndex + 1]; + + // 두 화면의 display_order 교환 + await Promise.all([ + apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { + display_order: nextLink.display_order || currentIndex + 1 + }), + apiClient.put(`/screen-groups/group-screens/${nextLink.id}`, { + display_order: currentLink.display_order || currentIndex + }), + ]); + + loadGroups(); + } catch (error: any) { + console.error("화면 순서 변경 실패:", error); + toast.error("순서 변경에 실패했습니다."); + } + }; + + // 미분류 화면 (그룹에 연결되지 않은 화면) + const ungroupedScreens = useMemo(() => { + const groupedScreenIds = new Set(); + groups.forEach((g) => { + g.screens?.forEach((gs) => groupedScreenIds.add(gs.screen_id)); + }); + return screens.filter((s) => !groupedScreenIds.has(s.screenId)); + }, [groups, screens]); + + // 전체 그룹 평탄화 (이동 드롭다운용) + const flattenedGroups = useMemo(() => { + const result: PopScreenGroup[] = []; + const flatten = (groups: PopScreenGroup[], parentName?: string) => { + groups.forEach((g) => { + // 표시 이름에 부모 경로 추가 + const displayGroup = { + ...g, + _displayName: parentName ? `${parentName} > ${g.group_name}` : g.group_name + }; + result.push(displayGroup); + if (g.children && g.children.length > 0) { + flatten(g.children, displayGroup._displayName); + } + }); + }; + flatten(treeData); + return result; + }, [treeData]); + + // 이동 모달 열기 + const openMoveModal = (screen: ScreenDefinition, fromGroupId: number | null) => { + setMovingScreen(screen); + setMovingFromGroupId(fromGroupId); + setMoveSearchTerm(""); + setIsMoveModalOpen(true); + }; + + // 이동 모달에서 그룹 선택 처리 + const handleMoveToSelectedGroup = async (targetGroup: PopScreenGroup) => { + if (!movingScreen) return; + + await handleMoveScreenToGroup(movingScreen, targetGroup); + setIsMoveModalOpen(false); + setMovingScreen(null); + setMovingFromGroupId(null); + }; + + // 이동 모달용 필터링된 그룹 목록 + const filteredMoveGroups = useMemo(() => { + if (!moveSearchTerm) return flattenedGroups; + const searchLower = moveSearchTerm.toLowerCase(); + return flattenedGroups.filter((g: any) => + (g._displayName || g.group_name).toLowerCase().includes(searchLower) + ); + }, [flattenedGroups, moveSearchTerm]); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 헤더 */} +
+

POP 카테고리

+
+ + +
+
+ + {/* 트리 영역 */} + +
+ {treeData.length === 0 && ungroupedScreens.length === 0 ? ( +
+ 카테고리가 없습니다. +
+ +
+ ) : ( + <> + {/* 트리 렌더링 */} + {treeData.map((group) => ( + openGroupModal(undefined, g)} + onDeleteGroup={(g) => { + setDeletingGroup(g); + setIsDeleteDialogOpen(true); + }} + onAddSubGroup={(g) => openGroupModal(g)} + screensMap={screensMap} + onOpenMoveModal={openMoveModal} + onRemoveScreenFromGroup={handleRemoveScreenFromGroup} + siblingGroups={treeData} + onMoveGroupUp={handleMoveGroupUp} + onMoveGroupDown={handleMoveGroupDown} + onMoveScreenUp={handleMoveScreenUp} + onMoveScreenDown={handleMoveScreenDown} + onDeleteScreen={handleDeleteScreen} + /> + ))} + + {/* 미분류 화면 */} + {ungroupedScreens.length > 0 && ( +
+
+ 미분류 ({ungroupedScreens.length}) +
+ {ungroupedScreens.map((screen) => ( +
onScreenSelect(screen)} + onDoubleClick={() => onScreenDesign(screen)} + > + + {screen.screenName} + #{screen.screenId} + + {/* 더보기 메뉴 */} + + + + + + onScreenDesign(screen)}> + + 설계 + + + openMoveModal(screen, null)}> + + 카테고리로 이동 + + + handleDeleteScreen(screen)} + > + + 화면 삭제 + + + +
+ ))} +
+ )} + + )} +
+
+ + {/* 그룹 생성/수정 모달 */} + + + + + {editingGroup ? "카테고리 수정" : "새 카테고리"} + + + {editingGroup ? "카테고리 정보를 수정합니다." : "POP 화면을 분류할 카테고리를 추가합니다."} + + + +
+
+ + setGroupFormData((prev) => ({ ...prev, group_name: e.target.value }))} + placeholder="예: 생산관리" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + {!editingGroup && ( +
+ + setGroupFormData((prev) => ({ ...prev, group_code: e.target.value.toUpperCase() }))} + placeholder="예: PRODUCTION" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 영문 대문자와 밑줄만 사용 가능합니다. +

+
+ )} + +
+ + setGroupFormData((prev) => ({ ...prev, description: e.target.value }))} + placeholder="카테고리에 대한 설명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + + + + +
+
+ + {/* 삭제 확인 다이얼로그 */} + + + + 카테고리 삭제 + + "{deletingGroup?.group_name}" 카테고리를 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+ + {/* 화면 이동 모달 */} + + + + + 카테고리로 이동 + + + "{movingScreen?.screenName}" 화면을 이동할 카테고리를 선택하세요. + + + + {/* 검색 입력 */} +
+ + setMoveSearchTerm(e.target.value)} + className="pl-9 h-9 text-sm" + /> +
+ + {/* 카테고리 트리 목록 */} + +
+ {filteredMoveGroups.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + filteredMoveGroups.map((group: any) => { + const isCurrentGroup = group.id === movingFromGroupId; + const displayName = group._displayName || group.group_name; + const depth = (displayName.match(/>/g) || []).length; + + return ( + + ); + }) + )} +
+
+ + + + +
+
+ + {/* 화면 삭제 확인 다이얼로그 */} + + + + 화면 삭제 + + "{deletingScreen?.screenName}" 화면을 삭제하시겠습니까? +
+ + 삭제된 화면은 휴지통으로 이동되며, 나중에 복원할 수 있습니다. + +
+
+ + 취소 + + 삭제 + + +
+
+
+ ); +} diff --git a/frontend/components/pop/management/PopScreenFlowView.tsx b/frontend/components/pop/management/PopScreenFlowView.tsx new file mode 100644 index 00000000..4b01076e --- /dev/null +++ b/frontend/components/pop/management/PopScreenFlowView.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { + ReactFlow, + Node, + Edge, + Position, + MarkerType, + Background, + Controls, + MiniMap, + useNodesState, + useEdgesState, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { cn } from "@/lib/utils"; +import { Monitor, Layers, ArrowRight, Loader2 } from "lucide-react"; +import { ScreenDefinition } from "@/types/screen"; +import { screenApi } from "@/lib/api/screen"; + +// ============================================================ +// 타입 정의 +// ============================================================ + +interface PopScreenFlowViewProps { + screen: ScreenDefinition | null; + className?: string; + onSubScreenSelect?: (subScreenId: string) => void; +} + +interface PopLayoutData { + version?: string; + sections?: any[]; + mainScreen?: { + id: string; + name: string; + }; + subScreens?: SubScreen[]; + flow?: FlowConnection[]; +} + +interface SubScreen { + id: string; + name: string; + type: "modal" | "drawer" | "fullscreen"; + triggerFrom?: string; // 어느 화면/버튼에서 트리거되는지 +} + +interface FlowConnection { + from: string; + to: string; + trigger?: string; + label?: string; +} + +// ============================================================ +// 커스텀 노드 컴포넌트 +// ============================================================ + +interface ScreenNodeData { + label: string; + type: "main" | "modal" | "drawer" | "fullscreen"; + isMain?: boolean; +} + +function ScreenNode({ data }: { data: ScreenNodeData }) { + const isMain = data.type === "main" || data.isMain; + + return ( +
+
+ {isMain ? ( + + ) : ( + + )} + + {isMain ? "메인 화면" : data.type === "modal" ? "모달" : data.type === "drawer" ? "드로어" : "전체화면"} + +
+
{data.label}
+
+ ); +} + +const nodeTypes = { + screenNode: ScreenNode, +}; + +// ============================================================ +// 메인 컴포넌트 +// ============================================================ + +export function PopScreenFlowView({ screen, className, onSubScreenSelect }: PopScreenFlowViewProps) { + const [loading, setLoading] = useState(false); + const [layoutData, setLayoutData] = useState(null); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + // 레이아웃 데이터 로드 + useEffect(() => { + if (!screen) { + setLayoutData(null); + setNodes([]); + setEdges([]); + return; + } + + const loadLayout = async () => { + try { + setLoading(true); + const layout = await screenApi.getLayoutPop(screen.screenId); + + if (layout && layout.version === "pop-1.0") { + setLayoutData(layout); + } else { + setLayoutData(null); + } + } catch (error) { + console.error("레이아웃 로드 실패:", error); + setLayoutData(null); + } finally { + setLoading(false); + } + }; + + loadLayout(); + }, [screen]); + + // 레이아웃 데이터에서 노드/엣지 생성 + useEffect(() => { + if (!layoutData || !screen) { + return; + } + + const newNodes: Node[] = []; + const newEdges: Edge[] = []; + + // 메인 화면 노드 + const mainNodeId = "main"; + newNodes.push({ + id: mainNodeId, + type: "screenNode", + position: { x: 50, y: 100 }, + data: { + label: screen.screenName, + type: "main", + isMain: true, + }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + }); + + // 하위 화면 노드들 + const subScreens = layoutData.subScreens || []; + const horizontalGap = 200; + const verticalGap = 100; + + subScreens.forEach((subScreen, index) => { + // 세로로 나열, 여러 개일 경우 열 분리 + const col = Math.floor(index / 3); + const row = index % 3; + + newNodes.push({ + id: subScreen.id, + type: "screenNode", + position: { + x: 300 + col * horizontalGap, + y: 50 + row * verticalGap, + }, + data: { + label: subScreen.name, + type: subScreen.type || "modal", + }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + }); + }); + + // 플로우 연결 (flow 배열 또는 triggerFrom 기반) + const flows = layoutData.flow || []; + + if (flows.length > 0) { + // 명시적 flow 배열 사용 + flows.forEach((flow, index) => { + newEdges.push({ + id: `edge-${index}`, + source: flow.from, + target: flow.to, + type: "smoothstep", + animated: true, + label: flow.label || flow.trigger, + markerEnd: { + type: MarkerType.ArrowClosed, + color: "#888", + }, + style: { stroke: "#888", strokeWidth: 2 }, + }); + }); + } else { + // triggerFrom 기반으로 엣지 생성 (기본: 메인 → 서브) + subScreens.forEach((subScreen, index) => { + const sourceId = subScreen.triggerFrom || mainNodeId; + newEdges.push({ + id: `edge-${index}`, + source: sourceId, + target: subScreen.id, + type: "smoothstep", + animated: true, + markerEnd: { + type: MarkerType.ArrowClosed, + color: "#888", + }, + style: { stroke: "#888", strokeWidth: 2 }, + }); + }); + } + + setNodes(newNodes); + setEdges(newEdges); + }, [layoutData, screen, setNodes, setEdges]); + + // 노드 클릭 핸들러 + const onNodeClick = useCallback( + (_: React.MouseEvent, node: Node) => { + if (node.id !== "main" && onSubScreenSelect) { + onSubScreenSelect(node.id); + } + }, + [onSubScreenSelect] + ); + + // 레이아웃 또는 하위 화면이 없는 경우 + const hasSubScreens = layoutData?.subScreens && layoutData.subScreens.length > 0; + + if (!screen) { + return ( +
+
+

화면 흐름

+
+
+
+ +

화면을 선택하면 흐름이 표시됩니다.

+
+
+
+ ); + } + + if (loading) { + return ( +
+
+

화면 흐름

+
+
+ +
+
+ ); + } + + if (!layoutData) { + return ( +
+
+

화면 흐름

+
+
+
+ +

POP 레이아웃이 없습니다.

+
+
+
+ ); + } + + return ( +
+
+
+

화면 흐름

+ + {screen.screenName} + +
+ {!hasSubScreens && ( + + 하위 화면 없음 + + )} +
+ +
+ {hasSubScreens ? ( + + + + (node.data?.isMain ? "#3b82f6" : "#9ca3af")} + maskColor="rgba(0, 0, 0, 0.1)" + className="!bg-muted/50" + /> + + ) : ( + // 하위 화면이 없으면 간단한 단일 노드 표시 +
+
+
+ + {screen.screenName} +
+

+ 이 화면에 연결된 하위 화면(모달)이 없습니다. +
+ 화면 설정에서 하위 화면을 추가할 수 있습니다. +

+
+
+ )} +
+
+ ); +} diff --git a/frontend/components/pop/management/PopScreenPreview.tsx b/frontend/components/pop/management/PopScreenPreview.tsx new file mode 100644 index 00000000..66b605ab --- /dev/null +++ b/frontend/components/pop/management/PopScreenPreview.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { cn } from "@/lib/utils"; +import { Smartphone, Tablet, Loader2, ExternalLink, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScreenDefinition } from "@/types/screen"; +import { screenApi } from "@/lib/api/screen"; + +// ============================================================ +// 타입 정의 +// ============================================================ + +type DeviceType = "mobile" | "tablet"; + +interface PopScreenPreviewProps { + screen: ScreenDefinition | null; + className?: string; +} + +// 디바이스 프레임 크기 +// 모바일: 세로(portrait), 태블릿: 가로(landscape) 디폴트 +const DEVICE_SIZES = { + mobile: { width: 375, height: 667 }, // iPhone SE 기준 (세로) + tablet: { width: 1024, height: 768 }, // iPad 기준 (가로) +}; + +// ============================================================ +// 메인 컴포넌트 +// ============================================================ + +export function PopScreenPreview({ screen, className }: PopScreenPreviewProps) { + const [deviceType, setDeviceType] = useState("tablet"); + const [loading, setLoading] = useState(false); + const [hasLayout, setHasLayout] = useState(false); + const [key, setKey] = useState(0); // iframe 새로고침용 + + // 레이아웃 존재 여부 확인 + useEffect(() => { + if (!screen) { + setHasLayout(false); + return; + } + + const checkLayout = async () => { + try { + setLoading(true); + const layout = await screenApi.getLayoutPop(screen.screenId); + + // v2 레이아웃: sections는 객체 (Record) + // v1 레이아웃: sections는 배열 + if (layout) { + const isV2 = layout.version === "pop-2.0"; + const hasSections = isV2 + ? layout.sections && Object.keys(layout.sections).length > 0 + : layout.sections && Array.isArray(layout.sections) && layout.sections.length > 0; + + setHasLayout(hasSections); + } else { + setHasLayout(false); + } + } catch { + setHasLayout(false); + } finally { + setLoading(false); + } + }; + + checkLayout(); + }, [screen]); + + // 미리보기 URL + const previewUrl = screen ? `/pop/screens/${screen.screenId}?preview=true&device=${deviceType}` : null; + + // 새 탭에서 열기 + const openInNewTab = () => { + if (previewUrl) { + const size = DEVICE_SIZES[deviceType]; + window.open(previewUrl, "_blank", `width=${size.width + 40},height=${size.height + 80}`); + } + }; + + // iframe 새로고침 + const refreshPreview = () => { + setKey((prev) => prev + 1); + }; + + const deviceSize = DEVICE_SIZES[deviceType]; + // 미리보기 컨테이너에 맞게 스케일 조정 + const scale = deviceType === "tablet" ? 0.5 : 0.6; + + return ( +
+ {/* 헤더 */} +
+
+

미리보기

+ {screen && ( + + {screen.screenName} + + )} +
+ +
+ {/* 디바이스 선택 */} + setDeviceType(v as DeviceType)}> + + + + 모바일 + + + + 태블릿 + + + + + {screen && hasLayout && ( + <> + + + + )} +
+
+ + {/* 미리보기 영역 */} +
+ {!screen ? ( + // 화면 미선택 +
+
+ {deviceType === "mobile" ? ( + + ) : ( + + )} +
+

화면을 선택하면 미리보기가 표시됩니다.

+
+ ) : loading ? ( + // 로딩 중 +
+ +

레이아웃 확인 중...

+
+ ) : !hasLayout ? ( + // 레이아웃 없음 +
+
+ {deviceType === "mobile" ? ( + + ) : ( + + )} +
+

POP 레이아웃이 없습니다.

+

+ 화면을 더블클릭하여 설계 모드로 이동하세요. +

+
+ ) : ( + // 디바이스 프레임 + iframe (심플한 테두리) +
+