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/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 25f0fc90..394c0b95 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -4903,24 +4903,16 @@ export class ScreenManagementService { companyCode: string, userId?: string, ): Promise { - console.log(`=== POP 레이아웃 저장 시작 ===`); + console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); - // 버전 감지 - const isV3 = layoutData.version === "pop-3.0" || - (layoutData.layouts && layoutData.components && !layoutData.sections); - const isV2 = layoutData.version === "pop-2.0" || - (layoutData.layouts && layoutData.sections && layoutData.components); + // v5 그리드 레이아웃만 지원 + const componentCount = Object.keys(layoutData.components || {}).length; + console.log(`컴포넌트: ${componentCount}개`); - if (isV3) { - const componentCount = Object.keys(layoutData.components || {}).length; - console.log(`v3 레이아웃: ${componentCount}개 컴포넌트 (섹션 없음)`); - } else if (isV2) { - const sectionCount = Object.keys(layoutData.sections || {}).length; - const componentCount = Object.keys(layoutData.components || {}).length; - console.log(`v2 레이아웃: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); - } else { - console.log(`v1 레이아웃 (섹션 수: ${layoutData.sections?.length || 0})`); + // v5 형식 검증 + if (layoutData.version && layoutData.version !== "pop-5.0") { + console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`); } // 권한 확인 @@ -4946,50 +4938,12 @@ export class ScreenManagementService { console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`); - // 버전 정보 보장 - let dataToSave: any; - if (isV3) { - dataToSave = { - ...layoutData, - version: "pop-3.0", - }; - - // canvasGrid.rows 검증 및 보정 - if (dataToSave.settings?.canvasGrid) { - if (!dataToSave.settings.canvasGrid.rows) { - console.warn("canvasGrid.rows 없음, 기본값 24로 설정"); - dataToSave.settings.canvasGrid.rows = 24; - } - // 구버전 rowHeight 필드 제거 - if (dataToSave.settings.canvasGrid.rowHeight) { - console.warn("구버전 rowHeight 필드 제거"); - delete dataToSave.settings.canvasGrid.rowHeight; - } - } - } else if (isV2) { - dataToSave = { - ...layoutData, - version: "pop-2.0", - }; - - // canvasGrid.rows 검증 및 보정 - if (dataToSave.settings?.canvasGrid) { - if (!dataToSave.settings.canvasGrid.rows) { - console.warn("canvasGrid.rows 없음, 기본값 24로 설정"); - dataToSave.settings.canvasGrid.rows = 24; - } - if (dataToSave.settings.canvasGrid.rowHeight) { - console.warn("구버전 rowHeight 필드 제거"); - delete dataToSave.settings.canvasGrid.rowHeight; - } - } - } else { - // v1 형식으로 저장 (하위 호환) - dataToSave = { - version: "pop-1.0", - ...layoutData, - }; - } + // v5 그리드 레이아웃으로 저장 (단일 버전) + const dataToSave = { + ...layoutData, + version: "pop-5.0", + }; + console.log(`저장: gridConfig=${JSON.stringify(dataToSave.gridConfig || 'default')}`) // UPSERT (있으면 업데이트, 없으면 삽입) await query( diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index b09df855..999c4b4b 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -5,11 +5,9 @@ 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, LayoutData } from "@/types/screen"; +import { ScreenDefinition } from "@/types/screen"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { initializeComponents } from "@/lib/registry/components"; -import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { useAuth } from "@/hooks/useAuth"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; @@ -17,23 +15,14 @@ import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHei import { ScreenContextProvider } from "@/contexts/ScreenContext"; import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; -import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; import { - PopLayoutDataV3, - PopLayoutDataV4, - PopLayoutModeKey, - ensureV3Layout, - isV3Layout, - isV4Layout, + PopLayoutDataV5, + GridMode, + isV5Layout, + createEmptyPopLayoutV5, } from "@/components/pop/designer/types/pop-layout"; -import { - PopLayoutRenderer, - hasBaseLayout, - getEffectiveModeLayout, -} from "@/components/pop/designer/renderers"; -import { PopFlexRenderer } from "@/components/pop/designer/renderers/PopFlexRenderer"; +import PopRenderer from "@/components/pop/designer/renderers/PopRenderer"; import { - useResponsiveMode, useResponsiveModeWithOverride, type DeviceType, } from "@/hooks/useDeviceOrientation"; @@ -50,39 +39,16 @@ const DEVICE_SIZES: Record { +// 모드 키 변환 +const getModeKey = (device: DeviceType, isLandscape: boolean): GridMode => { if (device === "tablet") { return isLandscape ? "tablet_landscape" : "tablet_portrait"; } return isLandscape ? "mobile_landscape" : "mobile_portrait"; }; -// v3.0 레이아웃인지 확인 -const isPopLayoutV3 = (layout: any): layout is PopLayoutDataV3 => { - return layout && layout.version === "pop-3.0" && layout.layouts && layout.components; -}; - -// v4.0 레이아웃인지 확인 -const isPopLayoutV4 = (layout: any): layout is PopLayoutDataV4 => { - return layout && layout.version === "pop-4.0" && layout.root && layout.components; -}; - -// v1/v2/v3/v4 레이아웃인지 확인 -const isPopLayout = (layout: any): boolean => { - return layout && ( - layout.version === "pop-1.0" || - layout.version === "pop-2.0" || - layout.version === "pop-3.0" || - layout.version === "pop-4.0" - ); -}; - // ======================================== -// 메인 컴포넌트 +// 메인 컴포넌트 (v5 그리드 시스템 전용) // ======================================== function PopScreenViewPage() { @@ -103,21 +69,15 @@ function PopScreenViewPage() { // 현재 모드 정보 const deviceType = mode.device; const isLandscape = mode.isLandscape; - const currentModeKey = mode.modeKey; + const currentModeKey = getModeKey(deviceType, isLandscape); - const { user, userName, companyCode } = useAuth(); + const { user } = useAuth(); const [screen, setScreen] = useState(null); - const [layout, setLayout] = useState(null); - const [popLayoutV3, setPopLayoutV3] = useState(null); - const [popLayoutV4, setPopLayoutV4] = useState(null); + const [layout, setLayout] = useState(createEmptyPopLayoutV5()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [formData, setFormData] = useState>({}); - const [selectedRowsData, setSelectedRowsData] = useState([]); - const [tableRefreshKey, setTableRefreshKey] = useState(0); - // 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px) const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로 @@ -131,18 +91,6 @@ function PopScreenViewPage() { return () => window.removeEventListener("resize", updateViewportWidth); }, []); - // 컴포넌트 초기화 - useEffect(() => { - const initComponents = async () => { - try { - await initializeComponents(); - } catch (error) { - console.error("POP 화면 컴포넌트 초기화 실패:", error); - } - }; - initComponents(); - }, []); - // 화면 및 POP 레이아웃 로드 useEffect(() => { const loadScreen = async () => { @@ -156,39 +104,22 @@ function PopScreenViewPage() { try { const popLayout = await screenApi.getLayoutPop(screenId); - if (popLayout && isPopLayoutV4(popLayout)) { - // v4 레이아웃 - setPopLayoutV4(popLayout); - setPopLayoutV3(null); + if (popLayout && isV5Layout(popLayout)) { + // v5 레이아웃 로드 + setLayout(popLayout); const componentCount = Object.keys(popLayout.components).length; - console.log(`[POP] v4 레이아웃 로드됨: ${componentCount}개 컴포넌트`); - } else if (popLayout && isPopLayout(popLayout)) { - // v1/v2/v3 → v3로 변환 - const v3Layout = ensureV3Layout(popLayout); - setPopLayoutV3(v3Layout); - setPopLayoutV4(null); - - const componentCount = Object.keys(v3Layout.components).length; - console.log(`[POP] v3 레이아웃 로드됨: ${componentCount}개 컴포넌트`); - - if (!isV3Layout(popLayout)) { - console.log("[POP] v1/v2 → v3 자동 마이그레이션 완료"); - } - } else if (popLayout && popLayout.components && Array.isArray(popLayout.components) && popLayout.components.length > 0) { - // 이전 형식 (레거시 components 구조) - console.log("[POP] 레거시 레이아웃 로드:", popLayout.components.length, "개 컴포넌트"); - setLayout(popLayout as LayoutData); + console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`); + } else if (popLayout) { + // 다른 버전 레이아웃은 빈 v5로 처리 + console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version); + setLayout(createEmptyPopLayoutV5()); } else { console.log("[POP] 레이아웃 없음"); - setPopLayoutV3(null); - setPopLayoutV4(null); - setLayout(null); + setLayout(createEmptyPopLayoutV5()); } } catch (layoutError) { console.warn("[POP] 레이아웃 로드 실패:", layoutError); - setPopLayoutV3(null); - setPopLayoutV4(null); - setLayout(null); + setLayout(createEmptyPopLayoutV5()); } } catch (error) { console.error("[POP] 화면 로드 실패:", error); @@ -205,6 +136,7 @@ function PopScreenViewPage() { }, [screenId]); const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"]; + const hasComponents = Object.keys(layout.components).length > 0; if (loading) { return ( @@ -336,70 +268,19 @@ function PopScreenViewPage() { flexShrink: 0, } : undefined} > - {/* POP 레이아웃 v4.0 렌더링 */} - {popLayoutV4 ? ( + {/* v5 그리드 렌더러 */} + {hasComponents ? (
-
- ) : popLayoutV3 ? ( - /* POP 레이아웃 v3.0 렌더링 */ - - ) : layout && layout.components && layout.components.length > 0 ? ( - // 레거시 형식 (components 구조) - 호환성 유지 - -
- {layout.components - .filter((component) => !component.parentId) - .map((component) => ( -
- { }} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - selectedRowsData={selectedRowsData} - onSelectedRowsChange={(_, selectedData) => { - setSelectedRowsData(selectedData); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); - }} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - /> -
- ))} -
-
) : ( // 빈 화면
@@ -423,55 +304,6 @@ function PopScreenViewPage() { ); } -// ======================================== -// POP 레이아웃 v3.0 렌더러 -// ======================================== -interface PopLayoutV3RendererProps { - layout: PopLayoutDataV3; - modeKey: PopLayoutModeKey; -} - -function PopLayoutV3Renderer({ layout, modeKey }: PopLayoutV3RendererProps) { - // 태블릿 가로 모드가 기준으로 설정되어 있는지 확인 - if (!hasBaseLayout(layout)) { - return ( -
-
- ! -
-

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

-

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

-
- ); - } - - // 현재 모드에 맞는 레이아웃 가져오기 - const { modeLayout, isConverted, sourceModeKey } = getEffectiveModeLayout(layout, modeKey); - - return ( -
- {isConverted && ( -
- {sourceModeKey} 기준 자동 변환됨 -
- )} - - -
- ); -} - // Provider 래퍼 export default function PopScreenViewPageWrapper() { return ( diff --git a/frontend/app/(pop)/pop/test-v4/page.tsx b/frontend/app/(pop)/pop/test-v4/page.tsx deleted file mode 100644 index 8a722462..00000000 --- a/frontend/app/(pop)/pop/test-v4/page.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client"; - -import { useState, useCallback } from "react"; -import { DndProvider } from "react-dnd"; -import { HTML5Backend } from "react-dnd-html5-backend"; -import { - PopLayoutDataV4, - createEmptyPopLayoutV4, - addComponentToV4Layout, - removeComponentFromV4Layout, - updateComponentInV4Layout, - updateContainerV4, - findContainerV4, - PopComponentType, - PopComponentDefinitionV4, - PopContainerV4, -} from "@/components/pop/designer/types/pop-layout"; -import { PopCanvasV4 } from "@/components/pop/designer/PopCanvasV4"; -import { PopPanel } from "@/components/pop/designer/panels/PopPanel"; -import { ComponentEditorPanelV4 } from "@/components/pop/designer/panels/ComponentEditorPanelV4"; - -// ======================================== -// v4 테스트 페이지 -// -// 목적: v4 렌더러, 캔버스, 속성 패널 테스트 -// 경로: /pop/test-v4 -// ======================================== - -export default function TestV4Page() { - // 레이아웃 상태 - const [layout, setLayout] = useState(() => { - // 초기 테스트 데이터 - const initial = createEmptyPopLayoutV4(); - return initial; - }); - - // 선택 상태 - const [selectedComponentId, setSelectedComponentId] = useState(null); - const [selectedContainerId, setSelectedContainerId] = useState(null); - - // 컴포넌트 ID 카운터 - const [idCounter, setIdCounter] = useState(1); - - // 선택된 컴포넌트/컨테이너 가져오기 - const selectedComponent = selectedComponentId - ? layout.components[selectedComponentId] - : null; - const selectedContainer = selectedContainerId - ? findContainerV4(layout.root, selectedContainerId) - : null; - - // 컴포넌트 드롭 - const handleDropComponent = useCallback( - (type: PopComponentType, containerId: string) => { - const componentId = `comp_${idCounter}`; - setIdCounter((prev) => prev + 1); - - setLayout((prev) => - addComponentToV4Layout(prev, componentId, type, containerId, `${type} ${idCounter}`) - ); - setSelectedComponentId(componentId); - setSelectedContainerId(null); - }, - [idCounter] - ); - - // 컴포넌트 삭제 - const handleDeleteComponent = useCallback((componentId: string) => { - setLayout((prev) => removeComponentFromV4Layout(prev, componentId)); - setSelectedComponentId(null); - }, []); - - // 컴포넌트 업데이트 - const handleUpdateComponent = useCallback( - (componentId: string, updates: Partial) => { - setLayout((prev) => updateComponentInV4Layout(prev, componentId, updates)); - }, - [] - ); - - // 컨테이너 업데이트 - const handleUpdateContainer = useCallback( - (containerId: string, updates: Partial) => { - setLayout((prev) => ({ - ...prev, - root: updateContainerV4(prev.root, containerId, updates), - })); - }, - [] - ); - - // 선택 - const handleSelectComponent = useCallback((id: string | null) => { - setSelectedComponentId(id); - if (id) setSelectedContainerId(null); - }, []); - - const handleSelectContainer = useCallback((id: string | null) => { - setSelectedContainerId(id); - if (id) setSelectedComponentId(null); - }, []); - - return ( - -
- {/* 왼쪽: 컴포넌트 팔레트 */} -
-
-

v4 테스트

-

컴포넌트를 드래그하세요

-
- -
- - {/* 중앙: 캔버스 */} -
- -
- - {/* 오른쪽: 속성 패널 */} -
- handleUpdateComponent(selectedComponentId, updates) - : undefined - } - onUpdateContainer={ - selectedContainerId - ? (updates) => handleUpdateContainer(selectedContainerId, updates) - : undefined - } - /> -
-
-
- ); -} diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index 2d98dec8..f86cbd93 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -1,93 +1,130 @@ "use client"; -import { useCallback, useRef, useState, useEffect } from "react"; +import { useCallback, useRef, useState, useEffect, useMemo } from "react"; import { useDrop } from "react-dnd"; import { cn } from "@/lib/utils"; import { - PopLayoutDataV3, - PopLayoutModeKey, + PopLayoutDataV5, + PopComponentDefinitionV5, PopComponentType, - GridPosition, - MODE_RESOLUTIONS, + PopGridPosition, + GridMode, + GRID_BREAKPOINTS, + DEFAULT_COMPONENT_GRID_SIZE, } from "./types/pop-layout"; -import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel"; -import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react"; +import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet } from "lucide-react"; import { Button } from "@/components/ui/button"; +import PopRenderer from "./renderers/PopRenderer"; +import { mouseToGridPosition, findNextEmptyPosition } from "./utils/gridUtils"; + +// DnD 타입 상수 (인라인) +const DND_ITEM_TYPES = { + COMPONENT: "component", +} as const; + +interface DragItemComponent { + type: typeof DND_ITEM_TYPES.COMPONENT; + componentType: PopComponentType; +} // ======================================== -// 타입 정의 +// 프리셋 해상도 (4개 모드) // ======================================== -type DeviceType = "mobile" | "tablet"; +const VIEWPORT_PRESETS = [ + { id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, height: 667, icon: Smartphone }, + { id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 667, height: 375, icon: Smartphone }, + { id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 768, height: 1024, icon: Tablet }, + { id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, height: 768, icon: Tablet }, +] as const; -// 모드별 라벨 -const MODE_LABELS: Record = { - tablet_landscape: "태블릿 가로", - tablet_portrait: "태블릿 세로", - mobile_landscape: "모바일 가로", - mobile_portrait: "모바일 세로", -}; +type ViewportPreset = GridMode; -// 컴포넌트 타입별 라벨 -const COMPONENT_TYPE_LABELS: Record = { - "pop-field": "필드", - "pop-button": "버튼", - "pop-list": "리스트", - "pop-indicator": "인디케이터", - "pop-scanner": "스캐너", - "pop-numpad": "숫자패드", -}; +// 기본 프리셋 (태블릿 가로) +const DEFAULT_PRESET: ViewportPreset = "tablet_landscape"; // ======================================== // Props // ======================================== interface PopCanvasProps { - layout: PopLayoutDataV3; - activeDevice: DeviceType; - activeModeKey: PopLayoutModeKey; - onModeKeyChange: (modeKey: PopLayoutModeKey) => void; + layout: PopLayoutDataV5; selectedComponentId: string | null; + currentMode: GridMode; + onModeChange: (mode: GridMode) => void; onSelectComponent: (id: string | null) => void; - onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void; - onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => 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; } // ======================================== -// 메인 컴포넌트 +// PopCanvas: 그리드 캔버스 // ======================================== -export function PopCanvas({ + +export default function PopCanvas({ layout, - activeDevice, - activeModeKey, - onModeKeyChange, selectedComponentId, + currentMode, + onModeChange, onSelectComponent, - onUpdateComponentPosition, onDropComponent, + onUpdateComponent, onDeleteComponent, + onMoveComponent, + onResizeComponent, }: PopCanvasProps) { - const { settings, components, layouts } = layout; - const canvasGrid = settings.canvasGrid; - - // 줌 상태 (0.3 ~ 1.5 범위) - const [canvasScale, setCanvasScale] = useState(0.6); - + // 줌 상태 + const [canvasScale, setCanvasScale] = useState(0.8); + + // 커스텀 뷰포트 크기 + const [customWidth, setCustomWidth] = useState(1024); + const [customHeight, setCustomHeight] = useState(768); + + // 그리드 가이드 표시 여부 + 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 [isDraggingComponent, setIsDraggingComponent] = useState(false); + const [draggedComponentId, setDraggedComponentId] = useState(null); + const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null); + const [dragPreviewPos, setDragPreviewPos] = useState(null); + + // 현재 뷰포트 해상도 + const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!; + const breakpoint = GRID_BREAKPOINTS[currentMode]; + + // 그리드 라벨 계산 + const gridLabels = useMemo(() => { + const columnLabels = Array.from({ length: breakpoint.columns }, (_, i) => i + 1); + const rowLabels = Array.from({ length: 20 }, (_, i) => i + 1); + return { columnLabels, rowLabels }; + }, [breakpoint.columns]); // 줌 컨트롤 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); + setCustomHeight(presetData.height); + }; + // 패닝 const handlePanStart = (e: React.MouseEvent) => { const isMiddleButton = e.button === 1; - const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area"); - if (isMiddleButton || isSpacePressed || isScrollAreaClick) { + if (isMiddleButton || isSpacePressed) { setIsPanning(true); setPanStart({ x: e.clientX, y: e.clientY }); e.preventDefault(); @@ -105,12 +142,14 @@ export function PopCanvas({ const handlePanEnd = () => setIsPanning(false); - // 마우스 휠 줌 - const handleWheel = useCallback((e: React.WheelEvent) => { - e.preventDefault(); - const delta = e.deltaY > 0 ? -0.1 : 0.1; - setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta))); - }, []); + // 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(() => { @@ -128,42 +167,139 @@ export function PopCanvas({ }; }, [isSpacePressed]); - // 초기 로드 시 캔버스 중앙 스크롤 - useEffect(() => { - if (containerRef.current) { - const container = containerRef.current; - const timer = setTimeout(() => { - const scrollX = (container.scrollWidth - container.clientWidth) / 2; - const scrollY = (container.scrollHeight - container.clientHeight) / 2; - container.scrollTo(scrollX, scrollY); - }, 100); - return () => clearTimeout(timer); - } - }, [activeDevice]); + // 컴포넌트 드롭 (팔레트에서) + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: DND_ITEM_TYPES.COMPONENT, + drop: (item: DragItemComponent, monitor) => { + if (!canvasRef.current) return; + + const offset = monitor.getClientOffset(); + if (!offset) return; + + const canvasRect = canvasRef.current.getBoundingClientRect(); + + // 마우스 위치 → 그리드 좌표 변환 + const gridPos = mouseToGridPosition( + offset.x, + offset.y, + canvasRect, + breakpoint.columns, + breakpoint.rowHeight, + breakpoint.gap, + breakpoint.padding + ); + + // 컴포넌트 기본 크기 + const defaultSize = DEFAULT_COMPONENT_GRID_SIZE[item.componentType]; + + // 다음 빈 위치 찾기 + const existingPositions = Object.values(layout.components).map(c => c.position); + const position = findNextEmptyPosition( + existingPositions, + defaultSize.colSpan, + defaultSize.rowSpan, + breakpoint.columns + ); + + // 컴포넌트 추가 + onDropComponent(item.componentType, position); + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }), + [onDropComponent, breakpoint, layout.components] + ); - // 현재 디바이스의 가로/세로 모드 키 - const landscapeModeKey: PopLayoutModeKey = activeDevice === "tablet" - ? "tablet_landscape" - : "mobile_landscape"; - const portraitModeKey: PopLayoutModeKey = activeDevice === "tablet" - ? "tablet_portrait" - : "mobile_portrait"; + drop(canvasRef); + + // 빈 상태 체크 + const isEmpty = Object.keys(layout.components).length === 0; return ( -
- {/* 줌 컨트롤 바 */} -
- - 줌: {Math.round(canvasScale * 100)}% - - - - + ); + })} +
+ +
+ + {/* 해상도 표시 */} +
+ {customWidth} × {customHeight} +
+ +
+ + {/* 줌 컨트롤 */} +
+ + {Math.round(canvasScale * 100)}% + + + + +
+ +
+ + {/* 그리드 가이드 토글 */} +
@@ -171,9 +307,9 @@ export function PopCanvas({
-
- {/* 가로 모드 */} - - - {/* 세로 모드 */} - -
-
-
- ); -} - -// ======================================== -// CSS Grid 기반 디바이스 프레임 (v3: 컴포넌트 직접 배치) -// ======================================== -interface DeviceFrameProps { - modeKey: PopLayoutModeKey; - isActive: boolean; - scale: number; - canvasGrid: { columns: number; rows: number; gap: number }; - layout: PopLayoutDataV3; - selectedComponentId: string | null; - onModeKeyChange: (modeKey: PopLayoutModeKey) => void; - onSelectComponent: (id: string | null) => void; - onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void; - onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => void; - onDeleteComponent: (componentId: string) => void; -} - -function DeviceFrame({ - modeKey, - isActive, - scale, - canvasGrid, - layout, - selectedComponentId, - onModeKeyChange, - onSelectComponent, - onUpdateComponentPosition, - onDropComponent, - onDeleteComponent, -}: DeviceFrameProps) { - const gridRef = useRef(null); - const dropRef = useRef(null); - - const { components, layouts } = layout; - const resolution = MODE_RESOLUTIONS[modeKey]; - const modeLayout = layouts[modeKey]; - const componentPositions = modeLayout.componentPositions; - const componentIds = Object.keys(componentPositions); - - const cols = canvasGrid.columns; - const rows = canvasGrid.rows || 24; - const gap = canvasGrid.gap; - - // 드래그 상태 - const [dragState, setDragState] = useState<{ - componentId: string; - startPos: GridPosition; - currentPos: GridPosition; - isDragging: boolean; - } | null>(null); - - // 리사이즈 상태 - const [resizeState, setResizeState] = useState<{ - componentId: string; - startPos: GridPosition; - currentPos: GridPosition; - handle: "se" | "sw" | "ne" | "nw" | "e" | "w" | "n" | "s"; - isResizing: boolean; - } | null>(null); - - // 라벨 - const sizeLabel = `${resolution.width}x${resolution.height}`; - const modeLabel = `${MODE_LABELS[modeKey]} (${sizeLabel})`; - - // 마우스 → 그리드 좌표 변환 - const getGridPosition = useCallback((clientX: number, clientY: number): { col: number; row: number } => { - if (!gridRef.current) return { col: 1, row: 1 }; - const rect = gridRef.current.getBoundingClientRect(); - const x = (clientX - rect.left) / scale; - const y = (clientY - rect.top) / scale; - const cellWidth = (resolution.width - gap * (cols + 1)) / cols; - const cellHeight = (resolution.height - gap * (rows + 1)) / rows; - const col = Math.max(1, Math.min(cols, Math.floor((x - gap) / (cellWidth + gap)) + 1)); - const row = Math.max(1, Math.min(rows, Math.floor((y - gap) / (cellHeight + gap)) + 1)); - return { col, row }; - }, [scale, resolution, cols, rows, gap]); - - // 드래그 시작 - const handleDragStart = useCallback((e: React.MouseEvent, componentId: string) => { - if (!isActive) return; - e.preventDefault(); - e.stopPropagation(); - const pos = componentPositions[componentId]; - setDragState({ - componentId, - startPos: { ...pos }, - currentPos: { ...pos }, - isDragging: true, - }); - }, [isActive, componentPositions]); - - // 마우스 이동 - const handleMouseMove = useCallback((e: React.MouseEvent) => { - if (dragState?.isDragging && gridRef.current) { - const { col, row } = getGridPosition(e.clientX, e.clientY); - const newCol = Math.max(1, Math.min(cols - dragState.startPos.colSpan + 1, col)); - const newRow = Math.max(1, Math.min(rows - dragState.startPos.rowSpan + 1, row)); - setDragState(prev => prev ? { - ...prev, - currentPos: { ...prev.startPos, col: newCol, row: newRow } - } : null); - } - - if (resizeState?.isResizing && gridRef.current) { - const { col, row } = getGridPosition(e.clientX, e.clientY); - const startPos = resizeState.startPos; - let newPos = { ...startPos }; - - switch (resizeState.handle) { - case "se": - newPos.colSpan = Math.max(2, col - startPos.col + 1); - newPos.rowSpan = Math.max(2, row - startPos.row + 1); - break; - case "e": - newPos.colSpan = Math.max(2, col - startPos.col + 1); - break; - case "s": - newPos.rowSpan = Math.max(2, row - startPos.row + 1); - break; - case "sw": - const newColSW = Math.min(col, startPos.col + startPos.colSpan - 2); - newPos.col = newColSW; - newPos.colSpan = startPos.col + startPos.colSpan - newColSW; - newPos.rowSpan = Math.max(2, row - startPos.row + 1); - break; - case "w": - const newColW = Math.min(col, startPos.col + startPos.colSpan - 2); - newPos.col = newColW; - newPos.colSpan = startPos.col + startPos.colSpan - newColW; - break; - case "ne": - const newRowNE = Math.min(row, startPos.row + startPos.rowSpan - 2); - newPos.row = newRowNE; - newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNE; - newPos.colSpan = Math.max(2, col - startPos.col + 1); - break; - case "n": - const newRowN = Math.min(row, startPos.row + startPos.rowSpan - 2); - newPos.row = newRowN; - newPos.rowSpan = startPos.row + startPos.rowSpan - newRowN; - break; - case "nw": - const newColNW = Math.min(col, startPos.col + startPos.colSpan - 2); - const newRowNW = Math.min(row, startPos.row + startPos.rowSpan - 2); - newPos.col = newColNW; - newPos.row = newRowNW; - newPos.colSpan = startPos.col + startPos.colSpan - newColNW; - newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNW; - break; - } - - newPos.col = Math.max(1, newPos.col); - newPos.row = Math.max(1, newPos.row); - newPos.colSpan = Math.min(cols - newPos.col + 1, newPos.colSpan); - newPos.rowSpan = Math.min(rows - newPos.row + 1, newPos.rowSpan); - - setResizeState(prev => prev ? { ...prev, currentPos: newPos } : null); - } - }, [dragState, resizeState, getGridPosition, cols, rows]); - - // 드래그/리사이즈 종료 - const handleMouseUp = useCallback(() => { - if (dragState?.isDragging) { - onUpdateComponentPosition(dragState.componentId, dragState.currentPos, modeKey); - setDragState(null); - } - if (resizeState?.isResizing) { - onUpdateComponentPosition(resizeState.componentId, resizeState.currentPos, modeKey); - setResizeState(null); - } - }, [dragState, resizeState, onUpdateComponentPosition, modeKey]); - - // 리사이즈 시작 - const handleResizeStart = useCallback((e: React.MouseEvent, componentId: string, handle: string) => { - if (!isActive) return; - e.preventDefault(); - e.stopPropagation(); - const pos = componentPositions[componentId]; - setResizeState({ - componentId, - startPos: { ...pos }, - currentPos: { ...pos }, - handle: handle as any, - isResizing: true, - }); - }, [isActive, componentPositions]); - - // 컴포넌트 드롭 - const [{ isOver, canDrop }, drop] = useDrop( - () => ({ - accept: DND_ITEM_TYPES.COMPONENT, - drop: (item: DragItemComponent, monitor) => { - if (!isActive) return; - const clientOffset = monitor.getClientOffset(); - if (!clientOffset || !gridRef.current) return; - const { col, row } = getGridPosition(clientOffset.x, clientOffset.y); - onDropComponent(item.componentType, { col, row, colSpan: 4, rowSpan: 3 }); - }, - canDrop: () => isActive, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), - }), - }), - [isActive, getGridPosition, onDropComponent] - ); - - drop(dropRef); - - // 현재 표시할 위치 - const getDisplayPosition = (componentId: string): GridPosition => { - if (dragState?.componentId === componentId && dragState.isDragging) { - return dragState.currentPos; - } - if (resizeState?.componentId === componentId && resizeState.isResizing) { - return resizeState.currentPos; - } - return componentPositions[componentId]; - }; - - return ( -
- {/* 모드 라벨 */} -
- {modeLabel} -
- - {/* 디바이스 프레임 */} -
{ - if (e.target === e.currentTarget) { - if (!isActive) onModeKeyChange(modeKey); - else onSelectComponent(null); - } - }} - onMouseMove={handleMouseMove} - onMouseUp={handleMouseUp} - onMouseLeave={handleMouseUp} - > - {/* CSS Grid (뷰어와 동일) */}
- {componentIds.length > 0 ? ( - componentIds.map((componentId) => { - const compDef = components[componentId]; - if (!compDef) return null; - - const pos = getDisplayPosition(componentId); - const isSelected = selectedComponentId === componentId; - const isDragging = dragState?.componentId === componentId && dragState.isDragging; - const isResizing = resizeState?.componentId === componentId && resizeState.isResizing; - - return ( -
{ - e.stopPropagation(); - if (!isActive) onModeKeyChange(modeKey); - onSelectComponent(componentId); - }} - onMouseDown={(e) => handleDragStart(e, componentId)} - > - {/* 컴포넌트 라벨 */} - - {compDef.label || COMPONENT_TYPE_LABELS[compDef.type]} - - - {/* 리사이즈 핸들 */} - {isActive && isSelected && ( - <> -
handleResizeStart(e, componentId, "se")} /> -
handleResizeStart(e, componentId, "sw")} /> -
handleResizeStart(e, componentId, "ne")} /> -
handleResizeStart(e, componentId, "nw")} /> -
handleResizeStart(e, componentId, "e")} /> -
handleResizeStart(e, componentId, "w")} /> -
handleResizeStart(e, componentId, "s")} /> -
handleResizeStart(e, componentId, "n")} /> - - )} -
- ); - }) - ) : ( -
- {isOver && canDrop - ? "여기에 컴포넌트를 놓으세요" - : isActive - ? "왼쪽 패널에서 컴포넌트를 드래그하세요" - : "클릭하여 편집"} -
+ {/* 그리드 라벨 영역 */} + {showGridGuide && ( + <> + {/* 열 라벨 (상단) */} +
+ {gridLabels.columnLabels.map((num) => ( +
+ {num} +
+ ))} +
+ + {/* 행 라벨 (좌측) */} +
+ {gridLabels.rowLabels.map((num) => ( +
+ {num} +
+ ))} +
+ )} + + {/* 디바이스 스크린 */} +
+ {isEmpty ? ( + // 빈 상태 +
+
+
+ 컴포넌트를 드래그하여 배치하세요 +
+
+ {breakpoint.label} - {breakpoint.columns}칸 그리드 +
+
+
+ ) : ( + // 그리드 렌더러 + onSelectComponent(null)} + /> + )} +
+
+
+ + {/* 하단 정보 */} +
+
+ {breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px) +
+
+ Space + 드래그: 패닝 | Ctrl + 휠: 줌
diff --git a/frontend/components/pop/designer/PopCanvasV4.tsx b/frontend/components/pop/designer/PopCanvasV4.tsx deleted file mode 100644 index dc00c2d9..00000000 --- a/frontend/components/pop/designer/PopCanvasV4.tsx +++ /dev/null @@ -1,391 +0,0 @@ -"use client"; - -import { useCallback, useRef, useState, useEffect } from "react"; -import { useDrop } from "react-dnd"; -import { cn } from "@/lib/utils"; -import { - PopLayoutDataV4, - PopContainerV4, - PopComponentDefinitionV4, - PopComponentType, - PopSizeConstraintV4, -} from "./types/pop-layout"; -import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel"; -import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, RotateCcw, Lock } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { PopFlexRenderer } from "./renderers/PopFlexRenderer"; - -// ======================================== -// 프리셋 해상도 (4개 모드) -// ======================================== -const VIEWPORT_PRESETS = [ - { id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕", width: 375, height: 667, icon: Smartphone, isLandscape: false }, - { id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔", width: 667, height: 375, icon: Smartphone, isLandscape: true }, - { id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕", width: 768, height: 1024, icon: Tablet, isLandscape: false }, - { id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔", width: 1024, height: 768, icon: Tablet, isLandscape: true }, -] as const; - -type ViewportPreset = (typeof VIEWPORT_PRESETS)[number]["id"]; - -// 기본 프리셋 (태블릿 가로) -const DEFAULT_PRESET: ViewportPreset = "tablet_landscape"; - -// ======================================== -// Props -// ======================================== -interface PopCanvasV4Props { - layout: PopLayoutDataV4; - selectedComponentId: string | null; - selectedContainerId: string | null; - currentMode: ViewportPreset; // 현재 모드 - tempLayout?: PopContainerV4 | null; // 임시 레이아웃 (고정 전 미리보기) - onModeChange: (mode: ViewportPreset) => void; // 모드 변경 - onSelectComponent: (id: string | null) => void; - onSelectContainer: (id: string | null) => void; - onDropComponent: (type: PopComponentType, containerId: string) => void; - onUpdateComponent: (componentId: string, updates: Partial) => void; - onUpdateContainer: (containerId: string, updates: Partial) => void; - onDeleteComponent: (componentId: string) => void; - onResizeComponent?: (componentId: string, size: Partial) => void; - onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void; - onLockLayout?: () => void; // 배치 고정 - onResetOverride?: (mode: ViewportPreset) => void; // 오버라이드 초기화 -} - -// ======================================== -// v4 캔버스 -// -// 핵심: 단일 캔버스 + 뷰포트 프리뷰 -// - 가로/세로 모드 따로 없음 -// - 다양한 뷰포트 크기로 미리보기 -// ======================================== -export function PopCanvasV4({ - layout, - selectedComponentId, - selectedContainerId, - currentMode, - tempLayout, - onModeChange, - onSelectComponent, - onSelectContainer, - onDropComponent, - onUpdateComponent, - onUpdateContainer, - onDeleteComponent, - onResizeComponent, - onReorderComponent, - onLockLayout, - onResetOverride, -}: PopCanvasV4Props) { - // 줌 상태 - const [canvasScale, setCanvasScale] = useState(0.8); - - // 커스텀 뷰포트 크기 (슬라이더) - const [customWidth, setCustomWidth] = useState(1024); - const [customHeight, setCustomHeight] = useState(768); - - // 패닝 상태 - const [isPanning, setIsPanning] = useState(false); - const [panStart, setPanStart] = useState({ x: 0, y: 0 }); - const [isSpacePressed, setIsSpacePressed] = useState(false); - const containerRef = useRef(null); - const dropRef = useRef(null); - - // 현재 뷰포트 해상도 - const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!; - const viewportWidth = customWidth; - const viewportHeight = customHeight; - - // 줌 컨트롤 - 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 = (preset: ViewportPreset) => { - onModeChange(preset); // 부모에게 알림 - const presetData = VIEWPORT_PRESETS.find((p) => p.id === preset)!; - setCustomWidth(presetData.width); - setCustomHeight(presetData.height); - }; - - // 슬라이더로 너비 변경 시 높이도 비율에 맞게 조정 - const handleWidthChange = (newWidth: number) => { - setCustomWidth(newWidth); - // 현재 프리셋의 가로세로 비율 유지 - const ratio = currentPreset.height / currentPreset.width; - setCustomHeight(Math.round(newWidth * ratio)); - }; - - // 패닝 - const handlePanStart = (e: React.MouseEvent) => { - const isMiddleButton = e.button === 1; - const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area"); - if (isMiddleButton || isSpacePressed || isScrollAreaClick) { - setIsPanning(true); - setPanStart({ x: e.clientX, y: e.clientY }); - 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); - - // 마우스 휠 줌 - const handleWheel = useCallback((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 키 감지 (패닝용) - // 참고: Delete/Backspace 키는 PopDesigner에서 처리 (히스토리 지원) - 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, - drop: (item: DragItemComponent) => { - // 루트 컨테이너에 추가 - onDropComponent(item.componentType, "root"); - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), - }), - }), - [onDropComponent] - ); - - drop(dropRef); - - // 오버라이드 상태 확인 - const hasOverride = (mode: ViewportPreset): boolean => { - if (mode === DEFAULT_PRESET) return false; // 기본 모드는 오버라이드 없음 - - const override = layout.overrides?.[mode as keyof typeof layout.overrides]; - if (!override) return false; - - // 컴포넌트 또는 컨테이너 오버라이드가 있으면 true - const hasComponentOverrides = override.components && Object.keys(override.components).length > 0; - const hasContainerOverrides = override.containers && Object.keys(override.containers).length > 0; - - return !!(hasComponentOverrides || hasContainerOverrides); - }; - - return ( -
- {/* 툴바 */} -
- {/* 뷰포트 프리셋 (4개 모드) */} -
- 미리보기: - {VIEWPORT_PRESETS.map((preset) => { - const Icon = preset.icon; - const isActive = currentMode === preset.id; - const isDefault = preset.id === DEFAULT_PRESET; - const isEdited = hasOverride(preset.id); - - return ( - - ); - })} -
- - {/* 고정 버튼 (기본 모드가 아닐 때 표시) */} - {currentMode !== DEFAULT_PRESET && onLockLayout && ( - - )} - - {/* 오버라이드 초기화 버튼 (편집된 모드에만 표시) */} - {hasOverride(currentMode) && onResetOverride && ( - - )} - - {/* 줌 컨트롤 */} -
- - {Math.round(canvasScale * 100)}% - - - - -
-
- - {/* 뷰포트 크기 슬라이더 */} -
- 너비: - handleWidthChange(Number(e.target.value))} - className="flex-1 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer" - /> - - {customWidth} x {viewportHeight} - -
- - {/* 캔버스 영역 */} -
-
- {/* 디바이스 프레임 */} -
- {/* 뷰포트 라벨 */} -
- {currentPreset.label} ({viewportWidth}x{viewportHeight}) -
- - {/* Flexbox 렌더러 - 최소 높이는 뷰포트 높이, 컨텐츠에 따라 늘어남 */} -
- { - onSelectComponent(null); - onSelectContainer(null); - }} - onComponentResize={onResizeComponent} - onReorderComponent={onReorderComponent} - /> -
- - {/* 드롭 안내 (빈 상태) */} - {layout.root.children.length === 0 && ( -
-
-

- {isOver && canDrop - ? "여기에 놓으세요" - : "컴포넌트를 드래그하세요"} -

-

- 왼쪽 패널에서 컴포넌트를 드래그하여 추가 -

-
-
- )} -
-
-
-
- ); -} - -export default PopCanvasV4; diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index ad9b6e3f..58488531 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -3,9 +3,8 @@ import { useState, useCallback, useEffect } from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; -import { ArrowLeft, Save, Smartphone, Tablet, Undo2, Redo2 } from "lucide-react"; +import { ArrowLeft, Save, Undo2, Redo2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ResizableHandle, ResizablePanel, @@ -13,44 +12,22 @@ import { } from "@/components/ui/resizable"; import { toast } from "sonner"; -import { PopCanvas } from "./PopCanvas"; -import { PopCanvasV4 } from "./PopCanvasV4"; -import { PopPanel } from "./panels/PopPanel"; -import { ComponentPaletteV4 } from "./panels/ComponentPaletteV4"; -import { ComponentEditorPanelV4 } from "./panels/ComponentEditorPanelV4"; +import PopCanvas from "./PopCanvas"; +import ComponentEditorPanel from "./panels/ComponentEditorPanel"; +import ComponentPalette from "./panels/ComponentPalette"; import { - PopLayoutDataV3, - PopLayoutDataV4, - PopLayoutModeKey, + PopLayoutDataV5, PopComponentType, - GridPosition, - PopComponentDefinition, - PopComponentDefinitionV4, - PopContainerV4, - PopSizeConstraintV4, - createEmptyPopLayoutV3, - createEmptyPopLayoutV4, - ensureV3Layout, - addComponentToV3Layout, - removeComponentFromV3Layout, - updateComponentPositionInModeV3, - addComponentToV4Layout, - removeComponentFromV4Layout, - updateComponentInV4Layout, - updateContainerV4, - findContainerV4, - isV3Layout, - isV4Layout, + PopComponentDefinitionV5, + PopGridPosition, + GridMode, + createEmptyPopLayoutV5, + isV5Layout, + addComponentToV5Layout, } from "./types/pop-layout"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; -// ======================================== -// 레이아웃 모드 타입 -// ======================================== -type LayoutMode = "v3" | "v4"; -type DeviceType = "mobile" | "tablet"; - // ======================================== // Props // ======================================== @@ -61,9 +38,7 @@ interface PopDesignerProps { } // ======================================== -// 메인 컴포넌트 (v3/v4 통합) -// - 새 화면: v4로 시작 -// - 기존 v3 화면: v3로 로드 (하위 호환) +// 메인 컴포넌트 (v5 그리드 시스템 전용) // ======================================== export default function PopDesigner({ selectedScreen, @@ -71,140 +46,77 @@ export default function PopDesigner({ onScreenUpdate, }: PopDesignerProps) { // ======================================== - // 레이아웃 모드 (데이터에 따라 자동 결정) + // 레이아웃 상태 // ======================================== - const [layoutMode, setLayoutMode] = useState("v4"); + const [layout, setLayout] = useState(createEmptyPopLayoutV5()); - // ======================================== - // 레이아웃 상태 (데스크탑 모드와 동일한 방식) - // ======================================== - const [layoutV4, setLayoutV4] = useState(createEmptyPopLayoutV4()); - const [layoutV3, setLayoutV3] = useState(createEmptyPopLayoutV3()); + // 히스토리 + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); - // 히스토리 (v4용) - const [historyV4, setHistoryV4] = useState([]); - const [historyIndexV4, setHistoryIndexV4] = useState(-1); - - // 히스토리 (v3용) - const [historyV3, setHistoryV3] = useState([]); - const [historyIndexV3, setHistoryIndexV3] = useState(-1); - - const [idCounter, setIdCounter] = useState(1); - + // UI 상태 const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); + const [idCounter, setIdCounter] = useState(1); - // ======================================== - // 히스토리 저장 함수 - // ======================================== - const saveToHistoryV4 = useCallback((newLayout: PopLayoutDataV4) => { - setHistoryV4((prev) => { - const newHistory = prev.slice(0, historyIndexV4 + 1); - newHistory.push(JSON.parse(JSON.stringify(newLayout))); // 깊은 복사 - return newHistory.slice(-50); // 최대 50개 - }); - setHistoryIndexV4((prev) => Math.min(prev + 1, 49)); - }, [historyIndexV4]); - - const saveToHistoryV3 = useCallback((newLayout: PopLayoutDataV3) => { - setHistoryV3((prev) => { - const newHistory = prev.slice(0, historyIndexV3 + 1); - newHistory.push(JSON.parse(JSON.stringify(newLayout))); - return newHistory.slice(-50); - }); - setHistoryIndexV3((prev) => Math.min(prev + 1, 49)); - }, [historyIndexV3]); - - // ======================================== - // Undo/Redo 함수 - // ======================================== - const undoV4 = useCallback(() => { - if (historyIndexV4 > 0) { - const newIndex = historyIndexV4 - 1; - const previousLayout = historyV4[newIndex]; - if (previousLayout) { - setLayoutV4(JSON.parse(JSON.stringify(previousLayout))); - setHistoryIndexV4(newIndex); - console.log("[Undo V4] 복원됨, index:", newIndex); - } - } - }, [historyIndexV4, historyV4]); - - const redoV4 = useCallback(() => { - if (historyIndexV4 < historyV4.length - 1) { - const newIndex = historyIndexV4 + 1; - const nextLayout = historyV4[newIndex]; - if (nextLayout) { - setLayoutV4(JSON.parse(JSON.stringify(nextLayout))); - setHistoryIndexV4(newIndex); - console.log("[Redo V4] 복원됨, index:", newIndex); - } - } - }, [historyIndexV4, historyV4]); - - const undoV3 = useCallback(() => { - if (historyIndexV3 > 0) { - const newIndex = historyIndexV3 - 1; - const previousLayout = historyV3[newIndex]; - if (previousLayout) { - setLayoutV3(JSON.parse(JSON.stringify(previousLayout))); - setHistoryIndexV3(newIndex); - } - } - }, [historyIndexV3, historyV3]); - - const redoV3 = useCallback(() => { - if (historyIndexV3 < historyV3.length - 1) { - const newIndex = historyIndexV3 + 1; - const nextLayout = historyV3[newIndex]; - if (nextLayout) { - setLayoutV3(JSON.parse(JSON.stringify(nextLayout))); - setHistoryIndexV3(newIndex); - } - } - }, [historyIndexV3, historyV3]); - - // 현재 모드의 Undo/Redo - const canUndo = layoutMode === "v4" ? historyIndexV4 > 0 : historyIndexV3 > 0; - const canRedo = layoutMode === "v4" - ? historyIndexV4 < historyV4.length - 1 - : historyIndexV3 < historyV3.length - 1; - const handleUndo = layoutMode === "v4" ? undoV4 : undoV3; - const handleRedo = layoutMode === "v4" ? redoV4 : redoV3; - - // ======================================== - // v3용 디바이스/모드 상태 - // ======================================== - const [activeDevice, setActiveDevice] = useState("tablet"); - const [activeModeKey, setActiveModeKey] = useState("tablet_landscape"); - - // ======================================== - // v4용 뷰포트 모드 상태 - // ======================================== - type ViewportMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; - const [currentViewportMode, setCurrentViewportMode] = useState("tablet_landscape"); - - // v4: 임시 레이아웃 (고정 전 배치) - 다른 모드에서만 사용 - const [tempLayout, setTempLayout] = useState(null); - - // ======================================== // 선택 상태 - // ======================================== const [selectedComponentId, setSelectedComponentId] = useState(null); - const [selectedContainerId, setSelectedContainerId] = useState(null); - // 선택된 컴포넌트/컨테이너 - const selectedComponentV3: PopComponentDefinition | null = selectedComponentId - ? layoutV3.components[selectedComponentId] || null - : null; - const selectedComponentV4: PopComponentDefinitionV4 | null = selectedComponentId - ? layoutV4.components[selectedComponentId] || null - : null; - const selectedContainer: PopContainerV4 | null = selectedContainerId - ? findContainerV4(layoutV4.root, selectedContainerId) + // 그리드 모드 (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; + // ======================================== // 레이아웃 로드 // ======================================== @@ -216,45 +128,27 @@ export default function PopDesigner({ try { const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); - // 유효한 레이아웃인지 확인: - // 1. version 필드 필수 - // 2. 컴포넌트가 있어야 함 (빈 레이아웃은 새 화면 취급) - const hasValidLayout = loadedLayout && loadedLayout.version; - const hasComponents = loadedLayout?.components && Object.keys(loadedLayout.components).length > 0; - - if (hasValidLayout && hasComponents) { - if (isV4Layout(loadedLayout)) { - // v4 레이아웃 - setLayoutV4(loadedLayout); - setHistoryV4([loadedLayout]); - setHistoryIndexV4(0); - setLayoutMode("v4"); - console.log(`POP v4 레이아웃 로드: ${Object.keys(loadedLayout.components).length}개 컴포넌트`); - } else { - // v1/v2/v3 → v3로 변환 - const v3Layout = ensureV3Layout(loadedLayout); - setLayoutV3(v3Layout); - setHistoryV3([v3Layout]); - setHistoryIndexV3(0); - setLayoutMode("v3"); - console.log(`POP v3 레이아웃 로드: ${Object.keys(v3Layout.components).length}개 컴포넌트`); - } + if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) { + // v5 레이아웃 로드 + setLayout(loadedLayout); + setHistory([loadedLayout]); + setHistoryIndex(0); + console.log(`POP 레이아웃 로드: ${Object.keys(loadedLayout.components).length}개 컴포넌트`); } else { - // 새 화면 또는 빈 레이아웃 → v4로 시작 - const emptyLayout = createEmptyPopLayoutV4(); - setLayoutV4(emptyLayout); - setHistoryV4([emptyLayout]); - setHistoryIndexV4(0); - setLayoutMode("v4"); + // 새 화면 또는 빈 레이아웃 + const emptyLayout = createEmptyPopLayoutV5(); + setLayout(emptyLayout); + setHistory([emptyLayout]); + setHistoryIndex(0); + console.log("새 POP 화면 생성 (v5 그리드)"); } } catch (error) { console.error("레이아웃 로드 실패:", error); toast.error("레이아웃을 불러오는데 실패했습니다"); - const emptyLayout = createEmptyPopLayoutV4(); - setLayoutV4(emptyLayout); - setHistoryV4([emptyLayout]); - setHistoryIndexV4(0); - setLayoutMode("v4"); + const emptyLayout = createEmptyPopLayoutV5(); + setLayout(emptyLayout); + setHistory([emptyLayout]); + setHistoryIndex(0); } finally { setIsLoading(false); } @@ -271,8 +165,7 @@ export default function PopDesigner({ setIsSaving(true); try { - const layoutToSave = layoutMode === "v3" ? layoutV3 : layoutV4; - await screenApi.saveLayoutPop(selectedScreen.screenId, layoutToSave); + await screenApi.saveLayoutPop(selectedScreen.screenId, layout); toast.success("저장되었습니다"); setHasChanges(false); } catch (error) { @@ -281,271 +174,69 @@ export default function PopDesigner({ } finally { setIsSaving(false); } - }, [selectedScreen?.screenId, layoutMode, layoutV3, layoutV4]); + }, [selectedScreen?.screenId, layout]); // ======================================== - // v3: 컴포넌트 핸들러 + // 컴포넌트 핸들러 // ======================================== - const handleDropComponentV3 = useCallback( - (type: PopComponentType, gridPosition: GridPosition) => { - const newId = `${type}-${Date.now()}`; - const newLayout = addComponentToV3Layout(layoutV3, newId, type, gridPosition); - setLayoutV3(newLayout); - saveToHistoryV3(newLayout); - setSelectedComponentId(newId); - setHasChanges(true); - }, - [layoutV3, saveToHistoryV3] - ); - - const handleUpdateComponentDefinitionV3 = useCallback( - (componentId: string, updates: Partial) => { - const newLayout = { - ...layoutV3, - components: { - ...layoutV3.components, - [componentId]: { ...layoutV3.components[componentId], ...updates }, - }, - }; - setLayoutV3(newLayout); - saveToHistoryV3(newLayout); - setHasChanges(true); - }, - [layoutV3, saveToHistoryV3] - ); - - const handleUpdateComponentPositionV3 = useCallback( - (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => { - const targetMode = modeKey || activeModeKey; - const newLayout = updateComponentPositionInModeV3(layoutV3, targetMode, componentId, position); - setLayoutV3(newLayout); - saveToHistoryV3(newLayout); - setHasChanges(true); - }, - [layoutV3, activeModeKey, saveToHistoryV3] - ); - - const handleDeleteComponentV3 = useCallback((componentId: string) => { - const newLayout = removeComponentFromV3Layout(layoutV3, componentId); - setLayoutV3(newLayout); - saveToHistoryV3(newLayout); - setSelectedComponentId(null); - setHasChanges(true); - }, [layoutV3, saveToHistoryV3]); - - // ======================================== - // v4: 컴포넌트 핸들러 - // ======================================== - const handleDropComponentV4 = useCallback( - (type: PopComponentType, containerId: string) => { + const handleDropComponent = useCallback( + (type: PopComponentType, position: PopGridPosition) => { const componentId = `comp_${idCounter}`; setIdCounter((prev) => prev + 1); - const newLayout = addComponentToV4Layout(layoutV4, componentId, type, containerId, `${type} ${idCounter}`); - setLayoutV4(newLayout); - saveToHistoryV4(newLayout); + const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`); + setLayout(newLayout); + saveToHistory(newLayout); setSelectedComponentId(componentId); - setSelectedContainerId(null); - setHasChanges(true); - console.log("[V4] 컴포넌트 추가, 히스토리 저장됨"); - }, - [idCounter, layoutV4, saveToHistoryV4] - ); - - const handleUpdateComponentV4 = useCallback( - (componentId: string, updates: Partial) => { - const newLayout = updateComponentInV4Layout(layoutV4, componentId, updates); - setLayoutV4(newLayout); - saveToHistoryV4(newLayout); setHasChanges(true); }, - [layoutV4, saveToHistoryV4] + [idCounter, layout, saveToHistory] ); - const handleUpdateContainerV4 = useCallback( - (containerId: string, updates: Partial) => { - if (currentViewportMode === "tablet_landscape") { - // 기본 모드 (태블릿 가로) → root 직접 수정 ✅ - const newLayout = { - ...layoutV4, - root: updateContainerV4(layoutV4.root, containerId, updates), - }; - setLayoutV4(newLayout); - saveToHistoryV4(newLayout); - setHasChanges(true); - console.log("[기본 모드] root 컨테이너 수정"); - } else { - // 다른 모드 → 속성 패널에서 수정 차단됨 (UI에서 비활성화) - toast.warning("기본 모드(태블릿 가로)에서만 속성을 변경할 수 있습니다"); - console.log("[다른 모드] 속성 수정 차단"); - } - }, - [layoutV4, currentViewportMode, saveToHistoryV4] - ); - - const handleDeleteComponentV4 = useCallback((componentId: string) => { - const newLayout = removeComponentFromV4Layout(layoutV4, componentId); - setLayoutV4(newLayout); - saveToHistoryV4(newLayout); - setSelectedComponentId(null); - setHasChanges(true); - console.log("[V4] 컴포넌트 삭제, 히스토리 저장됨"); - }, [layoutV4, saveToHistoryV4]); - - // v4: 현재 모드 배치 고정 (오버라이드 저장) 🔥 - const handleLockLayoutV4 = useCallback(() => { - if (currentViewportMode === "tablet_landscape") { - toast.info("기본 모드는 고정할 필요가 없습니다"); - return; - } - - if (!tempLayout) { - toast.info("변경사항이 없습니다"); - return; - } - - // 임시 레이아웃을 오버라이드에 저장 ✅ - const newLayout = { - ...layoutV4, - overrides: { - ...layoutV4.overrides, - [currentViewportMode]: { - ...layoutV4.overrides?.[currentViewportMode as keyof typeof layoutV4.overrides], - containers: { - root: { - direction: tempLayout.direction, - wrap: tempLayout.wrap, - gap: tempLayout.gap, - alignItems: tempLayout.alignItems, - justifyContent: tempLayout.justifyContent, - padding: tempLayout.padding, - children: tempLayout.children, // 순서 고정 - } - } - } - } - }; - - setLayoutV4(newLayout); - saveToHistoryV4(newLayout); - setTempLayout(null); // 임시 레이아웃 초기화 - setHasChanges(true); - toast.success(`${currentViewportMode} 모드 배치가 고정되었습니다`); - console.log(`[V4] ${currentViewportMode} 배치 고정됨 (tempLayout → overrides)`); - }, [layoutV4, currentViewportMode, tempLayout, saveToHistoryV4]); - - // v4: 오버라이드 초기화 (자동 계산으로 되돌리기) - const handleResetOverrideV4 = useCallback((mode: ViewportMode) => { - if (mode === "tablet_landscape") { - toast.info("기본 모드는 초기화할 수 없습니다"); - return; - } - - const newOverrides = { ...layoutV4.overrides }; - delete newOverrides[mode as keyof typeof newOverrides]; - - const newLayout = { - ...layoutV4, - overrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined - }; - - setLayoutV4(newLayout); - saveToHistoryV4(newLayout); - setHasChanges(true); - toast.success(`${mode} 모드 오버라이드가 초기화되었습니다`); - console.log(`[V4] ${mode} 오버라이드 초기화됨`); - }, [layoutV4, saveToHistoryV4]); - - // v4: 컴포넌트 크기 조정 (드래그) - 리사이즈 중에는 히스토리 저장 안 함 - // 리사이즈 완료 시 별도로 저장해야 함 (TODO: 드래그 종료 시 저장) - const handleResizeComponentV4 = useCallback( - (componentId: string, sizeUpdates: Partial) => { - const existingComponent = layoutV4.components[componentId]; + const handleUpdateComponent = useCallback( + (componentId: string, updates: Partial) => { + const existingComponent = layout.components[componentId]; if (!existingComponent) return; const newLayout = { - ...layoutV4, + ...layout, components: { - ...layoutV4.components, + ...layout.components, [componentId]: { ...existingComponent, - size: { - ...existingComponent.size, - ...sizeUpdates, - }, + ...updates, }, }, }; - setLayoutV4(newLayout); - // 리사이즈 중에는 히스토리 저장 안 함 (너무 많아짐) - // saveToHistoryV4(newLayout); + setLayout(newLayout); + saveToHistory(newLayout); setHasChanges(true); }, - [layoutV4] + [layout, saveToHistory] ); - // v4: 컴포넌트 순서 변경 (드래그 앤 드롭) - const handleReorderComponentV4 = useCallback( - (containerId: string, fromIndex: number, toIndex: number) => { - // 컨테이너 찾기 (재귀) - const reorderInContainer = (container: PopContainerV4): PopContainerV4 => { - if (container.id === containerId) { - const newChildren = [...container.children]; - const [movedItem] = newChildren.splice(fromIndex, 1); - newChildren.splice(toIndex, 0, movedItem); - return { ...container, children: newChildren }; - } - - // 자식 컨테이너에서도 찾기 - return { - ...container, - children: container.children.map(child => { - if (typeof child === "object") { - return reorderInContainer(child); - } - return child; - }), - }; + const handleDeleteComponent = useCallback( + (componentId: string) => { + const newComponents = { ...layout.components }; + delete newComponents[componentId]; + + const newLayout = { + ...layout, + components: newComponents, }; - - if (currentViewportMode === "tablet_landscape") { - // 기본 모드 → root 직접 수정 ✅ - const newLayout = { - ...layoutV4, - root: reorderInContainer(layoutV4.root), - }; - setLayoutV4(newLayout); - saveToHistoryV4(newLayout); - setHasChanges(true); - console.log("[기본 모드] 컴포넌트 순서 변경 (root 저장)"); - } else { - // 다른 모드 → 임시 레이아웃에만 저장 (화면에만 표시, layoutV4는 안 건드림) 🔥 - const reorderedRoot = reorderInContainer(layoutV4.root); - setTempLayout(reorderedRoot); - console.log(`[${currentViewportMode}] 컴포넌트 순서 변경 (임시, 고정 필요)`); - toast.info("배치 변경됨. '고정' 버튼을 클릭하여 저장하세요", { duration: 2000 }); - } + setLayout(newLayout); + saveToHistory(newLayout); + setSelectedComponentId(null); + setHasChanges(true); }, - [layoutV4, currentViewportMode, saveToHistoryV4] + [layout, saveToHistory] ); - // ======================================== - // v3: 디바이스/모드 전환 - // ======================================== - const handleDeviceChange = useCallback((device: DeviceType) => { - setActiveDevice(device); - setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape"); - }, []); - - const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => { - setActiveModeKey(modeKey); - }, []); - // ======================================== // 뒤로가기 // ======================================== const handleBack = useCallback(() => { if (hasChanges) { - if (confirm("저장하지 않은 변경사항이 있습니다. 나가시겠습니까?")) { + if (confirm("저장하지 않은 변경사항이 있습니다. 정말 나가시겠습니까?")) { onBackToList(); } } else { @@ -554,7 +245,7 @@ export default function PopDesigner({ }, [hasChanges, onBackToList]); // ======================================== - // 단축키 처리 (Delete, Undo, Redo) + // 단축키 처리 // ======================================== useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -570,53 +261,35 @@ export default function PopDesigner({ if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); if (selectedComponentId) { - layoutMode === "v3" ? handleDeleteComponentV3(selectedComponentId) : handleDeleteComponentV4(selectedComponentId); + handleDeleteComponent(selectedComponentId); } } - // Ctrl+Z / Cmd+Z: Undo (Shift 안 눌림) + // Ctrl+Z: Undo if (isCtrlOrCmd && key === "z" && !e.shiftKey) { e.preventDefault(); - console.log("Undo 시도:", { canUndo, layoutMode }); - if (canUndo) { - handleUndo(); - setHasChanges(true); - toast.success("실행 취소됨"); - } else { - toast.info("실행 취소할 내용이 없습니다"); - } + if (canUndo) undo(); return; } - // Ctrl+Shift+Z / Cmd+Shift+Z: Redo - if (isCtrlOrCmd && key === "z" && e.shiftKey) { + // Ctrl+Shift+Z or Ctrl+Y: Redo + if ((isCtrlOrCmd && key === "z" && e.shiftKey) || (isCtrlOrCmd && key === "y")) { e.preventDefault(); - console.log("Redo 시도:", { canRedo, layoutMode }); - if (canRedo) { - handleRedo(); - setHasChanges(true); - toast.success("다시 실행됨"); - } else { - toast.info("다시 실행할 내용이 없습니다"); - } + if (canRedo) redo(); return; } - // Ctrl+Y / Cmd+Y: Redo (대체) - if (isCtrlOrCmd && key === "y") { + // Ctrl+S: 저장 + if (isCtrlOrCmd && key === "s") { e.preventDefault(); - if (canRedo) { - handleRedo(); - setHasChanges(true); - toast.success("다시 실행됨"); - } + handleSave(); return; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedComponentId, layoutMode, handleDeleteComponentV3, handleDeleteComponentV4, canUndo, canRedo, handleUndo, handleRedo]); + }, [selectedComponentId, handleDeleteComponent, canUndo, canRedo, undo, redo, handleSave]); // ======================================== // 로딩 @@ -634,41 +307,25 @@ export default function PopDesigner({ // ======================================== return ( -
- {/* 툴바 */} -
+
+ {/* 헤더 */} +
{/* 왼쪽: 뒤로가기 + 화면명 */}
- - - {selectedScreen?.screenName || "POP 화면"} - - {hasChanges && *변경됨} -
- - {/* 중앙: 레이아웃 버전 + v3 디바이스 전환 */} -
- - {layoutMode === "v4" ? "자동 레이아웃 (v4)" : "4모드 레이아웃 (v3)"} - - - {layoutMode === "v3" && ( - handleDeviceChange(v as DeviceType)}> - - - - 태블릿 - - - - 모바일 - - - - )} +
+

{selectedScreen?.screenName}

+

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

+
{/* 오른쪽: Undo/Redo + 저장 */} @@ -679,11 +336,7 @@ export default function PopDesigner({ variant="ghost" size="icon" className="h-8 w-8" - onClick={() => { - handleUndo(); - setHasChanges(true); - toast.success("실행 취소됨"); - }} + onClick={undo} disabled={!canUndo} title="실행 취소 (Ctrl+Z)" > @@ -693,11 +346,7 @@ export default function PopDesigner({ variant="ghost" size="icon" className="h-8 w-8" - onClick={() => { - handleRedo(); - setHasChanges(true); - toast.success("다시 실행됨"); - }} + onClick={redo} disabled={!canRedo} title="다시 실행 (Ctrl+Shift+Z)" > @@ -715,84 +364,41 @@ export default function PopDesigner({ {/* 메인 영역 */} - {/* 왼쪽: 컴포넌트 패널 */} - - {layoutMode === "v3" ? ( - - ) : ( - - )} + {/* 왼쪽: 컴포넌트 팔레트 */} + + {/* 중앙: 캔버스 */} - - {layoutMode === "v3" ? ( - - ) : ( - - )} + + - {/* 오른쪽: 속성 패널 (v4만) */} - {layoutMode === "v4" && ( - <> - - - handleUpdateComponentV4(selectedComponentId, updates) - : undefined - } - onUpdateContainer={ - selectedContainerId - ? (updates) => handleUpdateContainerV4(selectedContainerId, updates) - : undefined - } - /> - - - )} + + + {/* 오른쪽: 속성 패널 */} + + handleUpdateComponent(selectedComponentId, updates) + : undefined + } + /> +
diff --git a/frontend/components/pop/designer/index.ts b/frontend/components/pop/designer/index.ts index 80f2db22..37d86aec 100644 --- a/frontend/components/pop/designer/index.ts +++ b/frontend/components/pop/designer/index.ts @@ -1,4 +1,4 @@ -// POP 디자이너 컴포넌트 export +// POP 디자이너 컴포넌트 export (v5 그리드 시스템) // 타입 export * from "./types"; @@ -7,18 +7,25 @@ export * from "./types"; export { default as PopDesigner } from "./PopDesigner"; // 캔버스 -export { PopCanvas } from "./PopCanvas"; +export { default as PopCanvas } from "./PopCanvas"; // 패널 -export { PopPanel } from "./panels/PopPanel"; +export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel"; -// 타입 재export (편의) +// 렌더러 +export { default as PopRenderer } from "./renderers/PopRenderer"; + +// 유틸리티 +export * from "./utils/gridUtils"; + +// 핵심 타입 재export (편의) export type { - PopLayoutData, - PopSectionData, - PopComponentData, + PopLayoutDataV5, + PopComponentDefinitionV5, PopComponentType, - GridPosition, - PopCanvasGrid, - PopInnerGrid, + 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 index 967452e7..3bf5036c 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -2,50 +2,73 @@ import React from "react"; import { cn } from "@/lib/utils"; -import { PopComponentDefinition, PopComponentConfig } from "../types/pop-layout"; -import { Settings, Database, Link2 } from "lucide-react"; +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"; // ======================================== -// Props 정의 +// Props // ======================================== interface ComponentEditorPanelProps { - /** 선택된 컴포넌트 (없으면 null) */ - component: PopComponentDefinition | null; - /** 컴포넌트 설정 변경 시 호출 */ - onConfigChange?: (config: Partial) => void; - /** 컴포넌트 라벨 변경 시 호출 */ - onLabelChange?: (label: string) => void; + /** 선택된 컴포넌트 */ + component: PopComponentDefinitionV5 | null; + /** 현재 모드 */ + currentMode: GridMode; + /** 컴포넌트 업데이트 */ + onUpdateComponent?: (updates: Partial) => void; /** 추가 className */ className?: string; } // ======================================== -// 컴포넌트 편집 패널 -// -// 역할: -// - 선택된 컴포넌트의 설정을 편집 -// - 3개 탭: 기본 설정 / 데이터 바인딩 / 데이터 연결 -// -// TODO: -// - 타입별 상세 설정 UI 구현 -// - 데이터 바인딩 UI 구현 -// - 데이터 플로우 UI 구현 +// 컴포넌트 타입별 라벨 +// ======================================== +const COMPONENT_TYPE_LABELS: Record = { + "pop-field": "필드", + "pop-button": "버튼", + "pop-list": "리스트", + "pop-indicator": "인디케이터", + "pop-scanner": "스캐너", + "pop-numpad": "숫자패드", + "pop-spacer": "스페이서", + "pop-break": "줄바꿈", +}; + +// ======================================== +// 컴포넌트 편집 패널 (v5 그리드 시스템) // ======================================== -export function ComponentEditorPanel({ +export default function ComponentEditorPanel({ component, - onConfigChange, - onLabelChange, + currentMode, + onUpdateComponent, className, }: ComponentEditorPanelProps) { - // 컴포넌트가 선택되지 않은 경우 + const breakpoint = GRID_BREAKPOINTS[currentMode]; + + // 선택된 컴포넌트 없음 if (!component) { return (
-

컴포넌트 편집

+

속성

컴포넌트를 선택하세요 @@ -54,50 +77,75 @@ export function ComponentEditorPanel({ ); } + // 기본 모드 여부 + const isDefaultMode = currentMode === "tablet_landscape"; + return ( -
+
{/* 헤더 */}

- {component.label || getComponentTypeLabel(component.type)} + {component.label || COMPONENT_TYPE_LABELS[component.type]}

{component.type}

+ {!isDefaultMode && ( +

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

+ )}
- {/* 탭 컨텐츠 */} - + {/* 탭 */} + + + + 위치 + 설정 + + + 표시 + 데이터 - - - 연결 - - {/* 기본 설정 탭 */} - - + - {/* 데이터 바인딩 탭 (뼈대) */} - - + {/* 설정 탭 */} + + - {/* 데이터 연결 탭 (뼈대) */} - - + {/* 표시 탭 */} + + + + + {/* 데이터 탭 */} + +
@@ -105,41 +153,186 @@ export function ComponentEditorPanel({ } // ======================================== -// 컴포넌트 설정 폼 +// 위치 편집 폼 +// ======================================== + +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: PopComponentDefinition; - onConfigChange?: (config: Partial) => void; - onLabelChange?: (label: string) => void; + component: PopComponentDefinitionV5; + onUpdate?: (updates: Partial) => void; } -function ComponentSettingsForm({ - component, - onConfigChange, - onLabelChange, -}: ComponentSettingsFormProps) { +function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) { return (
- {/* 라벨 입력 */} -
- - + + onLabelChange?.(e.target.value)} - placeholder="컴포넌트 라벨" + onChange={(e) => onUpdate?.({ label: e.target.value })} + placeholder="컴포넌트 이름" + className="h-8 text-xs" />
- {/* 타입별 설정 (TODO: 상세 구현) */} -
-

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

-

- (추후 구현 예정) + {/* 컴포넌트 타입별 설정 (추후 구현) */} +

+

+ {component.type} 전용 설정은 Phase 4에서 구현 예정

@@ -147,69 +340,82 @@ function ComponentSettingsForm({ } // ======================================== -// 데이터 바인딩 플레이스홀더 (뼈대) +// 표시/숨김 폼 +// ======================================== + +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에서 구현 예정 +

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

- 데이터 연결 설정 -

-

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

-

- (추후 구현 예정) -

-
-
-
- ); -} - -// ======================================== -// 헬퍼 함수 -// ======================================== - -function getComponentTypeLabel(type: string): string { - const labels: Record = { - "pop-field": "필드", - "pop-button": "버튼", - "pop-list": "리스트", - "pop-indicator": "인디케이터", - "pop-scanner": "스캐너", - "pop-numpad": "넘패드", - }; - return labels[type] || type; -} - -export default ComponentEditorPanel; diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx deleted file mode 100644 index 4df98ff1..00000000 --- a/frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx +++ /dev/null @@ -1,721 +0,0 @@ -"use client"; - -import React from "react"; -import { cn } from "@/lib/utils"; -import { - PopComponentDefinitionV4, - PopSizeConstraintV4, - PopContainerV4, - PopComponentType, -} from "../types/pop-layout"; -import { - Settings, - Database, - Link2, - MoveHorizontal, - MoveVertical, - Square, - Maximize2, - AlignCenter, - Eye, -} from "lucide-react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -// ======================================== -// Props 정의 -// ======================================== - -interface ComponentEditorPanelV4Props { - /** 선택된 컴포넌트 */ - component: PopComponentDefinitionV4 | null; - /** 선택된 컨테이너 */ - container: PopContainerV4 | null; - /** 현재 뷰포트 모드 */ - currentViewportMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; - /** 컴포넌트 업데이트 */ - onUpdateComponent?: (updates: Partial) => void; - /** 컨테이너 업데이트 */ - onUpdateContainer?: (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": "줄바꿈", -}; - -// ======================================== -// v4 컴포넌트 편집 패널 -// -// 핵심: -// - 크기 제약 편집 (fixed/fill/hug) -// - 반응형 숨김 설정 -// - 개별 정렬 설정 -// ======================================== - -export function ComponentEditorPanelV4({ - component, - container, - currentViewportMode = "tablet_landscape", - onUpdateComponent, - onUpdateContainer, - className, -}: ComponentEditorPanelV4Props) { - // 아무것도 선택되지 않은 경우 - if (!component && !container) { - return ( -
-
-

속성

-
-
- 컴포넌트 또는 컨테이너를 선택하세요 -
-
- ); - } - - // 컨테이너가 선택된 경우 - if (container) { - const isNonDefaultMode = currentViewportMode !== "tablet_landscape"; - - return ( -
-
-

컨테이너 설정

-

{container.id}

- {isNonDefaultMode && ( -

- 다른 모드에서는 드래그로 배치 변경 후 '고정' 버튼을 사용하세요 -

- )} -
-
- -
-
- ); - } - - // 컴포넌트가 선택된 경우 - return ( -
- {/* 헤더 */} -
-

- {component!.label || COMPONENT_TYPE_LABELS[component!.type]} -

-

{component!.type}

-
- - {/* 탭 컨텐츠 */} - - - - - 크기 - - - - 설정 - - - - 표시 - - - - 데이터 - - - - {/* 크기 제약 탭 */} - - - - - {/* 기본 설정 탭 */} - - - - - {/* 모드별 표시 탭 */} - - - - - {/* 데이터 바인딩 탭 */} - - - - -
- ); -} - -// ======================================== -// 크기 제약 폼 -// ======================================== - -interface SizeConstraintFormProps { - component: PopComponentDefinitionV4; - onUpdate?: (updates: Partial) => void; -} - -function SizeConstraintForm({ component, onUpdate }: SizeConstraintFormProps) { - const { size } = component; - - const handleSizeChange = ( - field: keyof PopSizeConstraintV4, - value: string | number | undefined - ) => { - onUpdate?.({ - size: { - ...size, - [field]: value, - }, - }); - }; - - return ( -
- {/* 너비 설정 */} -
- - -
- handleSizeChange("width", "fixed")} - label="고정" - description="px" - /> - handleSizeChange("width", "fill")} - label="채움" - description="flex" - /> - handleSizeChange("width", "hug")} - label="맞춤" - description="auto" - /> -
- - {/* 고정 너비 입력 */} - {size.width === "fixed" && ( -
- - handleSizeChange( - "fixedWidth", - e.target.value ? Number(e.target.value) : undefined - ) - } - placeholder="너비" - /> - px -
- )} - - {/* 채움일 때 최소/최대 */} - {size.width === "fill" && ( -
- - handleSizeChange( - "minWidth", - e.target.value ? Number(e.target.value) : undefined - ) - } - placeholder="최소" - /> - ~ - - handleSizeChange( - "maxWidth", - e.target.value ? Number(e.target.value) : undefined - ) - } - placeholder="최대" - /> - px -
- )} -
- - {/* 높이 설정 */} -
- - -
- handleSizeChange("height", "fixed")} - label="고정" - description="px" - /> - handleSizeChange("height", "fill")} - label="채움" - description="flex" - /> - handleSizeChange("height", "hug")} - label="맞춤" - description="auto" - /> -
- - {/* 고정 높이 입력 */} - {size.height === "fixed" && ( -
- - handleSizeChange( - "fixedHeight", - e.target.value ? Number(e.target.value) : undefined - ) - } - placeholder="높이" - /> - px -
- )} - - {/* 채움일 때 최소 */} - {size.height === "fill" && ( -
- - handleSizeChange( - "minHeight", - e.target.value ? Number(e.target.value) : undefined - ) - } - placeholder="최소 높이" - /> - px -
- )} -
- - {/* 개별 정렬 */} -
- - -
-
- ); -} - -// ======================================== -// 크기 버튼 컴포넌트 -// ======================================== - -interface SizeButtonProps { - active: boolean; - onClick: () => void; - label: string; - description: string; -} - -function SizeButton({ active, onClick, label, description }: SizeButtonProps) { - return ( - - ); -} - -// ======================================== -// 컨테이너 설정 폼 -// ======================================== - -interface ContainerSettingsFormProps { - container: PopContainerV4; - currentViewportMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; - onUpdate?: (updates: Partial) => void; -} - -function ContainerSettingsForm({ - container, - currentViewportMode = "tablet_landscape", - onUpdate, -}: ContainerSettingsFormProps) { - const isNonDefaultMode = currentViewportMode !== "tablet_landscape"; - - return ( -
- {/* 방향 */} -
- -
- - -
- {isNonDefaultMode && ( -

- 드래그로 배치 변경 후 '고정' 버튼 클릭 -

- )} -
- - {/* 줄바꿈 */} -
- -
- - -
-
- - {/* 간격 */} -
- -
- onUpdate?.({ gap: Number(e.target.value) || 0 })} - disabled={isNonDefaultMode} - /> - px -
-
- - {/* 패딩 */} -
- -
- - onUpdate?.({ padding: Number(e.target.value) || undefined }) - } - disabled={isNonDefaultMode} - /> - px -
-
- - {/* 정렬 */} -
- - -
- -
- - -
-
- ); -} - -// ======================================== -// 컴포넌트 설정 폼 -// ======================================== - -interface ComponentSettingsFormProps { - component: PopComponentDefinitionV4; - onUpdate?: (updates: Partial) => void; -} - -function ComponentSettingsForm({ - component, - onUpdate, -}: ComponentSettingsFormProps) { - return ( -
- {/* 라벨 입력 */} -
- - onUpdate?.({ label: e.target.value })} - placeholder="컴포넌트 라벨" - /> -
- - {/* 타입별 설정 (TODO: 상세 구현) */} -
-

- {COMPONENT_TYPE_LABELS[component.type]} 상세 설정 -

-

- (추후 구현 예정) -

-
-
- ); -} - -// ======================================== -// 모드별 표시/숨김 폼 -// ======================================== - -interface VisibilityFormProps { - component: PopComponentDefinitionV4; - onUpdate?: (updates: Partial) => void; -} - -function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { - const modes = [ - { key: "tablet_landscape" as const, label: "태블릿 가로 (1024×768)" }, - { key: "tablet_portrait" as const, label: "태블릿 세로 (768×1024)" }, - { key: "mobile_landscape" as const, label: "모바일 가로 (667×375)" }, - { key: "mobile_portrait" as const, label: "모바일 세로 (375×667)" }, - ]; - - return ( -
-
- -

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

- -
- {modes.map(({ key, label }) => { - const isChecked = component.visibility?.[key] !== false; - - return ( -
- { - onUpdate?.({ - visibility: { - ...component.visibility, - [key]: e.target.checked, - }, - }); - }} - className="h-4 w-4 rounded border-gray-300" - /> - - {!isChecked && ( - (숨김) - )} -
- ); - })} -
-
- - {/* 반응형 숨김 (픽셀 기반) */} -
- -
- - onUpdate?.({ - hideBelow: e.target.value ? Number(e.target.value) : undefined, - }) - } - placeholder="없음" - /> - px 이하에서 숨김 -
-

- 예: 500 입력 시 화면 너비가 500px 이하면 자동 숨김 -

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

- 데이터 바인딩 설정 -

-

- 테이블 선택 - 칼럼 선택 - 조인 설정 -

-

- (추후 구현 예정) -

-
-
-
- ); -} - -export default ComponentEditorPanelV4; diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx new file mode 100644 index 00000000..c7f80b97 --- /dev/null +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useDrag } from "react-dnd"; +import { cn } from "@/lib/utils"; +import { PopComponentType } from "../types/pop-layout"; +import { Square } from "lucide-react"; + +// DnD 타입 상수 +const DND_ITEM_TYPES = { + COMPONENT: "component", +} as const; + +// 컴포넌트 정의 +interface PaletteItem { + type: PopComponentType; + label: string; + icon: React.ElementType; + description: string; +} + +const PALETTE_ITEMS: PaletteItem[] = [ + { + type: "pop-sample", + label: "샘플 박스", + icon: Square, + 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/ComponentPaletteV4.tsx b/frontend/components/pop/designer/panels/ComponentPaletteV4.tsx deleted file mode 100644 index 2346d454..00000000 --- a/frontend/components/pop/designer/panels/ComponentPaletteV4.tsx +++ /dev/null @@ -1,159 +0,0 @@ -"use client"; - -import { useDrag } from "react-dnd"; -import { - Type, - MousePointer, - List, - Activity, - ScanLine, - Calculator, - GripVertical, - Space, - WrapText, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { PopComponentType } from "../types/pop-layout"; -import { DND_ITEM_TYPES, DragItemComponent } from "./PopPanel"; - -// ======================================== -// 컴포넌트 팔레트 정의 -// ======================================== -const COMPONENT_PALETTE: { - type: PopComponentType; - label: string; - icon: React.ElementType; - description: string; -}[] = [ - { - type: "pop-field", - label: "필드", - icon: Type, - description: "텍스트, 숫자 등 데이터 입력", - }, - { - type: "pop-button", - label: "버튼", - icon: MousePointer, - description: "저장, 삭제 등 액션 실행", - }, - { - type: "pop-list", - label: "리스트", - icon: List, - description: "데이터 목록 (카드 템플릿 지원)", - }, - { - type: "pop-indicator", - label: "인디케이터", - icon: Activity, - description: "KPI, 상태 표시", - }, - { - type: "pop-scanner", - label: "스캐너", - icon: ScanLine, - description: "바코드/QR 스캔", - }, - { - type: "pop-numpad", - label: "숫자패드", - icon: Calculator, - description: "숫자 입력 전용", - }, - { - type: "pop-spacer", - label: "스페이서", - icon: Space, - description: "빈 공간 (정렬용)", - }, - { - type: "pop-break", - label: "줄바꿈", - icon: WrapText, - description: "강제 줄바꿈 (flex-basis: 100%)", - }, -]; - -// ======================================== -// v4 컴포넌트 팔레트 -// ======================================== -export function ComponentPaletteV4() { - return ( -
- {/* 헤더 */} -
-
- 편집 중: v4 (자동 반응형) -
-
- 규칙 기반 레이아웃 -
-
- - {/* 컴포넌트 목록 */} -
-
- 컴포넌트 -
-
- {COMPONENT_PALETTE.map((item) => ( - - ))} -
-
- 캔버스로 드래그하여 배치 -
-
-
- ); -} - -// ======================================== -// 드래그 가능한 컴포넌트 아이템 -// ======================================== -interface DraggableComponentV4Props { - type: PopComponentType; - label: string; - icon: React.ElementType; - description: string; -} - -function DraggableComponentV4({ type, label, icon: Icon, description }: DraggableComponentV4Props) { - const [{ isDragging }, drag] = useDrag( - () => ({ - type: DND_ITEM_TYPES.COMPONENT, - item: { type: DND_ITEM_TYPES.COMPONENT, componentType: type } as DragItemComponent, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }), - [type] - ); - - return ( -
- - -
-
{label}
-
{description}
-
-
- ); -} - -export default ComponentPaletteV4; diff --git a/frontend/components/pop/designer/panels/PopPanel.tsx b/frontend/components/pop/designer/panels/PopPanel.tsx deleted file mode 100644 index f51913d5..00000000 --- a/frontend/components/pop/designer/panels/PopPanel.tsx +++ /dev/null @@ -1,368 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useDrag } from "react-dnd"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { - Plus, - Settings, - Type, - MousePointer, - List, - Activity, - ScanLine, - Calculator, - Trash2, - ChevronDown, - GripVertical, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { - PopLayoutDataV3, - PopLayoutModeKey, - PopComponentDefinition, - PopComponentType, - MODE_RESOLUTIONS, -} from "../types/pop-layout"; - -// ======================================== -// 드래그 아이템 타입 -// ======================================== -export const DND_ITEM_TYPES = { - COMPONENT: "component", -} as const; - -export interface DragItemComponent { - type: typeof DND_ITEM_TYPES.COMPONENT; - componentType: PopComponentType; -} - -// ======================================== -// 컴포넌트 팔레트 정의 -// ======================================== -const COMPONENT_PALETTE: { - type: PopComponentType; - label: string; - icon: React.ElementType; - description: string; -}[] = [ - { - type: "pop-field", - label: "필드", - icon: Type, - description: "텍스트, 숫자 등 데이터 입력", - }, - { - type: "pop-button", - label: "버튼", - icon: MousePointer, - description: "저장, 삭제 등 액션 실행", - }, - { - type: "pop-list", - label: "리스트", - icon: List, - description: "데이터 목록 (카드 템플릿 지원)", - }, - { - type: "pop-indicator", - label: "인디케이터", - icon: Activity, - description: "KPI, 상태 표시", - }, - { - type: "pop-scanner", - label: "스캐너", - icon: ScanLine, - description: "바코드/QR 스캔", - }, - { - type: "pop-numpad", - label: "숫자패드", - icon: Calculator, - description: "숫자 입력 전용", - }, -]; - -// ======================================== -// Props (v3: 섹션 없음) -// ======================================== -interface PopPanelProps { - layout: PopLayoutDataV3; - activeModeKey: PopLayoutModeKey; - selectedComponentId: string | null; - selectedComponent: PopComponentDefinition | null; - onUpdateComponentDefinition: (id: string, updates: Partial) => void; - onDeleteComponent: (id: string) => void; - activeDevice: "mobile" | "tablet"; -} - -// ======================================== -// 메인 컴포넌트 -// ======================================== -export function PopPanel({ - layout, - activeModeKey, - selectedComponentId, - selectedComponent, - onUpdateComponentDefinition, - onDeleteComponent, - activeDevice, -}: PopPanelProps) { - const [activeTab, setActiveTab] = useState("components"); - - // 현재 모드의 컴포넌트 위치 - const currentModeLayout = layout.layouts[activeModeKey]; - const selectedComponentPosition = selectedComponentId - ? currentModeLayout.componentPositions[selectedComponentId] - : null; - - return ( -
- - - - - 컴포넌트 - - - - 편집 - - - - {/* 컴포넌트 탭 */} - -
- {/* 현재 모드 표시 */} -
-

- 편집 중: {getModeLabel(activeModeKey)} -

-

- {MODE_RESOLUTIONS[activeModeKey].width} x {MODE_RESOLUTIONS[activeModeKey].height} -

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

- 컴포넌트 -

-
- {COMPONENT_PALETTE.map((item) => ( - - ))} -
-

- 캔버스로 드래그하여 배치 -

-
-
-
- - {/* 편집 탭 */} - - {selectedComponent && selectedComponentPosition ? ( - - onUpdateComponentDefinition(selectedComponent.id, updates) - } - onDelete={() => onDeleteComponent(selectedComponent.id)} - /> - ) : ( -
- 컴포넌트를 선택하세요 -
- )} -
-
-
- ); -} - -// ======================================== -// 모드 라벨 헬퍼 -// ======================================== -function getModeLabel(modeKey: PopLayoutModeKey): string { - const labels: Record = { - tablet_landscape: "태블릿 가로", - tablet_portrait: "태블릿 세로", - mobile_landscape: "모바일 가로", - mobile_portrait: "모바일 세로", - }; - return labels[modeKey]; -} - -// ======================================== -// 드래그 가능한 컴포넌트 아이템 -// ======================================== -interface DraggableComponentItemProps { - type: PopComponentType; - label: string; - icon: React.ElementType; - description: string; -} - -function DraggableComponentItem({ - type, - label, - icon: Icon, - description, -}: DraggableComponentItemProps) { - const [{ isDragging }, drag] = useDrag(() => ({ - type: DND_ITEM_TYPES.COMPONENT, - item: { type: DND_ITEM_TYPES.COMPONENT, componentType: type } as DragItemComponent, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - })); - - return ( -
- - -
-

{label}

-

{description}

-
-
- ); -} - -// ======================================== -// v3 컴포넌트 편집기 -// ======================================== -interface ComponentEditorV3Props { - component: PopComponentDefinition; - position: { col: number; row: number; colSpan: number; rowSpan: number }; - activeModeKey: PopLayoutModeKey; - onUpdateDefinition: (updates: Partial) => void; - onDelete: () => void; -} - -function ComponentEditorV3({ - component, - position, - activeModeKey, - onUpdateDefinition, - onDelete, -}: ComponentEditorV3Props) { - const [isPositionOpen, setIsPositionOpen] = useState(true); - - // 컴포넌트 타입 라벨 - const typeLabels: Record = { - "pop-field": "필드", - "pop-button": "버튼", - "pop-list": "리스트", - "pop-indicator": "인디케이터", - "pop-scanner": "스캐너", - "pop-numpad": "숫자패드", - }; - - return ( -
- {/* 컴포넌트 기본 정보 */} -
-
- {typeLabels[component.type]} -

{component.id}

-
- -
- - {/* 라벨 */} -
- - onUpdateDefinition({ label: e.target.value })} - placeholder="컴포넌트 이름" - className="h-8 text-xs" - /> -
- - {/* 현재 모드 위치 (읽기 전용) */} - - - 현재 모드 위치 - - - -
-

{getModeLabel(activeModeKey)}

-
-
- 시작 열: - {position.col} -
-
- 시작 행: - {position.row} -
-
- 열 크기: - {position.colSpan} -
-
- 행 크기: - {position.rowSpan} -
-
-
-

- 위치/크기는 캔버스에서 드래그하여 조정하세요. - 각 모드(가로/세로)별로 별도 저장됩니다. -

-
-
- - {/* TODO: 컴포넌트별 설정 (config) */} -
-

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

-
-
- ); -} diff --git a/frontend/components/pop/designer/panels/index.ts b/frontend/components/pop/designer/panels/index.ts index baeb4880..f2a70880 100644 --- a/frontend/components/pop/designer/panels/index.ts +++ b/frontend/components/pop/designer/panels/index.ts @@ -1,4 +1,3 @@ -// POP 디자이너 패널 export -export { PopPanel } from "./PopPanel"; -export { ComponentEditorPanel } from "./ComponentEditorPanel"; -export { ComponentEditorPanelV4 } from "./ComponentEditorPanelV4"; +// POP 디자이너 패널 export (v5 그리드 시스템) +export { default as ComponentEditorPanel } from "./ComponentEditorPanel"; +export { default as ComponentPalette } from "./ComponentPalette"; diff --git a/frontend/components/pop/designer/renderers/ComponentRenderer.tsx b/frontend/components/pop/designer/renderers/ComponentRenderer.tsx deleted file mode 100644 index 670e48f9..00000000 --- a/frontend/components/pop/designer/renderers/ComponentRenderer.tsx +++ /dev/null @@ -1,237 +0,0 @@ -"use client"; - -import React, { forwardRef } from "react"; -import { cn } from "@/lib/utils"; -import { - PopComponentDefinition, - PopComponentType, - GridPosition, -} from "../types/pop-layout"; - -// ======================================== -// Props 정의 -// ======================================== - -interface ComponentRendererProps { - /** 컴포넌트 정의 (타입, 라벨, 설정 등) */ - component: PopComponentDefinition; - /** 컴포넌트의 그리드 위치 (섹션 내부 그리드 기준) */ - position: GridPosition; - /** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */ - isDesignMode?: boolean; - /** 선택된 상태인지 */ - isSelected?: boolean; - /** 컴포넌트 클릭 시 호출 */ - onClick?: () => void; - /** 추가 className */ - className?: string; -} - -// ======================================== -// 컴포넌트 렌더러 -// -// 역할: -// - 관리자가 설정한 GridPosition(col, row, colSpan, rowSpan)을 -// 그대로 CSS Grid에 반영 -// - 디자이너/뷰어 모두에서 동일한 렌더링 보장 -// - 디자인 모드에서는 선택 상태 표시 -// ======================================== - -export const ComponentRenderer = forwardRef( - function ComponentRenderer( - { - component, - position, - isDesignMode = false, - isSelected = false, - onClick, - className, - }, - ref - ) { - const { type, label, config } = component; - - return ( -
{ - e.stopPropagation(); - onClick?.(); - }} - > - {/* 컴포넌트 타입별 미리보기 렌더링 */} - -
- ); - } -); - -// ======================================== -// 컴포넌트 타입별 미리보기 -// ======================================== - -interface ComponentPreviewProps { - type: PopComponentType; - label?: string; - config?: any; -} - -function ComponentPreview({ type, label, config }: ComponentPreviewProps) { - switch (type) { - case "pop-field": - return ; - case "pop-button": - return ; - case "pop-list": - return ; - case "pop-indicator": - return ; - case "pop-scanner": - return ; - case "pop-numpad": - return ; - default: - return ( -
- {label || type} -
- ); - } -} - -// ======================================== -// 개별 컴포넌트 미리보기 -// ======================================== - -function FieldPreview({ label, config }: { label?: string; config?: any }) { - const fieldType = config?.fieldType || "text"; - const placeholder = config?.placeholder || "입력하세요"; - const required = config?.required || false; - - return ( -
- {/* 라벨 */} - - {label || "필드"} - {required && *} - - {/* 입력 필드 미리보기 */} -
- {placeholder} -
-
- ); -} - -function ButtonPreview({ label, config }: { label?: string; config?: any }) { - const buttonType = config?.buttonType || "action"; - const variant = buttonType === "submit" ? "bg-primary text-white" : "bg-gray-100 text-gray-700"; - - return ( -
-
- {label || "버튼"} -
-
- ); -} - -function ListPreview({ label, config }: { label?: string; config?: any }) { - const itemCount = config?.itemsPerPage || 5; - - return ( -
- {/* 라벨 */} - {label || "리스트"} - {/* 리스트 아이템 미리보기 */} -
- {Array.from({ length: Math.min(3, itemCount) }).map((_, i) => ( -
- ))} - {itemCount > 3 && ( -
- +{itemCount - 3} more -
- )} -
-
- ); -} - -function IndicatorPreview({ label, config }: { label?: string; config?: any }) { - const indicatorType = config?.indicatorType || "kpi"; - const unit = config?.unit || ""; - - return ( -
- {/* 라벨 */} - {label || "KPI"} - {/* 값 미리보기 */} - - 0{unit && {unit}} - -
- ); -} - -function ScannerPreview({ label, config }: { label?: string; config?: any }) { - const scannerType = config?.scannerType || "camera"; - - return ( -
- {/* QR 아이콘 */} -
- QR -
- {/* 라벨 */} - {label || "스캐너"} -
- ); -} - -function NumpadPreview({ label, config }: { label?: string; config?: any }) { - const keys = [1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"]; - - return ( -
- {/* 라벨 */} - {label && ( - {label} - )} - {/* 넘패드 미리보기 */} -
- {keys.map((key) => ( -
- {key} -
- ))} -
-
- ); -} - -export default ComponentRenderer; diff --git a/frontend/components/pop/designer/renderers/PopFlexRenderer.tsx b/frontend/components/pop/designer/renderers/PopFlexRenderer.tsx deleted file mode 100644 index 313de675..00000000 --- a/frontend/components/pop/designer/renderers/PopFlexRenderer.tsx +++ /dev/null @@ -1,912 +0,0 @@ -"use client"; - -import React, { useMemo, useState, useCallback, useRef } from "react"; -import { useDrag, useDrop } from "react-dnd"; -import { cn } from "@/lib/utils"; -import { - PopLayoutDataV4, - PopContainerV4, - PopComponentDefinitionV4, - PopResponsiveRuleV4, - PopSizeConstraintV4, - PopComponentType, -} from "../types/pop-layout"; - -// 드래그 아이템 타입 -const DND_COMPONENT_REORDER = "POP_COMPONENT_REORDER"; - -interface DragItem { - type: string; - componentId: string; - containerId: string; - index: number; -} - -// ======================================== -// Props 정의 -// ======================================== - -interface PopFlexRendererProps { - /** v4 레이아웃 데이터 */ - layout: PopLayoutDataV4; - /** 현재 뷰포트 너비 (반응형 규칙 적용용) */ - viewportWidth: number; - /** 현재 뷰포트 모드 (오버라이드 병합용) */ - currentMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; - /** 임시 레이아웃 (고정 전 미리보기) */ - tempLayout?: PopContainerV4 | null; - /** 디자인 모드 여부 */ - isDesignMode?: boolean; - /** 선택된 컴포넌트 ID */ - selectedComponentId?: string | null; - /** 컴포넌트 클릭 */ - onComponentClick?: (componentId: string) => void; - /** 컨테이너 클릭 */ - onContainerClick?: (containerId: string) => void; - /** 배경 클릭 */ - onBackgroundClick?: () => void; - /** 컴포넌트 크기 변경 */ - onComponentResize?: (componentId: string, size: Partial) => void; - /** 컴포넌트 순서 변경 */ - onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => 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": "줄바꿈", -}; - -// ======================================== -// v4 Flexbox 렌더러 -// -// 핵심 역할: -// - v4 레이아웃을 Flexbox CSS로 렌더링 -// - 제약조건(fill/fixed/hug) 기반 크기 계산 -// - 반응형 규칙(breakpoint) 자동 적용 -// ======================================== - -export function PopFlexRenderer({ - layout, - viewportWidth, - currentMode = "tablet_landscape", - tempLayout, - isDesignMode = false, - selectedComponentId, - onComponentClick, - onContainerClick, - onBackgroundClick, - onComponentResize, - onReorderComponent, - className, -}: PopFlexRendererProps) { - const { root, components, settings, overrides } = layout; - - // 오버라이드 병합 로직 (컨테이너) 🔥 - const getMergedRoot = (): PopContainerV4 => { - // 1. 임시 레이아웃이 있으면 최우선 (고정 전 미리보기) - if (tempLayout) { - return tempLayout; - } - - // 2. 기본 모드면 root 그대로 반환 - if (currentMode === "tablet_landscape") { - return root; - } - - // 3. 다른 모드면 오버라이드 병합 - const override = overrides?.[currentMode]?.containers?.root; - if (override) { - return { - ...root, - ...override, // 오버라이드 속성으로 덮어쓰기 - }; - } - - // 4. 오버라이드 없으면 기본값 - return root; - }; - - // visibility 체크 함수 🆕 - const isComponentVisible = (component: PopComponentDefinitionV4): boolean => { - if (!component.visibility) return true; // 기본값: 표시 - const modeVisibility = component.visibility[currentMode as keyof typeof component.visibility]; - return modeVisibility !== false; // undefined도 true로 취급 - }; - - // 컴포넌트 오버라이드 병합 🆕 - const getMergedComponent = (baseComponent: PopComponentDefinitionV4): PopComponentDefinitionV4 => { - if (currentMode === "tablet_landscape") return baseComponent; - - const componentOverride = overrides?.[currentMode]?.components?.[baseComponent.id]; - if (!componentOverride) return baseComponent; - - // 깊은 병합 (config, size) - return { - ...baseComponent, - ...componentOverride, - size: { ...baseComponent.size, ...componentOverride.size }, - config: { ...baseComponent.config, ...componentOverride.config }, - }; - }; - - const effectiveRoot = getMergedRoot(); - - // 빈 상태는 PopCanvasV4에서 표시하므로 여기서는 투명 배경만 렌더링 - if (effectiveRoot.children.length === 0) { - return ( -
- ); - } - - return ( -
{ - if (e.target === e.currentTarget) { - onBackgroundClick?.(); - } - }} - > - {/* 루트 컨테이너 렌더링 (병합된 레이아웃 사용) */} - -
- ); -} - -// ======================================== -// 컨테이너 렌더러 (재귀) -// ======================================== - -interface ContainerRendererProps { - container: PopContainerV4; - components: Record; - viewportWidth: number; - settings: PopLayoutDataV4["settings"]; - currentMode: string; - overrides: PopLayoutDataV4["overrides"]; - isDesignMode?: boolean; - selectedComponentId?: string | null; - onComponentClick?: (componentId: string) => void; - onContainerClick?: (containerId: string) => void; - onComponentResize?: (componentId: string, size: Partial) => void; - onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void; - depth?: number; -} - -function ContainerRenderer({ - container, - components, - viewportWidth, - settings, - currentMode, - overrides, - isDesignMode = false, - selectedComponentId, - onComponentClick, - onContainerClick, - onComponentResize, - onReorderComponent, - depth = 0, -}: ContainerRendererProps) { - // visibility 체크 함수 - const isComponentVisible = (component: PopComponentDefinitionV4): boolean => { - if (!component.visibility) return true; // 기본값: 표시 - const modeVisibility = component.visibility[currentMode as keyof typeof component.visibility]; - return modeVisibility !== false; // undefined도 true로 취급 - }; - - // 컴포넌트 오버라이드 병합 - const getMergedComponent = (baseComponent: PopComponentDefinitionV4): PopComponentDefinitionV4 => { - if (currentMode === "tablet_landscape") return baseComponent; - - const componentOverride = overrides?.[currentMode as keyof typeof overrides]?.components?.[baseComponent.id]; - if (!componentOverride) return baseComponent; - - // 깊은 병합 (config, size) - return { - ...baseComponent, - ...componentOverride, - size: { ...baseComponent.size, ...componentOverride.size }, - config: { ...baseComponent.config, ...componentOverride.config }, - }; - }; - // 반응형 규칙 적용 - const effectiveContainer = useMemo(() => { - return applyResponsiveRules(container, viewportWidth); - }, [container, viewportWidth]); - - // 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용) - const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH; - - // Flexbox 스타일 계산 (useMemo는 조건문 전에 호출해야 함) - const containerStyle = useMemo((): React.CSSProperties => { - const { direction, wrap, gap, alignItems, justifyContent, padding } = effectiveContainer; - - // gap과 padding도 스케일 적용 - const scaledGap = gap * scale; - const scaledPadding = padding ? padding * scale : undefined; - - return { - display: "flex", - flexDirection: direction === "horizontal" ? "row" : "column", - flexWrap: wrap ? "wrap" : "nowrap", - gap: `${scaledGap}px`, - alignItems: mapAlignment(alignItems), - justifyContent: mapJustify(justifyContent), - padding: scaledPadding ? `${scaledPadding}px` : undefined, - width: "100%", - minHeight: depth === 0 ? "100%" : undefined, - }; - }, [effectiveContainer, depth, scale]); - - // 숨김 처리 - if (effectiveContainer.hidden) { - return null; - } - - return ( -
0 && "border border-dashed border-gray-300 rounded" - )} - style={containerStyle} - onClick={(e) => { - if (e.target === e.currentTarget) { - onContainerClick?.(container.id); - } - }} - > - {effectiveContainer.children.map((child, index) => { - // 중첩 컨테이너인 경우 - if (typeof child === "object") { - return ( - - ); - } - - // 컴포넌트 ID인 경우 - const componentId = child; - const baseComponent = components[componentId]; - if (!baseComponent) return null; - - // visibility 체크 (모드별 숨김) - if (!isComponentVisible(baseComponent)) { - return null; - } - - // 반응형 숨김 처리 (픽셀 기반) - if (baseComponent.hideBelow && viewportWidth < baseComponent.hideBelow) { - return null; - } - - // 오버라이드 병합 - const mergedComponent = getMergedComponent(baseComponent); - - // pop-break 특수 처리 - if (mergedComponent.type === "pop-break") { - return ( -
onComponentClick?.(componentId)} - > - {isDesignMode && ( - 줄바꿈 - )} -
- ); - } - - return ( - - onComponentClick?.(componentId)} - onResize={onComponentResize} - /> - - ); - })} -
- ); -} - -// ======================================== -// 드래그 가능한 컴포넌트 래퍼 -// ======================================== - -interface DraggableComponentWrapperProps { - componentId: string; - containerId: string; - index: number; - isDesignMode: boolean; - onReorder?: (containerId: string, fromIndex: number, toIndex: number) => void; - children: React.ReactNode; -} - -function DraggableComponentWrapper({ - componentId, - containerId, - index, - isDesignMode, - onReorder, - children, -}: DraggableComponentWrapperProps) { - // 디자인 모드가 아니면 그냥 children 반환 (훅 호출 전에 체크) - // DndProvider가 없는 환경에서 useDrag/useDrop 훅 호출 방지 - if (!isDesignMode) { - return <>{children}; - } - - // 디자인 모드일 때만 드래그 기능 활성화 - return ( - - {children} - - ); -} - -// 디자인 모드 전용 내부 컴포넌트 (DndProvider 필요) -function DraggableComponentWrapperInner({ - componentId, - containerId, - index, - onReorder, - children, -}: Omit) { - const ref = useRef(null); - - const [{ isDragging }, drag] = useDrag({ - type: DND_COMPONENT_REORDER, - item: (): DragItem => ({ - type: DND_COMPONENT_REORDER, - componentId, - containerId, - index, - }), - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }); - - const [{ isOver, canDrop }, drop] = useDrop({ - accept: DND_COMPONENT_REORDER, - canDrop: (item: DragItem) => { - // 같은 컨테이너 내에서만 이동 가능 (일단은) - return item.containerId === containerId && item.index !== index; - }, - drop: (item: DragItem) => { - if (item.index !== index && onReorder) { - onReorder(containerId, item.index, index); - } - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), - }), - }); - - // drag와 drop 합치기 - drag(drop(ref)); - - return ( -
- {children} - {/* 드롭 인디케이터 */} - {isOver && canDrop && ( -
- )} -
- ); -} - -// ======================================== -// v4 컴포넌트 렌더러 (리사이즈 핸들 포함) -// ======================================== - -interface ComponentRendererV4Props { - componentId: string; - component: PopComponentDefinitionV4; - settings: PopLayoutDataV4["settings"]; - viewportWidth: number; - isDesignMode?: boolean; - isSelected?: boolean; - onClick?: () => void; - onResize?: (componentId: string, size: Partial) => void; -} - -function ComponentRendererV4({ - componentId, - component, - settings, - viewportWidth, - isDesignMode = false, - isSelected = false, - onClick, - onResize, -}: ComponentRendererV4Props) { - const { size, alignSelf, type, label } = component; - const containerRef = useRef(null); - - // 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용) - const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH; - - // 리사이즈 상태 - const [isResizing, setIsResizing] = useState(false); - const [resizeDirection, setResizeDirection] = useState<"width" | "height" | "both" | null>(null); - const resizeStartRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null); - - // 크기 스타일 계산 (스케일 적용) - const sizeStyle = useMemo((): React.CSSProperties => { - return calculateSizeStyle(size, settings, scale); - }, [size, settings, scale]); - - // alignSelf 스타일 - const alignStyle: React.CSSProperties = alignSelf - ? { alignSelf: mapAlignment(alignSelf) } - : {}; - - const typeLabel = COMPONENT_TYPE_LABELS[type] || type; - - // 리사이즈 시작 - const handleResizeStart = useCallback(( - e: React.MouseEvent, - direction: "width" | "height" | "both" - ) => { - e.stopPropagation(); - e.preventDefault(); - - if (!containerRef.current) return; - - const rect = containerRef.current.getBoundingClientRect(); - resizeStartRef.current = { - x: e.clientX, - y: e.clientY, - width: rect.width, - height: rect.height, - }; - setIsResizing(true); - setResizeDirection(direction); - }, []); - - // 리사이즈 중 - useCallback((e: MouseEvent) => { - if (!isResizing || !resizeStartRef.current || !onResize) return; - - const deltaX = e.clientX - resizeStartRef.current.x; - const deltaY = e.clientY - resizeStartRef.current.y; - - const updates: Partial = {}; - - if (resizeDirection === "width" || resizeDirection === "both") { - const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX)); - updates.width = "fixed"; - updates.fixedWidth = newWidth; - } - - if (resizeDirection === "height" || resizeDirection === "both") { - const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY)); - updates.height = "fixed"; - updates.fixedHeight = newHeight; - } - - onResize(componentId, updates); - }, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]); - - // 리사이즈 종료 및 이벤트 등록 - React.useEffect(() => { - if (!isResizing) return; - - const handleMouseMove = (e: MouseEvent) => { - if (!resizeStartRef.current || !onResize) return; - - const deltaX = e.clientX - resizeStartRef.current.x; - const deltaY = e.clientY - resizeStartRef.current.y; - - const updates: Partial = {}; - - if (resizeDirection === "width" || resizeDirection === "both") { - const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX)); - updates.width = "fixed"; - updates.fixedWidth = newWidth; - } - - if (resizeDirection === "height" || resizeDirection === "both") { - const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY)); - updates.height = "fixed"; - updates.fixedHeight = newHeight; - } - - onResize(componentId, updates); - }; - - const handleMouseUp = () => { - setIsResizing(false); - setResizeDirection(null); - resizeStartRef.current = null; - }; - - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - - return () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - }, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]); - - return ( -
{ - e.stopPropagation(); - if (!isResizing) { - onClick?.(); - } - }} - > - {/* 컴포넌트 라벨 (디자인 모드에서만) */} - {isDesignMode && ( -
- - {label || typeLabel} - -
- )} - - {/* 컴포넌트 내용 */} -
- {renderComponentContent(component, isDesignMode, settings)} -
- - {/* 리사이즈 핸들 (디자인 모드 + 선택 시에만) */} - {isDesignMode && isSelected && onResize && ( - <> - {/* 오른쪽 핸들 (너비 조정) */} -
handleResizeStart(e, "width")} - title="너비 조정" - > -
-
- - {/* 아래쪽 핸들 (높이 조정) */} -
handleResizeStart(e, "height")} - title="높이 조정" - > -
-
- - {/* 오른쪽 아래 핸들 (너비 + 높이 동시 조정) */} -
handleResizeStart(e, "both")} - title="크기 조정" - > -
-
- - )} -
- ); -} - -// ======================================== -// 헬퍼 함수들 -// ======================================== - -/** - * 반응형 규칙 적용 - */ -function applyResponsiveRules( - container: PopContainerV4, - viewportWidth: number -): PopContainerV4 & { hidden?: boolean } { - if (!container.responsive || container.responsive.length === 0) { - return container; - } - - // 현재 뷰포트에 적용되는 규칙 찾기 (가장 큰 breakpoint부터) - const sortedRules = [...container.responsive].sort((a, b) => b.breakpoint - a.breakpoint); - const applicableRule = sortedRules.find((rule) => viewportWidth <= rule.breakpoint); - - if (!applicableRule) { - return container; - } - - return { - ...container, - direction: applicableRule.direction ?? container.direction, - gap: applicableRule.gap ?? container.gap, - hidden: applicableRule.hidden ?? false, - }; -} - -/** - * 기준 뷰포트 너비 (10인치 태블릿 가로) - * 이 크기를 기준으로 컴포넌트 크기가 비율 조정됨 - */ -const BASE_VIEWPORT_WIDTH = 1024; - -/** - * 크기 제약 → CSS 스타일 변환 - * @param scale - 뷰포트 비율 (viewportWidth / BASE_VIEWPORT_WIDTH) - */ -function calculateSizeStyle( - size: PopSizeConstraintV4, - settings: PopLayoutDataV4["settings"], - scale: number = 1 -): React.CSSProperties { - const style: React.CSSProperties = {}; - - // 스케일된 터치 최소 크기 - const scaledTouchMin = settings.touchTargetMin * scale; - - // 너비 - switch (size.width) { - case "fixed": - // fixed 크기도 비율에 맞게 스케일 - style.width = size.fixedWidth ? `${size.fixedWidth * scale}px` : "auto"; - style.flexShrink = 0; - break; - case "fill": - style.flex = 1; - style.minWidth = size.minWidth ? `${size.minWidth * scale}px` : 0; - style.maxWidth = size.maxWidth ? `${size.maxWidth * scale}px` : undefined; - break; - case "hug": - style.width = "auto"; - style.flexShrink = 0; - break; - } - - // 높이 - switch (size.height) { - case "fixed": - const scaledFixedHeight = (size.fixedHeight || settings.touchTargetMin) * scale; - const minHeight = Math.max(scaledFixedHeight, scaledTouchMin); - style.height = `${minHeight}px`; - break; - case "fill": - style.flexGrow = 1; - style.minHeight = size.minHeight - ? `${Math.max(size.minHeight * scale, scaledTouchMin)}px` - : `${scaledTouchMin}px`; - break; - case "hug": - style.height = "auto"; - style.minHeight = `${scaledTouchMin}px`; - break; - } - - return style; -} - -/** - * alignItems 값 변환 - */ -function mapAlignment(value: string): React.CSSProperties["alignItems"] { - switch (value) { - case "start": - return "flex-start"; - case "end": - return "flex-end"; - case "center": - return "center"; - case "stretch": - return "stretch"; - default: - return "stretch"; - } -} - -/** - * justifyContent 값 변환 - */ -function mapJustify(value: string): React.CSSProperties["justifyContent"] { - switch (value) { - case "start": - return "flex-start"; - case "end": - return "flex-end"; - case "center": - return "center"; - case "space-between": - return "space-between"; - default: - return "flex-start"; - } -} - -/** - * 컴포넌트별 내용 렌더링 - */ -function renderComponentContent( - component: PopComponentDefinitionV4, - isDesignMode: boolean, - settings: PopLayoutDataV4["settings"] -): React.ReactNode { - const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; - - // 디자인 모드에서는 플레이스홀더 표시 - if (isDesignMode) { - // Spacer는 디자인 모드에서 점선 배경으로 표시 - if (component.type === "pop-spacer") { - return ( -
- 빈 공간 -
- ); - } - return ( -
- {typeLabel} -
- ); - } - - // 뷰어 모드: 실제 컴포넌트 렌더링 - switch (component.type) { - case "pop-field": - return ( - - ); - - case "pop-button": - return ( - - ); - - case "pop-list": - return ( -
-
- 리스트 (데이터 연결 필요) -
-
- ); - - case "pop-indicator": - return ( -
-
0
-
{component.label || "지표"}
-
- ); - - case "pop-scanner": - return ( -
-
스캐너
-
탭하여 스캔
-
- ); - - case "pop-numpad": - return ( -
- {[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => ( - - ))} -
- ); - - case "pop-spacer": - // 실제 모드에서 Spacer는 완전히 투명 (공간만 차지) - return null; - - default: - return ( -
- {typeLabel} -
- ); - } -} - -export default PopFlexRenderer; diff --git a/frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx b/frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx deleted file mode 100644 index ef1572fb..00000000 --- a/frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx +++ /dev/null @@ -1,401 +0,0 @@ -"use client"; - -import React from "react"; -import { cn } from "@/lib/utils"; -import { - PopLayoutDataV3, - PopLayoutModeKey, - PopModeLayoutV3, - GridPosition, - MODE_RESOLUTIONS, - PopComponentDefinition, -} from "../types/pop-layout"; - -// ======================================== -// Props 정의 -// ======================================== - -interface PopLayoutRendererProps { - /** 레이아웃 데이터 (v3.0) */ - layout: PopLayoutDataV3; - /** 현재 모드 키 (tablet_landscape, tablet_portrait 등) */ - modeKey: PopLayoutModeKey; - /** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */ - isDesignMode?: boolean; - /** 선택된 컴포넌트 ID */ - selectedComponentId?: string | null; - /** 컴포넌트 클릭 시 호출 */ - onComponentClick?: (componentId: string) => void; - /** 배경 클릭 시 호출 (선택 해제용) */ - onBackgroundClick?: () => void; - /** 커스텀 모드 레이아웃 (fallback 등에서 변환된 레이아웃 사용 시) */ - customModeLayout?: PopModeLayoutV3; - /** 추가 className */ - className?: string; - /** 추가 style */ - style?: React.CSSProperties; -} - -// ======================================== -// 컴포넌트 타입별 라벨 -// ======================================== -const COMPONENT_TYPE_LABELS: Record = { - "pop-field": "필드", - "pop-button": "버튼", - "pop-list": "리스트", - "pop-indicator": "인디케이터", - "pop-scanner": "스캐너", - "pop-numpad": "숫자패드", -}; - -// ======================================== -// POP 레이아웃 렌더러 (v3) -// -// 핵심 역할: -// - 디자이너와 뷰어에서 **동일한** 렌더링 결과 보장 -// - 컴포넌트가 캔버스에 직접 배치 (섹션 없음) -// - CSS Grid + 1fr 비율 기반 -// ======================================== - -export function PopLayoutRenderer({ - layout, - modeKey, - isDesignMode = false, - selectedComponentId, - onComponentClick, - onBackgroundClick, - customModeLayout, - className, - style, -}: PopLayoutRendererProps) { - const { components, layouts, settings } = layout; - const canvasGrid = settings.canvasGrid; - - // 현재 모드의 레이아웃 - const modeLayout = customModeLayout || layouts[modeKey]; - - // 컴포넌트가 없으면 빈 상태 표시 - if (!modeLayout || Object.keys(modeLayout.componentPositions).length === 0) { - return ( -
-
-

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

- {isDesignMode &&

컴포넌트를 추가해주세요

} -
-
- ); - } - - // 컴포넌트 ID 목록 - const componentIds = Object.keys(modeLayout.componentPositions); - - return ( -
{ - if (e.target === e.currentTarget) { - onBackgroundClick?.(); - } - }} - > - {/* 컴포넌트들 직접 렌더링 */} - {componentIds.map((componentId) => { - const compDef = components[componentId]; - const compPos = modeLayout.componentPositions[componentId]; - - if (!compDef || !compPos) return null; - - return ( - onComponentClick?.(componentId)} - /> - ); - })} -
- ); -} - -// ======================================== -// 컴포넌트 렌더러 -// ======================================== - -interface ComponentRendererProps { - componentId: string; - component: PopComponentDefinition; - position: GridPosition; - isDesignMode?: boolean; - isSelected?: boolean; - onComponentClick?: () => void; -} - -function ComponentRenderer({ - componentId, - component, - position, - isDesignMode = false, - isSelected = false, - onComponentClick, -}: ComponentRendererProps) { - const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; - - return ( -
{ - e.stopPropagation(); - onComponentClick?.(); - }} - > - {/* 컴포넌트 라벨 (디자인 모드에서만) */} - {isDesignMode && ( -
- - {component.label || typeLabel} - -
- )} - - {/* 컴포넌트 내용 */} -
- {renderComponentContent(component, isDesignMode)} -
-
- ); -} - -// ======================================== -// 컴포넌트별 렌더링 -// ======================================== - -function renderComponentContent( - component: PopComponentDefinition, - isDesignMode: boolean -): React.ReactNode { - const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; - - // 디자인 모드에서는 플레이스홀더 표시 - if (isDesignMode) { - return ( -
- {typeLabel} -
- ); - } - - // 뷰어 모드: 실제 컴포넌트 렌더링 - switch (component.type) { - case "pop-field": - return ( - - ); - - case "pop-button": - return ( - - ); - - case "pop-list": - return ( -
-
- 리스트 (데이터 연결 필요) -
-
- ); - - case "pop-indicator": - return ( -
-
0
-
{component.label || "지표"}
-
- ); - - case "pop-scanner": - return ( -
-
스캐너
-
탭하여 스캔
-
- ); - - case "pop-numpad": - return ( -
- {[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => ( - - ))} -
- ); - - default: - return ( -
- {typeLabel} -
- ); - } -} - -// ======================================== -// 헬퍼 함수들 (export) -// ======================================== - -/** - * 특정 모드에 레이아웃이 설정되어 있는지 확인 - */ -export function hasModeLayout( - layout: PopLayoutDataV3, - modeKey: PopLayoutModeKey -): boolean { - const modeLayout = layout.layouts[modeKey]; - return modeLayout && Object.keys(modeLayout.componentPositions).length > 0; -} - -/** - * 태블릿 가로 모드(기준 모드)가 설정되어 있는지 확인 - */ -export function hasBaseLayout(layout: PopLayoutDataV3): boolean { - return hasModeLayout(layout, "tablet_landscape"); -} - -/** - * 태블릿 가로 모드를 기준으로 다른 모드에 맞게 자동 변환 - */ -export function autoConvertLayout( - layout: PopLayoutDataV3, - targetModeKey: PopLayoutModeKey -): PopModeLayoutV3 { - const sourceKey: PopLayoutModeKey = "tablet_landscape"; - const sourceLayout = layout.layouts[sourceKey]; - const sourceRes = MODE_RESOLUTIONS[sourceKey]; - const targetRes = MODE_RESOLUTIONS[targetModeKey]; - - // 비율 계산 - const widthRatio = targetRes.width / sourceRes.width; - const heightRatio = targetRes.height / sourceRes.height; - - // 가로 → 세로 변환인지 확인 - const isOrientationChange = - sourceRes.width > sourceRes.height !== targetRes.width > targetRes.height; - - // 컴포넌트 위치 변환 - const convertedPositions: Record = {}; - let currentRow = 1; - - // 컴포넌트를 row, col 순으로 정렬 - const sortedComponentIds = Object.keys(sourceLayout.componentPositions).sort( - (a, b) => { - const posA = sourceLayout.componentPositions[a]; - const posB = sourceLayout.componentPositions[b]; - if (posA.row !== posB.row) return posA.row - posB.row; - return posA.col - posB.col; - } - ); - - for (const componentId of sortedComponentIds) { - const sourcePos = sourceLayout.componentPositions[componentId]; - - if (isOrientationChange) { - // 가로 → 세로: 세로 스택 방식 - const canvasColumns = layout.settings.canvasGrid.columns; - convertedPositions[componentId] = { - col: 1, - row: currentRow, - colSpan: canvasColumns, - rowSpan: Math.max(3, Math.round(sourcePos.rowSpan * 1.5)), - }; - currentRow += convertedPositions[componentId].rowSpan + 1; - } else { - // 같은 방향: 비율 변환 - convertedPositions[componentId] = { - col: Math.max(1, Math.round(sourcePos.col * widthRatio)), - row: Math.max(1, Math.round(sourcePos.row * heightRatio)), - colSpan: Math.max(1, Math.round(sourcePos.colSpan * widthRatio)), - rowSpan: Math.max(1, Math.round(sourcePos.rowSpan * heightRatio)), - }; - } - } - - return { - componentPositions: convertedPositions, - }; -} - -/** - * 현재 모드에 맞는 레이아웃 반환 (없으면 자동 변환) - */ -export function getEffectiveModeLayout( - layout: PopLayoutDataV3, - targetModeKey: PopLayoutModeKey -): { - modeLayout: PopModeLayoutV3; - isConverted: boolean; - sourceModeKey: PopLayoutModeKey; -} { - // 해당 모드에 레이아웃이 있으면 그대로 사용 - if (hasModeLayout(layout, targetModeKey)) { - return { - modeLayout: layout.layouts[targetModeKey], - isConverted: false, - sourceModeKey: targetModeKey, - }; - } - - // 없으면 태블릿 가로 모드를 기준으로 자동 변환 - return { - modeLayout: autoConvertLayout(layout, targetModeKey), - isConverted: true, - sourceModeKey: "tablet_landscape", - }; -} - -export default PopLayoutRenderer; diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx new file mode 100644 index 00000000..095138e2 --- /dev/null +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -0,0 +1,280 @@ +"use client"; + +import React, { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { + PopLayoutDataV5, + PopComponentDefinitionV5, + PopGridPosition, + GridMode, + GRID_BREAKPOINTS, + detectGridMode, + PopComponentType, +} from "../types/pop-layout"; + +// ======================================== +// Props +// ======================================== + +interface PopRendererProps { + /** v5 레이아웃 데이터 */ + layout: PopLayoutDataV5; + /** 현재 뷰포트 너비 */ + viewportWidth: number; + /** 현재 모드 (자동 감지 또는 수동 지정) */ + currentMode?: GridMode; + /** 디자인 모드 여부 */ + isDesignMode?: boolean; + /** 그리드 가이드 표시 여부 */ + showGridGuide?: boolean; + /** 선택된 컴포넌트 ID */ + selectedComponentId?: string | null; + /** 컴포넌트 클릭 */ + onComponentClick?: (componentId: string) => void; + /** 배경 클릭 */ + onBackgroundClick?: () => void; + /** 추가 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, + className, +}: PopRendererProps) { + const { gridConfig, components, overrides } = layout; + + // 현재 모드 (자동 감지 또는 지정) + const mode = currentMode || detectGridMode(viewportWidth); + const breakpoint = GRID_BREAKPOINTS[mode]; + + // CSS Grid 스타일 + const gridStyle = useMemo((): React.CSSProperties => ({ + display: "grid", + gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, + gridAutoRows: `${breakpoint.rowHeight}px`, + gap: `${breakpoint.gap}px`, + padding: `${breakpoint.padding}px`, + minHeight: "100%", + backgroundColor: "#ffffff", + position: "relative", + }), [breakpoint]); + + // 그리드 가이드 셀 생성 + const gridCells = useMemo(() => { + if (!isDesignMode || !showGridGuide) return []; + + const cells = []; + const rowCount = 20; // 충분한 행 수 + + for (let row = 1; row <= rowCount; row++) { + for (let col = 1; col <= breakpoint.columns; col++) { + cells.push({ + id: `cell-${col}-${row}`, + col, + row + }); + } + } + return cells; + }, [isDesignMode, showGridGuide, breakpoint.columns]); + + // visibility 체크 + const isVisible = (comp: PopComponentDefinitionV5): boolean => { + if (!comp.visibility) return true; + const modeVisibility = comp.visibility[mode]; + return modeVisibility !== false; + }; + + // 위치 변환 (12칸 기준 → 현재 모드 칸 수) + const convertPosition = (position: PopGridPosition): React.CSSProperties => { + const sourceColumns = 12; // 항상 12칸 기준으로 저장 + const targetColumns = breakpoint.columns; + + // 같은 칸 수면 그대로 사용 + if (sourceColumns === targetColumns) { + return { + gridColumn: `${position.col} / span ${position.colSpan}`, + gridRow: `${position.row} / span ${position.rowSpan}`, + }; + } + + // 비율 계산 (12칸 → 4칸, 6칸, 8칸) + 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 { + gridColumn: `${newCol} / span ${Math.max(1, newColSpan)}`, + gridRow: `${position.row} / span ${position.rowSpan}`, + }; + }; + + // 오버라이드 적용 + const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => { + const override = overrides?.[mode]?.positions?.[comp.id]; + if (override) { + return { ...comp.position, ...override }; + } + return comp.position; + }; + + // 오버라이드 숨김 체크 + const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => { + return overrides?.[mode]?.hidden?.includes(comp.id) ?? false; + }; + + return ( +
{ + if (e.target === e.currentTarget) { + onBackgroundClick?.(); + } + }} + > + {/* 그리드 가이드 셀 (실제 DOM) */} + {gridCells.map(cell => ( +
+ ))} + + {/* 컴포넌트 렌더링 (z-index로 위에 표시) */} + {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; + + return ( +
{ + e.stopPropagation(); + onComponentClick?.(comp.id); + }} + > + +
+ ); + })} +
+ ); +} + +// ======================================== +// 컴포넌트 내용 렌더링 +// ======================================== + +interface ComponentContentProps { + component: PopComponentDefinitionV5; + isDesignMode: boolean; + isSelected: boolean; +} + +function ComponentContent({ component, isDesignMode, isSelected }: ComponentContentProps) { + const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; + + // 디자인 모드: 플레이스홀더 표시 + if (isDesignMode) { + return ( +
+ {/* 헤더 */} +
+ + {component.label || typeLabel} + +
+ + {/* 내용 */} +
+ {typeLabel} +
+ + {/* 위치 정보 표시 */} +
+ {component.position.col},{component.position.row} + ({component.position.colSpan}×{component.position.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 index ee36be85..bf82b0d2 100644 --- a/frontend/components/pop/designer/renderers/index.ts +++ b/frontend/components/pop/designer/renderers/index.ts @@ -1,11 +1,4 @@ -// POP 레이아웃 렌더러 모듈 (v3) +// POP 레이아웃 렌더러 모듈 (v5 그리드 시스템) // 디자이너와 뷰어에서 동일한 렌더링을 보장하기 위한 공용 렌더러 -// 섹션 제거됨, 컴포넌트 직접 배치 -export { PopLayoutRenderer, default } from "./PopLayoutRenderer"; -export { - hasModeLayout, - hasBaseLayout, - autoConvertLayout, - getEffectiveModeLayout, -} from "./PopLayoutRenderer"; +export { default as PopRenderer } from "./PopRenderer"; diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index a1cab063..4bfcbbc3 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -1,127 +1,251 @@ // POP 디자이너 레이아웃 타입 정의 -// v4.0: 제약조건 기반, 단일 소스 → 자동 적응 (Flexbox) -// v3.0: 4모드 그리드 기반 (CSS Grid) +// v5.0: CSS Grid 기반 그리드 시스템 +// 2024-02 버전 통합: v1~v4 제거, v5 단일 버전 // ======================================== -// v4.0 제약조건 기반 레이아웃 +// 공통 타입 // ======================================== -// 핵심: 1번 설계 → 모든 화면 자동 적응 -// - 위치(col/row) 대신 규칙(fill/fixed/hug) 설정 -// - Flexbox로 렌더링, 자동 줄바꿈 /** - * v4 레이아웃 (반응형) - * - 단일 소스: 4모드 따로 설계 X - * - 규칙 기반: 컴포넌트가 어떻게 반응할지 정의 - * - 오버라이드: 특정 모드에서만 다르게 표현 (Phase 2) + * POP 컴포넌트 타입 */ -export interface PopLayoutDataV4 { - version: "pop-4.0"; - - // 루트 컨테이너 (스택) - root: PopContainerV4; - - // 컴포넌트 정의 (ID → 정의) - components: Record; - - // 데이터 흐름 (기존과 동일) - dataFlow: PopDataFlow; - - // 전역 설정 - settings: PopGlobalSettingsV4; - - // 메타데이터 - metadata?: PopLayoutMetadata; - - // 모드별 오버라이드 (Phase 2) - // - tablet_landscape가 기본이므로 오버라이드 없음 - // - 나머지 3개 모드만 오버라이드 가능 - overrides?: { - mobile_portrait?: PopModeOverride; - mobile_landscape?: PopModeOverride; - tablet_portrait?: PopModeOverride; +export type PopComponentType = "pop-sample"; // 테스트용 샘플 박스 + +/** + * 데이터 흐름 정의 + */ +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; }; } /** - * 모드별 오버라이드 - * - 특정 모드에서만 다르게 표현할 속성들 - * - 기본 모드(tablet_landscape) 값을 부분적으로 덮어씀 + * 스타일 프리셋 */ -export interface PopModeOverride { - // 컴포넌트별 오버라이드 - components?: Record>; - - // 컨테이너별 오버라이드 - containers?: Record>; +export interface PopStylePreset { + theme?: "default" | "primary" | "success" | "warning" | "danger"; + size?: "sm" | "md" | "lg"; + variant?: "solid" | "outline" | "ghost"; } /** - * v4 컨테이너 (스택) - * - Flexbox 기반 - * - 자식: 컴포넌트 ID 또는 중첩 컨테이너 + * 컴포넌트 설정 */ -export interface PopContainerV4 { - id: string; - type: "stack"; +export interface PopComponentConfig { + // 필드 설정 + inputType?: "text" | "number" | "date" | "select" | "barcode"; + placeholder?: string; + readonly?: boolean; - // 방향: 가로 또는 세로 - direction: "horizontal" | "vertical"; + // 버튼 설정 + action?: "submit" | "scan" | "navigate" | "custom"; + targetScreen?: string; - // 줄바꿈 허용 - wrap: boolean; + // 리스트 설정 + 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; +} + +/** + * 브레이크포인트 상수 + */ +export const GRID_BREAKPOINTS: Record = { + // 4~6인치 모바일 세로 + mobile_portrait: { + maxWidth: 599, + columns: 4, + rowHeight: 40, + gap: 8, + padding: 12, + label: "모바일 세로 (4칸)", + }, + + // 6~8인치 모바일 가로 / 작은 태블릿 + mobile_landscape: { + minWidth: 600, + maxWidth: 839, + columns: 6, + rowHeight: 44, + gap: 8, + padding: 16, + label: "모바일 가로 (6칸)", + }, + + // 8~10인치 태블릿 세로 + tablet_portrait: { + minWidth: 840, + maxWidth: 1023, + columns: 8, + rowHeight: 48, + gap: 12, + padding: 16, + label: "태블릿 세로 (8칸)", + }, + + // 10~14인치 태블릿 가로 (기본) + tablet_landscape: { + minWidth: 1024, + columns: 12, + rowHeight: 48, + gap: 16, + padding: 24, + label: "태블릿 가로 (12칸)", + }, +} as const; + +/** + * 기본 그리드 모드 + */ +export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape"; + +/** + * 뷰포트 너비로 모드 감지 + */ +export function detectGridMode(viewportWidth: number): GridMode { + if (viewportWidth < 600) return "mobile_portrait"; + if (viewportWidth < 840) 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; - - // 정렬 - alignItems: "start" | "center" | "end" | "stretch"; - justifyContent: "start" | "center" | "end" | "space-between"; + gap: number; // 기본 8px // 패딩 (px) - padding?: number; - - // 반응형 규칙 (선택) - responsive?: PopResponsiveRuleV4[]; - - // 자식 요소 - children: (string | PopContainerV4)[]; + padding: number; // 기본 16px } /** - * 반응형 규칙 - * - 특정 너비 이하에서 속성 변경 + * 그리드 위치 (열/행 좌표) */ -export interface PopResponsiveRuleV4 { - // 이 너비 이하에서 적용 (px) - breakpoint: number; - // 변경할 속성들 - direction?: "horizontal" | "vertical"; - gap?: number; - hidden?: boolean; // 컨테이너 숨김 +export interface PopGridPosition { + col: number; // 시작 열 (1부터, 최대 12) + row: number; // 시작 행 (1부터) + colSpan: number; // 차지할 열 수 (1~12) + rowSpan: number; // 차지할 행 수 (1~) } /** - * v4 컴포넌트 정의 - * - 크기 제약 기반 + * v5 컴포넌트 정의 */ -export interface PopComponentDefinitionV4 { +export interface PopComponentDefinitionV5 { id: string; type: PopComponentType; label?: string; - // 크기 제약 (핵심) - size: PopSizeConstraintV4; + // 위치 (열/행 좌표) - 기본 모드(태블릿 가로 12칸) 기준 + position: PopGridPosition; - // 개별 정렬 (컨테이너 설정 덮어쓰기) - alignSelf?: "start" | "center" | "end" | "stretch"; - - // 반응형 숨김 (픽셀 기반) - hideBelow?: number; // 이 너비 이하에서 숨김 - - // 모드별 표시/숨김 (명시적) + // 모드별 표시/숨김 visibility?: { - tablet_landscape?: boolean; // 기본값 true + tablet_landscape?: boolean; tablet_portrait?: boolean; mobile_landscape?: boolean; mobile_portrait?: boolean; @@ -134,1450 +258,109 @@ export interface PopComponentDefinitionV4 { } /** - * 크기 제약 - * - fixed: 고정 px - * - fill: 남은 공간 채움 - * - hug: 내용에 맞춤 + * v5 전역 설정 */ -export interface PopSizeConstraintV4 { - width: "fixed" | "fill" | "hug"; - height: "fixed" | "fill" | "hug"; - - // fixed일 때 값 - fixedWidth?: number; - fixedHeight?: number; - - // 최소/최대 - minWidth?: number; - maxWidth?: number; - minHeight?: number; -} - -/** - * v4 전역 설정 - */ -export interface PopGlobalSettingsV4 { +export interface PopGlobalSettingsV5 { // 터치 최소 크기 (px) touchTargetMin: number; // 기본 48 // 모드 - mode: "normal" | "industrial"; // industrial: 터치 60px + mode: "normal" | "industrial"; +} + +/** + * v5 모드별 오버라이드 + */ +export interface PopModeOverrideV5 { + // 컴포넌트별 위치 오버라이드 + positions?: Record>; - // 기본 간격 - defaultGap: number; // 기본 8 - - // 기본 패딩 - defaultPadding: number; // 기본 16 + // 컴포넌트별 숨김 + hidden?: string[]; } // ======================================== -// v4 생성 함수 +// v5 유틸리티 함수 // ======================================== -export const createEmptyPopLayoutV4 = (): PopLayoutDataV4 => ({ - version: "pop-4.0", - root: { - id: "root", - type: "stack", - direction: "horizontal", // 가로 방향 (왼쪽→오른쪽) - wrap: true, // 자동 줄바꿈 활성화 +/** + * 빈 v5 레이아웃 생성 + */ +export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ + version: "pop-5.0", + gridConfig: { + rowHeight: 48, gap: 8, - alignItems: "start", // 위쪽 정렬 - justifyContent: "start", // 왼쪽 정렬 padding: 16, - children: [], }, components: {}, dataFlow: { connections: [] }, settings: { touchTargetMin: 48, mode: "normal", - defaultGap: 8, - defaultPadding: 16, }, }); /** - * v4 컴포넌트 정의 생성 - * - 컴포넌트 타입별 기본 크기 설정 + * v5 레이아웃 여부 확인 */ -export const createComponentDefinitionV4 = ( - id: string, - type: PopComponentType, - label?: string -): PopComponentDefinitionV4 => { - // 타입별 기본 크기 설정 - const defaultSizes: Record = { - "pop-field": { - width: "fixed", - height: "fixed", - fixedWidth: 200, - fixedHeight: 48, - }, - "pop-button": { - width: "fixed", - height: "fixed", - fixedWidth: 120, - fixedHeight: 48, - }, - "pop-list": { - width: "fill", - height: "fixed", - fixedHeight: 200, - }, - "pop-indicator": { - width: "fixed", - height: "fixed", - fixedWidth: 120, - fixedHeight: 80, - }, - "pop-scanner": { - width: "fixed", - height: "fixed", - fixedWidth: 200, - fixedHeight: 48, - }, - "pop-numpad": { - width: "fixed", - height: "fixed", - fixedWidth: 200, - fixedHeight: 280, - }, - "pop-spacer": { - width: "fill", // Spacer는 기본적으로 남은 공간 채움 - height: "fixed", - fixedHeight: 48, - }, - "pop-break": { - width: "fill", // 100% 너비로 줄바꿈 강제 (flex-basis: 100%) - height: "fixed", - fixedHeight: 0, // 높이 0 (보이지 않음) - }, - }; - - return { - id, - type, - label, - size: defaultSizes[type] || { - width: "fixed", - height: "fixed", - fixedWidth: 100, - fixedHeight: 48, - }, - }; +export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { + return layout?.version === "pop-5.0"; }; /** - * v4 레이아웃에 컴포넌트 추가 + * 컴포넌트 타입별 기본 크기 (칸 단위) */ -export const addComponentToV4Layout = ( - layout: PopLayoutDataV4, +export const DEFAULT_COMPONENT_GRID_SIZE: Record = { + "pop-sample": { colSpan: 2, 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, - containerId: string = "root", + position: PopGridPosition, label?: string -): PopLayoutDataV4 => { +): PopLayoutDataV5 => { const newLayout = { ...layout }; // 컴포넌트 정의 추가 newLayout.components = { ...newLayout.components, - [componentId]: createComponentDefinitionV4(componentId, type, label), + [componentId]: createComponentDefinitionV5(componentId, type, position, label), }; - // 컨테이너에 컴포넌트 ID 추가 - if (containerId === "root") { - newLayout.root = { - ...newLayout.root, - children: [...newLayout.root.children, componentId], - }; - } else { - // 중첩 컨테이너의 경우 재귀적으로 찾아서 추가 - newLayout.root = addChildToContainer(newLayout.root, containerId, componentId); - } - return newLayout; }; -/** - * 컨테이너에 자식 추가 (재귀) - */ -function addChildToContainer( - container: PopContainerV4, - targetId: string, - childId: string -): PopContainerV4 { - if (container.id === targetId) { - return { - ...container, - children: [...container.children, childId], - }; - } - - return { - ...container, - children: container.children.map((child) => { - if (typeof child === "object") { - return addChildToContainer(child, targetId, childId); - } - return child; - }), - }; -} - -/** - * v4 레이아웃에서 컴포넌트 삭제 - * - components에서 삭제 - * - root.children에서 제거 - * - 모든 overrides에서도 제거 (중요!) - */ -export const removeComponentFromV4Layout = ( - layout: PopLayoutDataV4, - componentId: string -): PopLayoutDataV4 => { - // 1. 컴포넌트 정의 삭제 - const { [componentId]: _, ...remainingComponents } = layout.components; - - // 2. root.children에서 제거 - const newRoot = removeChildFromContainer(layout.root, componentId); - - // 3. 모든 오버라이드에서 제거 - const newOverrides = cleanupOverridesAfterDelete(layout.overrides, componentId); - - return { - ...layout, - root: newRoot, - components: remainingComponents, - overrides: newOverrides, - }; -}; - -/** - * 오버라이드 정리 (컴포넌트 삭제 시) - * - containers.root.children에서 제거 - * - components에서 제거 - */ -function cleanupOverridesAfterDelete( - overrides: PopLayoutDataV4["overrides"], - componentId: string -): PopLayoutDataV4["overrides"] { - if (!overrides) return undefined; - - const newOverrides = { ...overrides }; - - for (const mode of Object.keys(newOverrides) as Array) { - const override = newOverrides[mode]; - if (!override) continue; - - const updated = { ...override }; - - // containers.root.children에서 제거 - if (updated.containers?.root?.children) { - updated.containers = { - ...updated.containers, - root: { - ...updated.containers.root, - children: updated.containers.root.children.filter(id => id !== componentId), - }, - }; - } - - // components에서 제거 - if (updated.components?.[componentId]) { - const { [componentId]: _, ...rest } = updated.components; - updated.components = Object.keys(rest).length > 0 ? rest : undefined; - } - - // 빈 오버라이드 정리 - if (!updated.containers && !updated.components) { - delete newOverrides[mode]; - } else { - newOverrides[mode] = updated; - } - } - - // 모든 오버라이드가 비었으면 undefined 반환 - return Object.keys(newOverrides).length > 0 ? newOverrides : undefined; -} - -/** - * 컨테이너에서 자식 제거 (재귀) - */ -function removeChildFromContainer( - container: PopContainerV4, - childId: string -): PopContainerV4 { - return { - ...container, - children: container.children - .filter((child) => child !== childId) - .map((child) => { - if (typeof child === "object") { - return removeChildFromContainer(child, childId); - } - return child; - }), - }; -} - -/** - * v4 컴포넌트 업데이트 - */ -export const updateComponentInV4Layout = ( - layout: PopLayoutDataV4, - componentId: string, - updates: Partial -): PopLayoutDataV4 => { - const existingComponent = layout.components[componentId]; - if (!existingComponent) return layout; - - return { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...existingComponent, - ...updates, - // size는 깊은 병합 - size: updates.size - ? { ...existingComponent.size, ...updates.size } - : existingComponent.size, - }, - }, - }; -}; - -/** - * v4 컨테이너 찾기 (재귀) - */ -export const findContainerV4 = ( - root: PopContainerV4, - containerId: string -): PopContainerV4 | null => { - if (root.id === containerId) return root; - - for (const child of root.children) { - if (typeof child === "object") { - const found = findContainerV4(child, containerId); - if (found) return found; - } - } - - return null; -}; - -/** - * v4 컨테이너 업데이트 (재귀) - */ -export const updateContainerV4 = ( - container: PopContainerV4, - containerId: string, - updates: Partial -): PopContainerV4 => { - if (container.id === containerId) { - return { ...container, ...updates }; - } - - return { - ...container, - children: container.children.map((child) => { - if (typeof child === "object") { - return updateContainerV4(child, containerId, updates); - } - return child; - }), - }; -}; - // ======================================== -// 레이아웃 모드 키 (v3용) +// 레거시 타입 별칭 (하위 호환 - 추후 제거) // ======================================== -export type PopLayoutModeKey = - | "tablet_landscape" // 태블릿 가로 (1024x768) - | "tablet_portrait" // 태블릿 세로 (768x1024) - | "mobile_landscape" // 모바일 가로 (667x375) - | "mobile_portrait"; // 모바일 세로 (375x667) +// 기존 코드에서 import 오류 방지용 -/** - * 모드별 해상도 상수 - */ -export const MODE_RESOLUTIONS: Record = { - tablet_landscape: { width: 1024, height: 768 }, - tablet_portrait: { width: 768, height: 1024 }, - mobile_landscape: { width: 667, height: 375 }, - mobile_portrait: { width: 375, height: 667 }, -}; +/** @deprecated v5에서는 PopLayoutDataV5 사용 */ +export type PopLayoutData = PopLayoutDataV5; -// ======================================== -// v3.0 레이아웃 (섹션 제거, 컴포넌트 직접 배치) -// ======================================== +/** @deprecated v5에서는 PopComponentDefinitionV5 사용 */ +export type PopComponentDefinition = PopComponentDefinitionV5; -/** - * POP 레이아웃 v3.0 - * - 섹션 제거: 컴포넌트가 캔버스에 직접 배치 - * - 4개 모드별 컴포넌트 위치 분리 - * - 컴포넌트 정의는 공유 - * - 리스트 컴포넌트가 카드 템플릿 역할 담당 - */ -export interface PopLayoutDataV3 { - version: "pop-3.0"; - - // 4개 모드별 레이아웃 (컴포넌트 위치만) - layouts: { - tablet_landscape: PopModeLayoutV3; - tablet_portrait: PopModeLayoutV3; - mobile_landscape: PopModeLayoutV3; - mobile_portrait: PopModeLayoutV3; - }; - - // 공유 컴포넌트 정의 (ID → 정의) - components: Record; - - // 데이터 흐름 - dataFlow: PopDataFlow; - - // 전역 설정 - settings: PopGlobalSettings; - - // 메타데이터 - metadata?: PopLayoutMetadata; -} - -/** - * 모드별 레이아웃 v3 (컴포넌트 위치만) - */ -export interface PopModeLayoutV3 { - // 컴포넌트별 위치 (컴포넌트 ID → 위치) - 캔버스 그리드 기준 - componentPositions: Record; -} - -// ======================================== -// v2.0 레이아웃 (하위 호환용 - deprecated) -// ======================================== - -/** - * @deprecated v3.0 사용 권장 - */ -export interface PopLayoutDataV2 { - version: "pop-2.0"; - layouts: { - tablet_landscape: PopModeLayout; - tablet_portrait: PopModeLayout; - mobile_landscape: PopModeLayout; - mobile_portrait: PopModeLayout; - }; - sections: Record; - components: Record; - dataFlow: PopDataFlow; - settings: PopGlobalSettings; - metadata?: PopLayoutMetadata; -} - -/** - * @deprecated v3.0에서는 PopModeLayoutV3 사용 - */ -export interface PopModeLayout { - sectionPositions: Record; - componentPositions: Record; -} - -/** - * @deprecated v3.0에서 섹션 제거 - */ -export interface PopSectionDefinition { - id: string; - label?: string; - componentIds: string[]; - innerGrid: PopInnerGrid; - dataSource?: PopDataSource; - connections?: PopConnection[]; - style?: PopSectionStyle; -} - -// ======================================== -// v1.0 레이아웃 (하위 호환용 - deprecated) -// ======================================== - -/** - * @deprecated v3.0 사용 권장 - */ -export interface PopLayoutDataV1 { - version: "pop-1.0"; - layoutMode: "grid"; - deviceTarget: PopDeviceTarget; - canvasGrid: PopCanvasGrid; - sections: PopSectionDataV1[]; - metadata?: PopLayoutMetadata; -} - -// ======================================== -// 통합 타입 -// ======================================== - -export type PopLayoutData = PopLayoutDataV1 | PopLayoutDataV2 | PopLayoutDataV3 | PopLayoutDataV4; - -export function isV4Layout(data: PopLayoutData): data is PopLayoutDataV4 { - return data.version === "pop-4.0"; -} - -export function isV3Layout(data: PopLayoutData): data is PopLayoutDataV3 { - return data.version === "pop-3.0"; -} - -export function isV2Layout(data: PopLayoutData): data is PopLayoutDataV2 { - return data.version === "pop-2.0"; -} - -export function isV1Layout(data: PopLayoutData): data is PopLayoutDataV1 { - return data.version === "pop-1.0"; -} - -// ======================================== -// 공통 타입 -// ======================================== - -/** - * 그리드 위치/크기 - * - col/row: 1-based 시작 위치 - * - colSpan/rowSpan: 차지하는 칸 수 - */ -export interface GridPosition { - col: number; // 시작 열 (1-based) - row: number; // 시작 행 (1-based) - colSpan: number; // 열 개수 - rowSpan: number; // 행 개수 -} - -/** - * 캔버스 그리드 설정 - */ -export interface PopCanvasGrid { - columns: number; // 열 개수 (기본 24) - rows: number; // 행 개수 (기본 24) - 비율 기반 - gap: number; // 그리드 간격 (px) -} - -/** - * 대상 디바이스 - */ -export type PopDeviceTarget = "mobile" | "tablet" | "both"; - -/** - * 레이아웃 메타데이터 - */ -export interface PopLayoutMetadata { - lastModified?: string; - modifiedBy?: string; - description?: string; -} - -// ======================================== -// 컴포넌트 정의 -// ======================================== - -/** - * 공유 컴포넌트 정의 (위치 제외) - */ -export interface PopComponentDefinition { - id: string; - type: PopComponentType; - label?: string; - // 데이터 바인딩 - dataBinding?: PopDataBinding; - // 스타일 프리셋 - style?: PopStylePreset; - // 컴포넌트별 설정 - config?: PopComponentConfig; -} - -/** - * POP 컴포넌트 타입 - * - 7개 핵심 컴포넌트 (Spacer 포함) - */ -export type PopComponentType = - | "pop-field" // 데이터 입력/표시 - | "pop-button" // 액션 실행 - | "pop-list" // 데이터 목록 (카드 템플릿 포함) - | "pop-indicator" // 상태/수치 표시 - | "pop-scanner" // 바코드/QR 입력 - | "pop-numpad" // 숫자 입력 특화 - | "pop-spacer" // 빈 공간 (레이아웃 정렬용) - | "pop-break"; // 줄바꿈 (강제 줄바꿈, flex-basis: 100%) - -// ======================================== -// 데이터 흐름 -// ======================================== - -export interface PopDataFlow { - // 컴포넌트 간 연결 - connections: PopConnection[]; - - // 화면 로드 시 파라미터 수신 - onScreenLoad?: { - paramMapping: Record; // URL 파라미터 → 컴포넌트 ID - }; - - // 다음 화면으로 데이터 전달 - navigationOutput?: { - screenId: number; - paramMapping: Record; // 컴포넌트 ID → URL 파라미터명 - }; -} - -export interface PopConnection { - from: string; // 소스 컴포넌트 ID - to: string; // 타겟 컴포넌트 ID - trigger: PopTrigger; // 트리거 이벤트 - action: PopAction; // 수행할 액션 -} - -export type PopTrigger = - | "onChange" // 값 변경 시 - | "onSubmit" // 제출 시 - | "onClick" // 클릭 시 - | "onScan" // 스캔 완료 시 - | "onSelect"; // 선택 시 - -export type PopAction = - | { type: "setValue"; targetField: string } - | { type: "filter"; filterField: string } - | { type: "refresh" } - | { type: "navigate"; screenId: number } - | { type: "api"; endpoint: string; method: string }; - -// ======================================== -// 전역 설정 -// ======================================== - -export interface PopGlobalSettings { - touchTargetMin: number; // 기본 48px - mode: "normal" | "industrial"; - canvasGrid: PopCanvasGrid; -} - -// ======================================== -// 데이터 소스 -// ======================================== - -export interface PopDataSource { - type: "api" | "static" | "parent"; - endpoint?: string; - params?: Record; - staticData?: any[]; -} - -// ======================================== -// 스타일 -// ======================================== - -export interface PopStylePreset { - variant: PopVariant; - padding: PopPaddingPreset; -} - -export type PopVariant = "default" | "primary" | "success" | "warning" | "danger"; -export type PopPaddingPreset = "none" | "small" | "medium" | "large"; -export type PopGapPreset = "none" | "small" | "medium" | "large"; - -export const POP_PADDING_MAP: Record = { - none: "0", - small: "8px", - medium: "16px", - large: "24px", -}; - -// @deprecated - v3에서 섹션 제거 -export interface PopSectionStyle { - showBorder?: boolean; - backgroundColor?: string; - padding?: PopPaddingPreset; -} - -// @deprecated - v3에서 섹션 제거 -export interface PopInnerGrid { - columns: number; - rows: number; - gap: number; -} - -// ======================================== -// 데이터 바인딩 -// ======================================== - -export interface PopDataBinding { - tableName: string; - columnName: string; - displayField?: string; - filter?: Record; -} - -// ======================================== -// 컴포넌트별 설정 -// ======================================== - -export type PopComponentConfig = - | PopFieldConfig - | PopButtonConfig - | PopListConfig - | PopIndicatorConfig - | PopScannerConfig - | PopNumpadConfig; - -export interface PopFieldConfig { - fieldType: PopFieldType; - placeholder?: string; - required?: boolean; - readonly?: boolean; - defaultValue?: any; - options?: PopFieldOption[]; - validation?: PopFieldValidation; -} - -export type PopFieldType = - | "text" - | "number" - | "date" - | "dropdown" - | "barcode" - | "numpad" - | "readonly"; - -export interface PopFieldOption { - value: string | number; - label: string; -} - -export interface PopFieldValidation { - min?: number; - max?: number; - pattern?: string; - message?: string; -} - -export interface PopButtonConfig { - buttonType: PopButtonType; - icon?: string; - action?: PopButtonAction; - confirmMessage?: string; -} - -export type PopButtonType = "submit" | "action" | "navigation" | "cancel"; - -export interface PopButtonAction { - type: "api" | "navigate" | "save" | "delete" | "custom"; - target?: string; - params?: Record; -} - -/** - * 리스트 설정 (카드 템플릿 포함) - */ -export interface PopListConfig { - listType: PopListType; - itemsPerPage?: number; - selectable?: boolean; - multiSelect?: boolean; - displayColumns?: string[]; - // 카드 템플릿 설정 (v3 신규) - cardTemplate?: PopCardTemplate; -} - -export type PopListType = "card" | "simple" | "table"; - -/** - * 카드 템플릿 (리스트 컴포넌트 내부) - * - 섹션의 역할을 대체 - * - 자유 배치 가능 - */ -export interface PopCardTemplate { - // 카드 내부 그리드 설정 - grid: { - columns: number; - rows: number; - gap: number; - }; - // 카드 내 요소들 - elements: PopCardElement[]; - // 카드 스타일 - style?: { - backgroundColor?: string; - borderRadius?: number; - shadow?: boolean; - }; -} - -/** - * 카드 내 요소 (이미지, 텍스트, 숫자, 버튼 등) - */ -export interface PopCardElement { - id: string; - type: "image" | "text" | "number" | "button" | "badge"; - position: GridPosition; - // 데이터 바인딩 (어떤 컬럼과 연결) - dataField?: string; - // 스타일 - style?: { - fontSize?: string; - fontWeight?: string; - color?: string; - textAlign?: "left" | "center" | "right"; - }; - // 액션 (버튼용) - action?: PopButtonAction; -} - -export interface PopIndicatorConfig { - indicatorType: PopIndicatorType; - unit?: string; - thresholds?: PopThreshold[]; - format?: string; -} - -export type PopIndicatorType = "kpi" | "gauge" | "traffic" | "progress"; - -export interface PopThreshold { - value: number; - color: string; - label?: string; -} - -export interface PopScannerConfig { - scannerType: PopScannerType; - targetField?: string; - autoSubmit?: boolean; - soundEnabled?: boolean; -} - -export type PopScannerType = "camera" | "external" | "both"; - -export interface PopNumpadConfig { - targetField?: string; - showDecimal?: boolean; - maxDigits?: number; - autoSubmit?: boolean; -} - -// ======================================== -// 기본값 -// ======================================== - -export const DEFAULT_CANVAS_GRID: PopCanvasGrid = { - columns: 24, - rows: 24, - gap: 4, -}; - -// @deprecated -export const DEFAULT_INNER_GRID: PopInnerGrid = { - columns: 3, - rows: 3, - gap: 4, -}; - -// ======================================== -// v3 생성 함수 -// ======================================== - -/** - * 빈 v3 레이아웃 생성 - */ -export const createEmptyPopLayoutV3 = (): PopLayoutDataV3 => ({ - version: "pop-3.0", - layouts: { - tablet_landscape: { componentPositions: {} }, - tablet_portrait: { componentPositions: {} }, - mobile_landscape: { componentPositions: {} }, - mobile_portrait: { componentPositions: {} }, - }, - components: {}, - dataFlow: { - connections: [], - }, - settings: { - touchTargetMin: 48, - mode: "normal", - canvasGrid: { ...DEFAULT_CANVAS_GRID }, - }, -}); - -/** - * v3 컴포넌트 정의 생성 - */ -export const createComponentDefinition = ( - id: string, - type: PopComponentType, - label?: string -): PopComponentDefinition => ({ - id, - type, - label, -}); - -// ======================================== -// v3 헬퍼 함수 -// ======================================== - -/** - * v3 레이아웃에 컴포넌트 추가 (4모드 동기화) - */ -export const addComponentToV3Layout = ( - layout: PopLayoutDataV3, - componentId: string, - type: PopComponentType, - position: GridPosition, - label?: string -): PopLayoutDataV3 => { - const newLayout = { ...layout }; - - // 컴포넌트 정의 추가 - newLayout.components = { - ...newLayout.components, - [componentId]: createComponentDefinition(componentId, type, label), - }; - - // 4모드 모두에 위치 추가 - const modeKeys: PopLayoutModeKey[] = [ - "tablet_landscape", "tablet_portrait", - "mobile_landscape", "mobile_portrait" - ]; - - const newLayouts = { ...newLayout.layouts }; - for (const mode of modeKeys) { - newLayouts[mode] = { - ...newLayouts[mode], - componentPositions: { - ...newLayouts[mode].componentPositions, - [componentId]: { ...position }, - }, - }; - } - newLayout.layouts = newLayouts; - - return newLayout; -}; - -/** - * v3 레이아웃에서 컴포넌트 삭제 (4모드 동기화) - */ -export const removeComponentFromV3Layout = ( - layout: PopLayoutDataV3, - componentId: string -): PopLayoutDataV3 => { - const newLayout = { ...layout }; - - // 컴포넌트 정의 삭제 - const { [componentId]: _, ...remainingComponents } = newLayout.components; - newLayout.components = remainingComponents; - - // 4모드 모두에서 위치 삭제 - const modeKeys: PopLayoutModeKey[] = [ - "tablet_landscape", "tablet_portrait", - "mobile_landscape", "mobile_portrait" - ]; - - const newLayouts = { ...newLayout.layouts }; - for (const mode of modeKeys) { - const { [componentId]: __, ...remainingPos } = newLayouts[mode].componentPositions; - newLayouts[mode] = { - componentPositions: remainingPos, - }; - } - newLayout.layouts = newLayouts; - - return newLayout; -}; - -/** - * v3 레이아웃에서 특정 모드의 컴포넌트 위치 업데이트 - */ -export const updateComponentPositionInModeV3 = ( - layout: PopLayoutDataV3, - modeKey: PopLayoutModeKey, - componentId: string, - position: GridPosition -): PopLayoutDataV3 => { - return { - ...layout, - layouts: { - ...layout.layouts, - [modeKey]: { - ...layout.layouts[modeKey], - componentPositions: { - ...layout.layouts[modeKey].componentPositions, - [componentId]: position, - }, - }, - }, - }; -}; - -// ======================================== -// 마이그레이션 함수 -// ======================================== - -/** - * v2 → v3 마이그레이션 - * 섹션을 제거하고 컴포넌트만 유지 - */ -export const migrateV2ToV3 = (v2: PopLayoutDataV2): PopLayoutDataV3 => { - const v3 = createEmptyPopLayoutV3(); - - // 설정 복사 - v3.settings = { ...v2.settings }; - v3.metadata = v2.metadata ? { ...v2.metadata } : undefined; - - // 컴포넌트 정의 복사 - v3.components = { ...v2.components }; - - // 4모드별 컴포넌트 위치 복사 (섹션 위치는 무시) - const modeKeys: PopLayoutModeKey[] = [ - "tablet_landscape", "tablet_portrait", - "mobile_landscape", "mobile_portrait" - ]; - - for (const mode of modeKeys) { - v3.layouts[mode] = { - componentPositions: { ...v2.layouts[mode].componentPositions }, - }; - } - - // 데이터 흐름 마이그레이션 (섹션 연결 → 컴포넌트 연결) - v3.dataFlow = { - connections: v2.dataFlow.sectionConnections || [], - onScreenLoad: v2.dataFlow.onScreenLoad, - navigationOutput: v2.dataFlow.navigationOutput, - }; - - return v3; -}; - -/** - * v1 → v3 마이그레이션 - */ -export const migrateV1ToV3 = (v1: PopLayoutDataV1): PopLayoutDataV3 => { - const v3 = createEmptyPopLayoutV3(); - - // 캔버스 그리드 설정 복사 - v3.settings.canvasGrid = { ...v1.canvasGrid }; - v3.metadata = v1.metadata ? { ...v1.metadata } : undefined; - - // 섹션 내 컴포넌트들을 캔버스 직접 배치로 변환 - for (const section of v1.sections) { - for (const comp of section.components) { - // 컴포넌트 정의 생성 - v3.components[comp.id] = { - id: comp.id, - type: comp.type, - label: comp.label, - dataBinding: comp.dataBinding, - style: comp.style, - config: comp.config, - }; - - // 섹션 위치 + 컴포넌트 상대 위치 → 절대 위치로 변환 - const absCol = section.grid.col + comp.grid.col - 1; - const absRow = section.grid.row + comp.grid.row - 1; - - const position: GridPosition = { - col: absCol, - row: absRow, - colSpan: comp.grid.colSpan, - rowSpan: comp.grid.rowSpan, - }; - - // 4모드 동일 위치 - v3.layouts.tablet_landscape.componentPositions[comp.id] = { ...position }; - v3.layouts.tablet_portrait.componentPositions[comp.id] = { ...position }; - v3.layouts.mobile_landscape.componentPositions[comp.id] = { ...position }; - v3.layouts.mobile_portrait.componentPositions[comp.id] = { ...position }; - } - } - - return v3; -}; - -/** - * 레이아웃 데이터를 v3로 보장 (필요시 마이그레이션) - */ -export const ensureV3Layout = (data: PopLayoutData): PopLayoutDataV3 => { - let result: PopLayoutDataV3; - - if (isV3Layout(data)) { - result = data; - } else if (isV2Layout(data)) { - console.log("v2 → v3 마이그레이션 수행"); - result = migrateV2ToV3(data); - } else if (isV1Layout(data)) { - console.log("v1 → v3 마이그레이션 수행"); - result = migrateV1ToV3(data); - } else { - console.warn("알 수 없는 레이아웃 버전, 빈 v3 레이아웃 생성"); - result = createEmptyPopLayoutV3(); - } - - // canvasGrid 정규화 - const { rowHeight, ...restGrid } = result.settings.canvasGrid as any; - - if (rowHeight !== undefined) { - console.warn("구버전 rowHeight 필드 제거"); - } - - if (!restGrid.rows) { - console.warn("canvasGrid.rows 없음, 기본값 24로 설정"); - } - - result.settings.canvasGrid = { - columns: restGrid.columns || DEFAULT_CANVAS_GRID.columns, - rows: restGrid.rows || DEFAULT_CANVAS_GRID.rows, - gap: restGrid.gap || DEFAULT_CANVAS_GRID.gap, - }; - - return result; -}; - -// ======================================== -// 하위 호환 (deprecated) -// ======================================== - -/** @deprecated v3 사용 권장 */ -export const createEmptyPopLayoutV2 = (): PopLayoutDataV2 => ({ - version: "pop-2.0", - layouts: { - tablet_landscape: { sectionPositions: {}, componentPositions: {} }, - tablet_portrait: { sectionPositions: {}, componentPositions: {} }, - mobile_landscape: { sectionPositions: {}, componentPositions: {} }, - mobile_portrait: { sectionPositions: {}, componentPositions: {} }, - }, - sections: {}, - components: {}, - dataFlow: { - sectionConnections: [], - }, - settings: { - touchTargetMin: 48, - mode: "normal", - canvasGrid: { ...DEFAULT_CANVAS_GRID }, - }, -}); - -/** @deprecated */ -export const createEmptyPopLayoutV1 = (): PopLayoutDataV1 => ({ - version: "pop-1.0", - layoutMode: "grid", - deviceTarget: "both", - canvasGrid: { ...DEFAULT_CANVAS_GRID }, - sections: [], -}); - -/** @deprecated */ -export const createEmptyPopLayout = createEmptyPopLayoutV1; - -/** @deprecated */ -export interface PopSectionDataV1 { - id: string; - label?: string; - grid: GridPosition; - mobileGrid?: GridPosition; - innerGrid: PopInnerGrid; - components: PopComponentDataV1[]; - style?: PopSectionStyle; -} - -/** @deprecated */ -export interface PopComponentDataV1 { - id: string; - type: PopComponentType; - grid: GridPosition; - mobileGrid?: GridPosition; - label?: string; - dataBinding?: PopDataBinding; - style?: PopStylePreset; - config?: PopComponentConfig; -} - -/** @deprecated */ -export type PopSectionData = PopSectionDataV1; -/** @deprecated */ -export type PopComponentData = PopComponentDataV1; - -/** @deprecated */ -export const createPopSection = ( - id: string, - grid: GridPosition = { col: 1, row: 1, colSpan: 3, rowSpan: 4 } -): PopSectionDataV1 => ({ - id, - grid, - innerGrid: { ...DEFAULT_INNER_GRID }, - components: [], - style: { showBorder: true, padding: "small" }, -}); - -/** @deprecated */ -export const createPopComponent = ( - id: string, - type: PopComponentType, - grid: GridPosition = { col: 1, row: 1, colSpan: 1, rowSpan: 1 }, - label?: string -): PopComponentDataV1 => ({ - id, - type, - grid, - label, -}); - -/** @deprecated */ -export const createSectionDefinition = ( - id: string, - label?: string -): PopSectionDefinition => ({ - id, - label, - componentIds: [], - innerGrid: { ...DEFAULT_INNER_GRID }, - style: { showBorder: true, padding: "small" }, -}); - -/** @deprecated - v2 헬퍼 */ -export const addSectionToV2Layout = ( - layout: PopLayoutDataV2, - sectionId: string, - position: GridPosition, - label?: string -): PopLayoutDataV2 => { - const newLayout = { ...layout }; - newLayout.sections = { - ...newLayout.sections, - [sectionId]: createSectionDefinition(sectionId, label), - }; - const modeKeys: PopLayoutModeKey[] = [ - "tablet_landscape", "tablet_portrait", - "mobile_landscape", "mobile_portrait" - ]; - const newLayouts = { ...newLayout.layouts }; - for (const mode of modeKeys) { - newLayouts[mode] = { - ...newLayouts[mode], - sectionPositions: { - ...newLayouts[mode].sectionPositions, - [sectionId]: { ...position }, - }, - }; - } - newLayout.layouts = newLayouts; - return newLayout; -}; - -/** @deprecated - v2 헬퍼 */ -export const removeSectionFromV2Layout = ( - layout: PopLayoutDataV2, - sectionId: string -): PopLayoutDataV2 => { - const newLayout = { ...layout }; - const section = newLayout.sections[sectionId]; - const componentIds = section?.componentIds || []; - const { [sectionId]: _, ...remainingSections } = newLayout.sections; - newLayout.sections = remainingSections; - let remainingComponents = { ...newLayout.components }; - for (const compId of componentIds) { - const { [compId]: __, ...rest } = remainingComponents; - remainingComponents = rest; - } - newLayout.components = remainingComponents; - const modeKeys: PopLayoutModeKey[] = [ - "tablet_landscape", "tablet_portrait", - "mobile_landscape", "mobile_portrait" - ]; - const newLayouts = { ...newLayout.layouts }; - for (const mode of modeKeys) { - const { [sectionId]: ___, ...remainingSecPos } = newLayouts[mode].sectionPositions; - let remainingCompPos = { ...newLayouts[mode].componentPositions }; - for (const compId of componentIds) { - const { [compId]: ____, ...rest } = remainingCompPos; - remainingCompPos = rest; - } - newLayouts[mode] = { - sectionPositions: remainingSecPos, - componentPositions: remainingCompPos, - }; - } - newLayout.layouts = newLayouts; - return newLayout; -}; - -/** @deprecated */ -export const addComponentToV2Layout = ( - layout: PopLayoutDataV2, - sectionId: string, - componentId: string, - type: PopComponentType, - position: GridPosition, - label?: string -): PopLayoutDataV2 => { - const newLayout = { ...layout }; - newLayout.components = { - ...newLayout.components, - [componentId]: { id: componentId, type, label }, - }; - const section = newLayout.sections[sectionId]; - if (section) { - newLayout.sections = { - ...newLayout.sections, - [sectionId]: { - ...section, - componentIds: [...section.componentIds, componentId], - }, - }; - } - const modeKeys: PopLayoutModeKey[] = [ - "tablet_landscape", "tablet_portrait", - "mobile_landscape", "mobile_portrait" - ]; - const newLayouts = { ...newLayout.layouts }; - for (const mode of modeKeys) { - newLayouts[mode] = { - ...newLayouts[mode], - componentPositions: { - ...newLayouts[mode].componentPositions, - [componentId]: { ...position }, - }, - }; - } - newLayout.layouts = newLayouts; - return newLayout; -}; - -/** @deprecated */ -export const removeComponentFromV2Layout = ( - layout: PopLayoutDataV2, - sectionId: string, - componentId: string -): PopLayoutDataV2 => { - const newLayout = { ...layout }; - const { [componentId]: _, ...remainingComponents } = newLayout.components; - newLayout.components = remainingComponents; - const section = newLayout.sections[sectionId]; - if (section) { - newLayout.sections = { - ...newLayout.sections, - [sectionId]: { - ...section, - componentIds: section.componentIds.filter(id => id !== componentId), - }, - }; - } - const modeKeys: PopLayoutModeKey[] = [ - "tablet_landscape", "tablet_portrait", - "mobile_landscape", "mobile_portrait" - ]; - const newLayouts = { ...newLayout.layouts }; - for (const mode of modeKeys) { - const { [componentId]: __, ...remainingCompPos } = newLayouts[mode].componentPositions; - newLayouts[mode] = { - ...newLayouts[mode], - componentPositions: remainingCompPos, - }; - } - newLayout.layouts = newLayouts; - return newLayout; -}; - -/** @deprecated */ -export const updateSectionPositionInMode = ( - layout: PopLayoutDataV2, - modeKey: PopLayoutModeKey, - sectionId: string, - position: GridPosition -): PopLayoutDataV2 => ({ - ...layout, - layouts: { - ...layout.layouts, - [modeKey]: { - ...layout.layouts[modeKey], - sectionPositions: { - ...layout.layouts[modeKey].sectionPositions, - [sectionId]: position, - }, - }, - }, -}); - -/** @deprecated */ -export const updateComponentPositionInMode = ( - layout: PopLayoutDataV2, - modeKey: PopLayoutModeKey, - componentId: string, - position: GridPosition -): PopLayoutDataV2 => ({ - ...layout, - layouts: { - ...layout.layouts, - [modeKey]: { - ...layout.layouts[modeKey], - componentPositions: { - ...layout.layouts[modeKey].componentPositions, - [componentId]: position, - }, - }, - }, -}); - -/** @deprecated */ -export const migrateV1ToV2 = (v1: PopLayoutDataV1): PopLayoutDataV2 => { - const v2 = createEmptyPopLayoutV2(); - v2.settings.canvasGrid = { ...v1.canvasGrid }; - if (v1.metadata) v2.metadata = { ...v1.metadata }; - for (const section of v1.sections) { - v2.sections[section.id] = { - id: section.id, - label: section.label, - componentIds: section.components.map(c => c.id), - innerGrid: { ...section.innerGrid }, - style: section.style, - }; - const sectionPos: GridPosition = { ...section.grid }; - v2.layouts.tablet_landscape.sectionPositions[section.id] = { ...sectionPos }; - v2.layouts.tablet_portrait.sectionPositions[section.id] = { ...sectionPos }; - v2.layouts.mobile_landscape.sectionPositions[section.id] = { ...sectionPos }; - v2.layouts.mobile_portrait.sectionPositions[section.id] = { ...sectionPos }; - for (const comp of section.components) { - v2.components[comp.id] = { - id: comp.id, - type: comp.type, - label: comp.label, - dataBinding: comp.dataBinding, - style: comp.style, - config: comp.config, - }; - const compPos: GridPosition = { ...comp.grid }; - v2.layouts.tablet_landscape.componentPositions[comp.id] = { ...compPos }; - v2.layouts.tablet_portrait.componentPositions[comp.id] = { ...compPos }; - v2.layouts.mobile_landscape.componentPositions[comp.id] = { ...compPos }; - v2.layouts.mobile_portrait.componentPositions[comp.id] = { ...compPos }; - } - } - return v2; -}; - -/** @deprecated - ensureV3Layout 사용 권장 */ -export const ensureV2Layout = (data: PopLayoutData): PopLayoutDataV2 => { - if (isV2Layout(data)) { - const { rowHeight, ...restGrid } = data.settings.canvasGrid as any; - data.settings.canvasGrid = { - columns: restGrid.columns || DEFAULT_CANVAS_GRID.columns, - rows: restGrid.rows || DEFAULT_CANVAS_GRID.rows, - gap: restGrid.gap || DEFAULT_CANVAS_GRID.gap, - }; - return data; - } else if (isV1Layout(data)) { - return migrateV1ToV2(data); - } - return createEmptyPopLayoutV2(); -}; - -// 타입 가드 -export const isPopField = (comp: PopComponentDataV1 | PopComponentDefinition): boolean => - comp.type === "pop-field"; - -export const isPopButton = (comp: PopComponentDataV1 | PopComponentDefinition): boolean => - comp.type === "pop-button"; +/** @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..d7e49ef8 --- /dev/null +++ b/frontend/components/pop/designer/utils/gridUtils.ts @@ -0,0 +1,301 @@ +import { + PopGridPosition, + GridMode, + GRID_BREAKPOINTS +} from "../types/pop-layout"; + +// ======================================== +// 그리드 위치 변환 +// ======================================== + +/** + * 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, + }; +} + +// ======================================== +// 겹침 감지 및 해결 +// ======================================== + +/** + * 두 위치가 겹치는지 확인 + */ +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; +} + +// ======================================== +// 좌표 변환 +// ======================================== + +/** + * 마우스 좌표 → 그리드 좌표 변환 + */ +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; + + // 칸 너비 계산 + const totalGap = gap * (columns - 1); + const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns; + + // 그리드 좌표 계산 (1부터 시작) + const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 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; +} diff --git a/popdocs/ARCHITECTURE.md b/popdocs/ARCHITECTURE.md index 357e5c9b..bca80c28 100644 --- a/popdocs/ARCHITECTURE.md +++ b/popdocs/ARCHITECTURE.md @@ -1,588 +1,273 @@ # POP 화면 시스템 아키텍처 -**최종 업데이트: 2026-02-04** +**최종 업데이트: 2026-02-05 (v5 그리드 시스템)** POP(Point of Production) 화면은 모바일/태블릿 환경에 최적화된 터치 기반 화면 시스템입니다. -이 문서는 POP 화면 구현에 관련된 모든 파일과 그 역할을 정리합니다. --- -## 목차 +## 현재 버전: v5 (CSS Grid) -1. [폴더 구조 개요](#1-폴더-구조-개요) -2. [App 라우팅 (app/(pop))](#2-app-라우팅-apppop) -3. [컴포넌트 (components/pop)](#3-컴포넌트-componentspop) -4. [라이브러리 (lib)](#4-라이브러리-lib) -5. [버전별 레이아웃 시스템](#5-버전별-레이아웃-시스템) -6. [데이터 흐름](#6-데이터-흐름) +| 항목 | v5 (현재) | +|------|----------| +| 레이아웃 | CSS Grid | +| 배치 방식 | 좌표 기반 (col, row, colSpan, rowSpan) | +| 모드 | 4개 (mobile_portrait, mobile_landscape, tablet_portrait, tablet_landscape) | +| 칸 수 | 4/6/8/12칸 | --- -## 1. 폴더 구조 개요 +## 폴더 구조 ``` frontend/ -├── app/(pop)/ # Next.js App Router - POP 라우팅 +├── app/(pop)/ # Next.js App Router │ ├── layout.tsx # POP 전용 레이아웃 │ └── pop/ -│ ├── page.tsx # POP 대시보드 (메인) -│ ├── screens/[screenId]/ # 개별 POP 화면 뷰어 -│ ├── test-v4/ # v4 렌더러 테스트 페이지 +│ ├── page.tsx # 대시보드 +│ ├── screens/[screenId]/ # 화면 뷰어 (v5) │ └── work/ # 작업 화면 │ -├── components/pop/ # POP 컴포넌트 라이브러리 -│ ├── designer/ # 디자이너 모듈 -│ │ ├── panels/ # 편집 패널 (좌측/우측) -│ │ ├── renderers/ # 레이아웃 렌더러 -│ │ └── types/ # 타입 정의 -│ ├── management/ # 화면 관리 모듈 -│ └── dashboard/ # 대시보드 모듈 +├── components/pop/ # POP 컴포넌트 +│ ├── designer/ # 디자이너 모듈 ★ +│ │ ├── PopDesigner.tsx # 메인 (레이아웃 로드/저장) +│ │ ├── PopCanvas.tsx # 캔버스 (DnD, 줌, 모드) +│ │ ├── panels/ +│ │ │ └── ComponentEditorPanel.tsx # 속성 편집 +│ │ ├── renderers/ +│ │ │ └── PopRenderer.tsx # CSS Grid 렌더링 +│ │ ├── types/ +│ │ │ └── pop-layout.ts # v5 타입 정의 +│ │ └── utils/ +│ │ └── gridUtils.ts # 위치 계산 +│ ├── management/ # 화면 관리 +│ └── dashboard/ # 대시보드 │ └── lib/ - ├── api/popScreenGroup.ts # POP 화면 그룹 API - ├── registry/PopComponentRegistry.ts # 컴포넌트 레지스트리 - └── schemas/popComponentConfig.ts # 컴포넌트 설정 스키마 + ├── api/screen.ts # 화면 API + └── registry/ # 컴포넌트 레지스트리 ``` --- -## 2. App 라우팅 (app/(pop)) +## 핵심 파일 -### `app/(pop)/layout.tsx` +### 1. PopDesigner.tsx (메인) -POP 전용 레이아웃. 데스크톱 레이아웃과 분리되어 터치 최적화 환경 제공. - -### `app/(pop)/pop/page.tsx` - -**경로**: `/pop` - -POP 메인 대시보드. 메뉴 그리드, KPI, 공지사항 등을 표시. - -### `app/(pop)/pop/screens/[screenId]/page.tsx` - -**경로**: `/pop/screens/:screenId` - -**역할**: 개별 POP 화면 뷰어 (디자인 모드 X, 실행 모드) - -**핵심 기능**: -- v3/v4 레이아웃 자동 감지 및 렌더링 -- 반응형 모드 감지 (태블릿/모바일, 가로/세로) -- 프리뷰 모드 지원 (`?preview=true`) -- **뷰포트 감지 및 비율 스케일링** +**역할**: 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리 ```typescript -// 뷰포트 너비 감지 (최대 1366px 제한) -const [viewportWidth, setViewportWidth] = useState(1024); - -useEffect(() => { - const updateViewportWidth = () => { - setViewportWidth(Math.min(window.innerWidth, 1366)); - }; - updateViewportWidth(); - window.addEventListener("resize", updateViewportWidth); - return () => window.removeEventListener("resize", updateViewportWidth); -}, []); - -// 레이아웃 버전 감지 및 렌더링 -if (popLayoutV4) { - // v4: PopFlexRenderer 사용 (비율 스케일링 적용) -
- -
-} else if (popLayoutV3) { - // v3: PopLayoutRenderer 사용 - -} -``` - -### `app/(pop)/pop/test-v4/page.tsx` - -**경로**: `/pop/test-v4` - -**역할**: v4 레이아웃 시스템 테스트 페이지 - -**구성**: -- 왼쪽: 컴포넌트 팔레트 (PopPanel) -- 중앙: v4 캔버스 (PopCanvasV4) -- 오른쪽: 속성 패널 (ComponentEditorPanelV4) - ---- - -## 3. 컴포넌트 (components/pop) - -### 3.1 디자이너 모듈 (`designer/`) - -#### `PopDesigner.tsx` - -**역할**: POP 화면 디자이너 메인 컴포넌트 - -**핵심 기능**: -- v3/v4 모드 전환 (상단 탭) -- 레이아웃 로드/저장 -- 컴포넌트 추가/삭제/수정 -- 드래그 앤 드롭 (react-dnd) - -**상태 관리**: -```typescript -const [layoutMode, setLayoutMode] = useState<"v3" | "v4">("v3"); -const [layoutV3, setLayoutV3] = useState(...); -const [layoutV4, setLayoutV4] = useState(...); +// 상태 관리 +const [layout, setLayout] = useState(createEmptyPopLayoutV5()); const [selectedComponentId, setSelectedComponentId] = useState(null); +const [currentMode, setCurrentMode] = useState("tablet_landscape"); +const [history, setHistory] = useState([]); + +// 핵심 함수 +handleSave() // 레이아웃 저장 +handleAddComponent() // 컴포넌트 추가 +handleUpdateComponent() // 컴포넌트 수정 +handleDeleteComponent() // 컴포넌트 삭제 +handleUndo() / handleRedo() // 히스토리 ``` -**레이아웃**: -``` -┌─────────────────────────────────────────────────┐ -│ 툴바 (뒤로가기, 화면명, 모드전환, 저장) │ -├──────────┬──────────────────────────┬──────────┤ -│ 왼쪽 │ 중앙 캔버스 │ 오른쪽 │ -│ 패널 │ │ 패널 │ -│ (20%) │ (60%) │ (20%) │ -│ │ │ (v4만) │ -└──────────┴──────────────────────────┴──────────┘ -``` +### 2. PopCanvas.tsx (캔버스) -#### `PopCanvas.tsx` (v3용) - -**역할**: v3 레이아웃용 CSS Grid 기반 캔버스 - -**핵심 기능**: -- 4개 모드 전환 (태블릿 가로/세로, 모바일 가로/세로) -- 그리드 기반 컴포넌트 배치 -- 드래그로 위치/크기 조정 - -#### `PopCanvasV4.tsx` (v4용) - -**역할**: v4 레이아웃용 Flexbox 기반 캔버스 - -**핵심 기능**: -- 단일 캔버스 + 뷰포트 프리뷰 -- 3가지 프리셋 (모바일 375px, 태블릿 768px, 데스크톱 1024px) -- 너비 슬라이더로 반응형 테스트 -- 줌 컨트롤 (30%~150%) +**역할**: 그리드 캔버스, DnD, 줌, 패닝, 모드 전환 ```typescript +// DnD 설정 +const DND_ITEM_TYPES = { COMPONENT: "component" }; + +// 뷰포트 프리셋 (4개 모드) const VIEWPORT_PRESETS = [ - { id: "mobile", label: "모바일", width: 375, height: 667 }, - { id: "tablet", label: "태블릿", width: 768, height: 1024 }, - { id: "desktop", label: "데스크톱", width: 1024, height: 768 }, + { id: "mobile_portrait", width: 375, columns: 4 }, + { id: "mobile_landscape", width: 667, columns: 6 }, + { id: "tablet_portrait", width: 768, columns: 8 }, + { id: "tablet_landscape", width: 1024, columns: 12 }, ]; + +// 기능 +- useDrop(): 팔레트에서 컴포넌트 드롭 +- handleWheel(): 줌 (30%~150%) +- Space + 드래그: 패닝 ``` ---- +### 3. PopRenderer.tsx (렌더러) -### 3.2 패널 모듈 (`designer/panels/`) +**역할**: CSS Grid 기반 레이아웃 렌더링 -#### `PopPanel.tsx` - -**역할**: 왼쪽 패널 - 컴포넌트 팔레트 & 편집 탭 - -**탭 구성**: -1. **컴포넌트 탭**: 드래그 가능한 6개 컴포넌트 -2. **편집 탭**: 선택된 컴포넌트 설정 - -**컴포넌트 팔레트**: ```typescript -const COMPONENT_PALETTE = [ - { type: "pop-field", label: "필드", description: "텍스트, 숫자 등 데이터 입력" }, - { type: "pop-button", label: "버튼", description: "저장, 삭제 등 액션 실행" }, - { type: "pop-list", label: "리스트", description: "데이터 목록" }, - { type: "pop-indicator", label: "인디케이터", description: "KPI, 상태 표시" }, - { type: "pop-scanner", label: "스캐너", description: "바코드/QR 스캔" }, - { type: "pop-numpad", label: "숫자패드", description: "숫자 입력 전용" }, -]; -``` - -**드래그 아이템 타입**: -```typescript -export const DND_ITEM_TYPES = { COMPONENT: "component" }; -export interface DragItemComponent { - type: typeof DND_ITEM_TYPES.COMPONENT; - componentType: PopComponentType; +// Props +interface PopRendererProps { + layout: PopLayoutDataV5; + viewportWidth: number; + currentMode: GridMode; + isDesignMode: boolean; + selectedComponentId?: string | null; + onSelectComponent?: (id: string | null) => void; } -``` -#### `ComponentEditorPanelV4.tsx` - -**역할**: v4 오른쪽 패널 - 컴포넌트/컨테이너 속성 편집 - -**3개 탭**: -1. **크기 탭**: 너비/높이 제약 (fixed/fill/hug) -2. **설정 탭**: 라벨, 타입별 설정 -3. **데이터 탭**: 데이터 바인딩 (미구현) - -**크기 제약 편집**: -```typescript -// 너비/높이 모드 -type SizeMode = "fixed" | "fill" | "hug"; - -// fixed: 고정 px 값 -// fill: 남은 공간 채움 (flex: 1) -// hug: 내용에 맞춤 (width: auto) -``` - -**컨테이너 설정**: -- 방향 (horizontal/vertical) -- 줄바꿈 (wrap) -- 간격 (gap) -- 패딩 (padding) -- 정렬 (alignItems, justifyContent) - ---- - -### 3.3 렌더러 모듈 (`designer/renderers/`) - -#### `PopLayoutRenderer.tsx` (v3용) - -**역할**: v3 레이아웃을 CSS Grid로 렌더링 - -**입력**: -- `layout`: PopLayoutDataV3 -- `modeKey`: 현재 모드 (tablet_landscape 등) -- `isDesignMode`: 디자인 모드 여부 - -#### `PopFlexRenderer.tsx` (v4용) - -**역할**: v4 레이아웃을 Flexbox로 렌더링 + 비율 스케일링 - -**핵심 기능**: -- 컨테이너 재귀 렌더링 -- 반응형 규칙 적용 (breakpoint) -- 크기 제약 → CSS 스타일 변환 -- 컴포넌트 숨김 처리 (hideBelow) -- **비율 스케일링** (뷰어 모드) - -**비율 스케일링 시스템**: -```typescript -// 기준 너비 (10인치 태블릿 가로) -const BASE_VIEWPORT_WIDTH = 1024; - -// 스케일 계산 (디자인 모드: 1, 뷰어 모드: 실제 비율) -const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH; - -// 예시: 12인치(1366px) 화면 -// scale = 1366 / 1024 = 1.33 -// 200px 컴포넌트 → 266px -``` - -**크기 제약 변환 로직** (스케일 적용): -```typescript -function calculateSizeStyle( - size: PopSizeConstraintV4, - settings: PopGlobalSettingsV4, - scale: number = 1 // 스케일 파라미터 추가 -): React.CSSProperties { - const style: React.CSSProperties = {}; - - // 너비 (스케일 적용) - switch (size.width) { - case "fixed": - style.width = `${size.fixedWidth * scale}px`; - style.flexShrink = 0; - break; - case "fill": - style.flex = 1; - style.minWidth = size.minWidth ? `${size.minWidth * scale}px` : 0; - break; - case "hug": - style.width = "auto"; - style.flexShrink = 0; - break; - } - - // 높이 (스케일 적용) - switch (size.height) { - case "fixed": - style.height = `${size.fixedHeight * scale}px`; - break; - case "fill": - style.flexGrow = 1; - break; - case "hug": - style.height = "auto"; - break; - } - - return style; -} -``` - -**컨테이너 스케일 적용**: -```typescript -// gap, padding도 스케일 적용 -const scaledGap = gap * scale; -const scaledPadding = padding ? padding * scale : undefined; -``` - -#### `ComponentRenderer.tsx` - -**역할**: 개별 컴포넌트 렌더링 (디자인 모드용 플레이스홀더) - ---- - -### 3.4 타입 정의 (`designer/types/`) - -#### `pop-layout.ts` - -**역할**: POP 레이아웃 전체 타입 시스템 정의 - -**파일 크기**: 1442줄 (v1~v4 모든 버전 포함) - -상세 내용은 [버전별 레이아웃 시스템](#5-버전별-레이아웃-시스템) 참조. - ---- - -### 3.5 관리 모듈 (`management/`) - -#### `PopCategoryTree.tsx` - -POP 화면 카테고리 트리 컴포넌트 - -#### `PopScreenSettingModal.tsx` - -POP 화면 설정 모달 - -#### `PopScreenPreview.tsx` - -POP 화면 미리보기 - -#### `PopScreenFlowView.tsx` - -화면 간 플로우 시각화 - ---- - -### 3.6 대시보드 모듈 (`dashboard/`) - -| 파일 | 역할 | -|------|------| -| `PopDashboard.tsx` | 대시보드 메인 컴포넌트 | -| `DashboardHeader.tsx` | 상단 헤더 (로고, 시간, 사용자) | -| `DashboardFooter.tsx` | 하단 푸터 | -| `MenuGrid.tsx` | 메뉴 그리드 (앱 아이콘 형태) | -| `KpiBar.tsx` | KPI 요약 바 | -| `NoticeBanner.tsx` | 공지 배너 | -| `NoticeList.tsx` | 공지 목록 | -| `ActivityList.tsx` | 최근 활동 목록 | - ---- - -## 4. 라이브러리 (lib) - -### `lib/api/popScreenGroup.ts` - -**역할**: POP 화면 그룹 API 클라이언트 - -**API 함수**: -```typescript -// 조회 -getPopScreenGroups(searchTerm?: string): Promise - -// 생성 -createPopScreenGroup(data: CreatePopScreenGroupRequest): Promise<...> - -// 수정 -updatePopScreenGroup(id: number, data: UpdatePopScreenGroupRequest): Promise<...> - -// 삭제 -deletePopScreenGroup(id: number): Promise<...> - -// 루트 그룹 확보 -ensurePopRootGroup(): Promise<...> -``` - -**트리 변환 유틸리티**: -```typescript -// 플랫 리스트 → 트리 구조 -buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[] -``` - -### `lib/registry/PopComponentRegistry.ts` - -**역할**: POP 컴포넌트 중앙 레지스트리 - -**주요 메서드**: -```typescript -class PopComponentRegistry { - static registerComponent(definition: PopComponentDefinition): void - static unregisterComponent(id: string): void - static getComponent(id: string): PopComponentDefinition | undefined - static getComponentByUrl(url: string): PopComponentDefinition | undefined - static getAllComponents(): PopComponentDefinition[] - static getComponentsByCategory(category: PopComponentCategory): PopComponentDefinition[] - static getComponentsByDevice(device: "mobile" | "tablet"): PopComponentDefinition[] - static searchComponents(query: string): PopComponentDefinition[] -} -``` - -**카테고리**: -```typescript -type PopComponentCategory = - | "display" // 데이터 표시 (카드, 리스트, 배지) - | "input" // 입력 (스캐너, 터치 입력) - | "action" // 액션 (버튼, 스와이프) - | "layout" // 레이아웃 (컨테이너, 그리드) - | "feedback"; // 피드백 (토스트, 로딩) -``` - -### `lib/schemas/popComponentConfig.ts` - -**역할**: POP 컴포넌트 설정 스키마 (Zod 기반) - -**제공 내용**: -- 컴포넌트별 기본값 (`popCardListDefaults`, `popTouchButtonDefaults` 등) -- 컴포넌트별 Zod 스키마 (`popCardListOverridesSchema` 등) -- URL → 기본값/스키마 조회 함수 - ---- - -## 5. 버전별 레이아웃 시스템 - -### v1.0 (deprecated) - -- 단일 모드 -- 섹션 중첩 구조 -- CSS Grid - -### v2.0 (deprecated) - -- 4개 모드 (태블릿/모바일 x 가로/세로) -- 섹션 + 컴포넌트 분리 -- CSS Grid - -### v3.0 (현재 기본) - -- 4개 모드 -- **섹션 제거**, 컴포넌트 직접 배치 -- CSS Grid - -```typescript -interface PopLayoutDataV3 { - version: "pop-3.0"; - layouts: { - tablet_landscape: { componentPositions: Record }; - tablet_portrait: { componentPositions: Record }; - mobile_landscape: { componentPositions: Record }; - mobile_portrait: { componentPositions: Record }; +// CSS Grid 스타일 생성 +const gridStyle = useMemo(() => ({ + display: "grid", + gridTemplateColumns: `repeat(${columns}, 1fr)`, + gridTemplateRows: `repeat(${rows}, 1fr)`, + gap: `${gap}px`, + padding: `${padding}px`, +}), [mode]); + +// 위치 변환 (12칸 → 다른 모드) +const convertPosition = (pos: PopGridPosition, targetMode: GridMode) => { + const ratio = GRID_BREAKPOINTS[targetMode].columns / 12; + return { + col: Math.max(1, Math.round(pos.col * ratio)), + colSpan: Math.max(1, Math.round(pos.colSpan * ratio)), + row: pos.row, + rowSpan: pos.rowSpan, }; - components: Record; - dataFlow: PopDataFlow; - settings: PopGlobalSettings; -} +}; ``` -### v4.0 (신규, 권장) +### 4. ComponentEditorPanel.tsx (속성 패널) -- **단일 소스** (1번 설계 → 모든 화면 자동 적응) -- **제약 기반** (fixed/fill/hug) -- **Flexbox** 렌더링 -- **반응형 규칙** (breakpoint) +**역할**: 선택된 컴포넌트 속성 편집 ```typescript -interface PopLayoutDataV4 { - version: "pop-4.0"; - root: PopContainerV4; // 루트 컨테이너 (스택) - components: Record; - dataFlow: PopDataFlow; - settings: PopGlobalSettingsV4; +// 탭 구조 +- grid: col, row, colSpan, rowSpan (기본 모드에서만 편집) +- settings: label, type 등 +- data: 데이터 바인딩 (미구현) +- visibility: 모드별 표시/숨김 +``` + +### 5. pop-layout.ts (타입 정의) + +**역할**: v5 타입 정의 + +```typescript +// 그리드 모드 +type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; + +// 브레이크포인트 설정 +const GRID_BREAKPOINTS = { + mobile_portrait: { columns: 4, rowHeight: 48, gap: 8, padding: 12 }, + mobile_landscape: { columns: 6, rowHeight: 44, gap: 8, padding: 16 }, + tablet_portrait: { columns: 8, rowHeight: 52, gap: 12, padding: 20 }, + tablet_landscape: { columns: 12, rowHeight: 56, gap: 12, padding: 24 }, +}; + +// 레이아웃 데이터 +interface PopLayoutDataV5 { + version: "pop-5.0"; + metadata: PopLayoutMetadata; + gridConfig: PopGridConfig; + components: PopComponentDefinitionV5[]; + globalSettings: PopGlobalSettingsV5; } -interface PopContainerV4 { +// 컴포넌트 정의 +interface PopComponentDefinitionV5 { id: string; - type: "stack"; - direction: "horizontal" | "vertical"; - wrap: boolean; - gap: number; - alignItems: "start" | "center" | "end" | "stretch"; - justifyContent: "start" | "center" | "end" | "space-between"; - padding?: number; - responsive?: PopResponsiveRuleV4[]; // 반응형 규칙 - children: (string | PopContainerV4)[]; // 컴포넌트 ID 또는 중첩 컨테이너 + type: PopComponentType; + label: string; + gridPosition: PopGridPosition; // col, row, colSpan, rowSpan + config: PopComponentConfig; + visibility: Record; + modeOverrides?: Record; } -interface PopSizeConstraintV4 { - width: "fixed" | "fill" | "hug"; - height: "fixed" | "fill" | "hug"; - fixedWidth?: number; - fixedHeight?: number; - minWidth?: number; - maxWidth?: number; - minHeight?: number; +// 위치 +interface PopGridPosition { + col: number; // 시작 열 (1부터) + row: number; // 시작 행 (1부터) + colSpan: number; // 열 크기 (1~12) + rowSpan: number; // 행 크기 (1~) } ``` -### 버전 비교표 +### 6. gridUtils.ts (유틸리티) -| 항목 | v3 | v4 | -|------|----|----| -| 설계 횟수 | 4번 (모드별) | 1번 | -| 위치 지정 | col, row, colSpan, rowSpan | 제약 (fill/fixed/hug) | -| 렌더링 | CSS Grid | Flexbox | -| 반응형 | 수동 (모드 전환) | 자동 (breakpoint 규칙) | -| 복잡도 | 높음 | 낮음 | +**역할**: 그리드 위치 계산 ---- +```typescript +// 위치 변환 +convertPositionToMode(pos, targetMode) -## 6. 데이터 흐름 +// 겹침 감지 +isOverlapping(posA, posB) -### 화면 로드 흐름 +// 빈 위치 찾기 +findNextEmptyPosition(layout, mode) -``` -[사용자 접속] - ↓ -[/pop/screens/:screenId] - ↓ -[screenApi.getLayoutPop(screenId)] - ↓ -[레이아웃 버전 감지] - ├── v4 → PopFlexRenderer - ├── v3 → PopLayoutRenderer - └── v1/v2 → ensureV3Layout() → v3로 변환 -``` - -### 디자이너 저장 흐름 - -``` -[사용자 편집] - ↓ -[hasChanges = true] - ↓ -[저장 버튼 클릭] - ↓ -[screenApi.saveLayoutPop(screenId, layoutV3 | layoutV4)] - ↓ -[hasChanges = false] -``` - -### 컴포넌트 드래그 앤 드롭 흐름 - -``` -[PopPanel의 컴포넌트 드래그] - ↓ -[DragItemComponent { type: "component", componentType: "pop-button" }] - ↓ -[캔버스 Drop 감지] - ↓ -[v3: handleDropComponentV3(type, gridPosition)] -[v4: handleDropComponentV4(type, containerId)] - ↓ -[레이아웃 상태 업데이트] - ↓ -[hasChanges = true] +// 마우스 → 그리드 좌표 +mouseToGridPosition(mouseX, mouseY, canvasRect, mode) ``` --- -## 관련 문서 +## 데이터 흐름 -- [PLAN.md](./PLAN.md) - 개발 계획 및 로드맵 -- [components-spec.md](./components-spec.md) - 컴포넌트 상세 스펙 -- [CHANGELOG.md](./CHANGELOG.md) - 변경 이력 +``` +[사용자 액션] + ↓ +[PopDesigner] ← 상태 관리 (layout, selectedComponentId, history) + ↓ +[PopCanvas] ← DnD, 줌, 모드 전환 + ↓ +[PopRenderer] ← CSS Grid 렌더링 + ↓ +[컴포넌트 표시] +``` + +### 저장 흐름 + +``` +[저장 버튼] + ↓ +PopDesigner.handleSave() + ↓ +screenApi.saveLayoutPop(screenId, layout) + ↓ +[백엔드] screenManagementService.saveLayoutPop() + ↓ +[DB] screen_layouts_pop 테이블 +``` + +### 로드 흐름 + +``` +[페이지 로드] + ↓ +PopDesigner useEffect + ↓ +screenApi.getLayoutPop(screenId) + ↓ +isV5Layout(data) 체크 + ↓ +setLayout(data) 또는 createEmptyPopLayoutV5() +``` --- -*이 문서는 POP 화면 시스템의 구조를 이해하고 유지보수하기 위한 참조용으로 작성되었습니다.* +## API 엔드포인트 + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| GET | `/api/screen-management/layout-pop/:screenId` | 레이아웃 조회 | +| POST | `/api/screen-management/layout-pop/:screenId` | 레이아웃 저장 | + +--- + +## 삭제된 레거시 (참고용) + +| 파일 | 버전 | 이유 | +|------|------|------| +| PopCanvasV4.tsx | v4 | Flexbox 기반, v5로 대체 | +| PopFlexRenderer.tsx | v4 | Flexbox 렌더러, v5로 대체 | +| PopLayoutRenderer.tsx | v3 | 절대 좌표 기반, v5로 대체 | +| ComponentEditorPanelV4.tsx | v4 | v5 전용으로 통합 | + +--- + +*상세 스펙: [SPEC.md](./SPEC.md) | 파일 목록: [FILES.md](./FILES.md)* diff --git a/popdocs/CHANGELOG.md b/popdocs/CHANGELOG.md index 73eb7169..305ed422 100644 --- a/popdocs/CHANGELOG.md +++ b/popdocs/CHANGELOG.md @@ -6,10 +6,105 @@ ## [미출시] -- Phase 2: 모드별 오버라이드 기능 (진행 중) -- Phase 3: 컴포넌트 표시/숨김 -- Phase 4: 순서 오버라이드 -- Tier 2, 3 컴포넌트 +- Phase 4: 실제 컴포넌트 구현 (pop-field, pop-button 등) +- 데이터 바인딩 구현 +- 워크플로우 연동 + +--- + +## [2026-02-05] v5 그리드 시스템 완전 통합 + +### 배경 (왜 v5로 전환했는가) + +**문제 상황**: +- v4 Flexbox로 반응형 구현 시도 → 배치가 예측 불가능 +- 캔버스에 "그리듯이" 배치하면 화면 크기별로 깨짐 + +**상급자 피드백**: +> "이런 식이면 나중에 문제가 생긴다." +> "스크린의 픽셀 규격과 마진 간격 규칙을 설정해라. +> 큰 화면 디자인의 전체 프레임 규격과 사이즈 간격 규칙을 정한 다음에 +> 거기에 컴포넌트를 끼워 맞추듯 우리의 규칙 내로 움직이게 바탕을 잡아라." + +**연구 내용**: +- Softr: 블록 기반, 제약 기반 레이아웃 +- Ant Design: 24열 그리드, 8px 간격 +- Material Design: 4/8/12열, 반응형 브레이크포인트 + +**결정**: CSS Grid 기반 그리드 시스템 (v5) 채택 +→ 상세: [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md) + +### popdocs 문서 구조 재정비 + +**배경**: 문서가 AI 에이전트 진입점 역할을 못함, 컨텍스트 효율화 필요 + +**적용 기법**: Progressive Disclosure (점진적 공개), Token as Currency + +**추가된 파일**: +- `SAVE_RULES.md`: AI 저장/조회 규칙, 템플릿 +- `STATUS.md`: 현재 진행 상태, 중단점 +- `PROBLEMS.md`: 문제-해결 색인 +- `INDEX.md`: 기능별 색인 +- `sessions/`: 날짜별 작업 기록 + +**문서 계층**: +- Layer 1 (진입점): README, STATUS, SAVE_RULES +- Layer 2 (상세): CHANGELOG, PROBLEMS, INDEX, FILES, ARCHITECTURE +- Layer 3 (심화): decisions/, sessions/, archive/ + +### Breaking Changes +- **v1, v2, v3, v4 레이아웃 완전 삭제** +- 기존 POP 화면 데이터 전체 초기화 필요 +- 레거시 컴포넌트 및 타입 삭제 + +### Added +- **CSS Grid 기반 그리드 시스템 (v5)** + - 4개 모드별 칸 수: 4/6/8/12칸 + - 명시적 위치 지정 (col, row, colSpan, rowSpan) + - 모드별 오버라이드 지원 + - 자동 위치 변환 (12칸 기준 → 다른 모드) + +- **통합된 파일 구조** + - `PopCanvas.tsx`: 그리드 캔버스 (DnD + 줌 + 모드 전환) + - `PopRenderer.tsx`: 그리드 렌더링 + - `ComponentEditorPanel.tsx`: 속성 편집 + - `pop-layout.ts`: v5 전용 타입 정의 + - `gridUtils.ts`: 그리드 유틸리티 함수 + +### Removed +- `PopCanvasV4.tsx`, `PopCanvas.tsx (v3)` +- `PopFlexRenderer.tsx`, `PopLayoutRenderer.tsx` +- `ComponentEditorPanelV4.tsx`, `PopPanel.tsx` +- v1, v2, v3, v4 타입 정의 및 유틸리티 함수 +- `test-v4` 테스트 페이지 + +### Changed +- `screenManagementService.ts`: v5 전용으로 단순화 +- `screen_layouts_pop` 테이블: 기존 데이터 삭제, v5 전용 +- `PopDesigner.tsx`: v5 전용으로 리팩토링 +- 뷰어 페이지: v5 렌더러 전용 + +### Technical Details +```typescript +// v5 그리드 모드 +type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; + +// 그리드 설정 +const GRID_BREAKPOINTS = { + mobile_portrait: { columns: 4, rowHeight: 48, gap: 8, padding: 12 }, + mobile_landscape: { columns: 6, rowHeight: 44, gap: 8, padding: 16 }, + tablet_portrait: { columns: 8, rowHeight: 52, gap: 12, padding: 20 }, + tablet_landscape: { columns: 12, rowHeight: 56, gap: 12, padding: 24 }, +}; + +// 컴포넌트 위치 +interface PopGridPosition { + col: number; // 시작 열 (1부터) + row: number; // 시작 행 (1부터) + colSpan: number; // 열 크기 + rowSpan: number; // 행 크기 +} +``` --- diff --git a/popdocs/FILES.md b/popdocs/FILES.md index f9e3f8ce..477e3afd 100644 --- a/popdocs/FILES.md +++ b/popdocs/FILES.md @@ -1,6 +1,6 @@ # POP 파일 상세 목록 -**최종 업데이트: 2026-02-04** +**최종 업데이트: 2026-02-05 (v5 그리드 시스템 통합)** 이 문서는 POP 화면 시스템과 관련된 모든 파일을 나열하고 각 파일의 역할을 설명합니다. @@ -13,10 +13,11 @@ 3. [Panels 파일](#3-panels-파일) 4. [Renderers 파일](#4-renderers-파일) 5. [Types 파일](#5-types-파일) -6. [Management 파일](#6-management-파일) -7. [Dashboard 파일](#7-dashboard-파일) -8. [Library 파일](#8-library-파일) -9. [루트 컴포넌트 파일](#9-루트-컴포넌트-파일) +6. [Utils 파일](#6-utils-파일) +7. [Management 파일](#7-management-파일) +8. [Dashboard 파일](#8-dashboard-파일) +9. [Library 파일](#9-library-파일) +10. [루트 컴포넌트 파일](#10-루트-컴포넌트-파일) --- @@ -46,34 +47,36 @@ | 항목 | 내용 | |------|------| -| 역할 | 개별 POP 화면 뷰어 | +| 역할 | 개별 POP 화면 뷰어 (v5 전용) | | 경로 | `/pop/screens/:screenId` | -| 라인 수 | 468줄 | +| 버전 | v5 그리드 시스템 전용 | **핵심 코드 구조**: ```typescript -// 상태 -const [popLayoutV3, setPopLayoutV3] = useState(null); -const [popLayoutV4, setPopLayoutV4] = useState(null); +// v5 레이아웃 상태 +const [layout, setLayout] = useState(createEmptyPopLayoutV5()); // 레이아웃 로드 useEffect(() => { const popLayout = await screenApi.getLayoutPop(screenId); - if (isPopLayoutV4(popLayout)) { - setPopLayoutV4(popLayout); - } else if (isPopLayout(popLayout)) { - const v3Layout = ensureV3Layout(popLayout); - setPopLayoutV3(v3Layout); + if (isV5Layout(popLayout)) { + setLayout(popLayout); + } else { + // 레거시 레이아웃은 빈 v5로 처리 + setLayout(createEmptyPopLayoutV5()); } }, [screenId]); -// 렌더링 분기 -{popLayoutV4 ? ( - -) : popLayoutV3 ? ( - +// v5 그리드 렌더링 +{hasComponents ? ( + ) : ( // 빈 화면 )} @@ -83,43 +86,7 @@ useEffect(() => { - 반응형 모드 감지 (useResponsiveModeWithOverride) - 프리뷰 모드 (`?preview=true`) - 디바이스/방향 수동 전환 (프리뷰 모드) -- v1/v2/v3/v4 레이아웃 자동 감지 - ---- - -### `frontend/app/(pop)/pop/test-v4/page.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | v4 렌더러 테스트 페이지 | -| 경로 | `/pop/test-v4` | -| 라인 수 | 150줄 | - -**핵심 코드 구조**: - -```typescript -export default function TestV4Page() { - const [layout, setLayout] = useState(createEmptyPopLayoutV4()); - const [selectedComponentId, setSelectedComponentId] = useState(null); - const [selectedContainerId, setSelectedContainerId] = useState(null); - const [idCounter, setIdCounter] = useState(1); - - // 컴포넌트 CRUD - const handleDropComponent = useCallback(...); - const handleDeleteComponent = useCallback(...); - const handleUpdateComponent = useCallback(...); - const handleUpdateContainer = useCallback(...); - - return ( - - {/* 3-column 레이아웃 */} - - - - - ); -} -``` +- 4개 그리드 모드 지원 --- @@ -138,8 +105,7 @@ export default function TestV4Page() { | 항목 | 내용 | |------|------| -| 역할 | POP 화면 디자이너 메인 | -| 라인 수 | 524줄 | +| 역할 | POP 화면 디자이너 메인 (v5 전용) | | 의존성 | react-dnd, ResizablePanelGroup | **핵심 Props**: @@ -155,41 +121,29 @@ interface PopDesignerProps { **상태 관리**: ```typescript -// 레이아웃 모드 -const [layoutMode, setLayoutMode] = useState<"v3" | "v4">("v3"); - -// v3 레이아웃 -const [layoutV3, setLayoutV3] = useState(createEmptyPopLayoutV3()); - -// v4 레이아웃 -const [layoutV4, setLayoutV4] = useState(createEmptyPopLayoutV4()); +// v5 레이아웃 +const [layout, setLayout] = useState(createEmptyPopLayoutV5()); // 선택 상태 const [selectedComponentId, setSelectedComponentId] = useState(null); -const [selectedContainerId, setSelectedContainerId] = useState(null); + +// 그리드 모드 (4개) +const [currentMode, setCurrentMode] = useState("tablet_landscape"); // UI 상태 const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); - -// v3용 상태 -const [activeDevice, setActiveDevice] = useState("tablet"); -const [activeModeKey, setActiveModeKey] = useState("tablet_landscape"); ``` **주요 핸들러**: | 핸들러 | 역할 | |--------|------| -| `handleDropComponentV3` | v3 컴포넌트 드롭 | -| `handleDropComponentV4` | v4 컴포넌트 드롭 | -| `handleUpdateComponentDefinitionV3` | v3 컴포넌트 정의 수정 | -| `handleUpdateComponentV4` | v4 컴포넌트 수정 | -| `handleUpdateContainerV4` | v4 컨테이너 수정 | -| `handleDeleteComponentV3` | v3 컴포넌트 삭제 | -| `handleDeleteComponentV4` | v4 컴포넌트 삭제 | -| `handleSave` | 레이아웃 저장 | +| `handleDropComponent` | 컴포넌트 드롭 (그리드 위치 계산) | +| `handleUpdateComponent` | 컴포넌트 속성 수정 | +| `handleDeleteComponent` | 컴포넌트 삭제 | +| `handleSave` | v5 레이아웃 저장 | --- @@ -197,32 +151,21 @@ const [activeModeKey, setActiveModeKey] = useState("tablet_lan | 항목 | 내용 | |------|------| -| 역할 | v3 CSS Grid 기반 캔버스 | -| 렌더링 | CSS Grid | +| 역할 | v5 CSS Grid 기반 캔버스 | +| 렌더링 | CSS Grid (4/6/8/12칸) | | 모드 | 4개 (태블릿/모바일 x 가로/세로) | ---- - -### `frontend/components/pop/designer/PopCanvasV4.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | v4 Flexbox 기반 캔버스 | -| 라인 수 | 309줄 | -| 렌더링 | Flexbox (via PopFlexRenderer) | - **핵심 Props**: ```typescript -interface PopCanvasV4Props { - layout: PopLayoutDataV4; +interface PopCanvasProps { + layout: PopLayoutDataV5; selectedComponentId: string | null; - selectedContainerId: string | null; + currentMode: GridMode; + onModeChange: (mode: GridMode) => void; onSelectComponent: (id: string | null) => void; - onSelectContainer: (id: string | null) => void; - onDropComponent: (type: PopComponentType, containerId: string) => void; - onUpdateComponent: (componentId: string, updates: Partial) => void; - onUpdateContainer: (containerId: string, updates: Partial) => void; + onDropComponent: (type: PopComponentType, position: PopGridPosition) => void; + onUpdateComponent: (componentId: string, updates: Partial) => void; onDeleteComponent: (componentId: string) => void; } ``` @@ -231,18 +174,19 @@ interface PopCanvasV4Props { ```typescript const VIEWPORT_PRESETS = [ - { id: "mobile", label: "모바일", width: 375, height: 667, icon: Smartphone }, - { id: "tablet", label: "태블릿", width: 768, height: 1024, icon: Tablet }, - { id: "desktop", label: "데스크톱", width: 1024, height: 768, icon: Monitor }, + { id: "mobile_portrait", label: "모바일 세로", width: 375, height: 667 }, // 4칸 + { id: "mobile_landscape", label: "모바일 가로", width: 667, height: 375 }, // 6칸 + { id: "tablet_portrait", label: "태블릿 세로", width: 768, height: 1024 }, // 8칸 + { id: "tablet_landscape", label: "태블릿 가로", width: 1024, height: 768 }, // 12칸 ]; ``` **제공 기능**: -- 뷰포트 프리셋 전환 -- 너비 슬라이더 (320px ~ 1440px) +- 4개 모드 프리셋 전환 - 줌 컨트롤 (30% ~ 150%) -- 패닝 (Space + 드래그 또는 휠 클릭) -- 컴포넌트 드롭 +- 패닝 (Space + 드래그) +- 컴포넌트 드래그 앤 드롭 +- 그리드 좌표 계산 --- @@ -250,75 +194,31 @@ const VIEWPORT_PRESETS = [ ```typescript export { default as PopDesigner } from "./PopDesigner"; -export { PopCanvas } from "./PopCanvas"; -export { PopCanvasV4 } from "./PopCanvasV4"; -export * from "./panels"; -export * from "./renderers"; +export { default as PopCanvas } from "./PopCanvas"; +export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel"; +export { default as PopRenderer } from "./renderers/PopRenderer"; export * from "./types"; +export * from "./utils/gridUtils"; ``` --- ## 3. Panels 파일 -### `frontend/components/pop/designer/panels/PopPanel.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | 왼쪽 패널 (컴포넌트 팔레트 + 편집) | -| 라인 수 | 369줄 | - -**탭 구성**: -1. `components` - 컴포넌트 팔레트 -2. `edit` - 선택된 컴포넌트 편집 - -**컴포넌트 팔레트**: - -```typescript -const COMPONENT_PALETTE = [ - { type: "pop-field", label: "필드", icon: Type, description: "텍스트, 숫자 등 데이터 입력" }, - { type: "pop-button", label: "버튼", icon: MousePointer, description: "저장, 삭제 등 액션 실행" }, - { type: "pop-list", label: "리스트", icon: List, description: "데이터 목록 (카드 템플릿 지원)" }, - { type: "pop-indicator", label: "인디케이터", icon: Activity, description: "KPI, 상태 표시" }, - { type: "pop-scanner", label: "스캐너", icon: ScanLine, description: "바코드/QR 스캔" }, - { type: "pop-numpad", label: "숫자패드", icon: Calculator, description: "숫자 입력 전용" }, -]; -``` - -**내보내기 (exports)**: - -```typescript -export const DND_ITEM_TYPES = { COMPONENT: "component" }; -export interface DragItemComponent { ... } -export function PopPanel({ ... }: PopPanelProps) { ... } -``` - ---- - ### `frontend/components/pop/designer/panels/ComponentEditorPanel.tsx` | 항목 | 내용 | |------|------| -| 역할 | v3 컴포넌트 편집 패널 | -| 용도 | PopPanel 내부에서 사용 | - ---- - -### `frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | v4 오른쪽 속성 패널 | -| 라인 수 | 609줄 | +| 역할 | v5 컴포넌트 편집 패널 | +| 위치 | 오른쪽 사이드바 | **핵심 Props**: ```typescript -interface ComponentEditorPanelV4Props { - component: PopComponentDefinitionV4 | null; - container: PopContainerV4 | null; - onUpdateComponent?: (updates: Partial) => void; - onUpdateContainer?: (updates: Partial) => void; +interface ComponentEditorPanelProps { + component: PopComponentDefinitionV5 | null; + currentMode: GridMode; + onUpdateComponent?: (updates: Partial) => void; className?: string; } ``` @@ -327,115 +227,71 @@ interface ComponentEditorPanelV4Props { | 탭 | 아이콘 | 내용 | |----|--------|------| -| `size` | Maximize2 | 크기 제약 (fixed/fill/hug) | +| `grid` | Grid3x3 | 그리드 위치 (col, row, colSpan, rowSpan) | | `settings` | Settings | 라벨, 타입별 설정 | -| `data` | Database | 데이터 바인딩 (미구현) | - -**내부 컴포넌트**: - -| 컴포넌트 | 역할 | -|----------|------| -| `SizeConstraintForm` | 너비/높이 제약 편집 | -| `SizeButton` | fixed/fill/hug 선택 버튼 | -| `ContainerSettingsForm` | 컨테이너 방향/정렬/간격 편집 | -| `ComponentSettingsForm` | 라벨 편집 | -| `DataBindingPlaceholder` | 데이터 바인딩 플레이스홀더 | +| `data` | Database | 데이터 바인딩 (Phase 4) | --- ### `frontend/components/pop/designer/panels/index.ts` ```typescript -export { PopPanel, DND_ITEM_TYPES } from "./PopPanel"; -export type { DragItemComponent } from "./PopPanel"; -export { ComponentEditorPanel } from "./ComponentEditorPanel"; -export { ComponentEditorPanelV4 } from "./ComponentEditorPanelV4"; +export { default as ComponentEditorPanel, default } from "./ComponentEditorPanel"; ``` --- ## 4. Renderers 파일 -### `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx` +### `frontend/components/pop/designer/renderers/PopRenderer.tsx` | 항목 | 내용 | |------|------| -| 역할 | v3 레이아웃 CSS Grid 렌더러 | -| 입력 | PopLayoutDataV3, modeKey | - -**내보내기**: - -```typescript -export function PopLayoutRenderer({ ... }) { ... } -export function hasBaseLayout(layout: PopLayoutDataV3): boolean { ... } -export function getEffectiveModeLayout(layout: PopLayoutDataV3, modeKey: PopLayoutModeKey) { ... } -``` - ---- - -### `frontend/components/pop/designer/renderers/PopFlexRenderer.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | v4 레이아웃 Flexbox 렌더러 | -| 라인 수 | 498줄 | -| 입력 | PopLayoutDataV4, viewportWidth | +| 역할 | v5 레이아웃 CSS Grid 렌더러 | +| 입력 | PopLayoutDataV5, viewportWidth, currentMode | **핵심 Props**: ```typescript -interface PopFlexRendererProps { - layout: PopLayoutDataV4; +interface PopRendererProps { + layout: PopLayoutDataV5; viewportWidth: number; + currentMode?: GridMode; isDesignMode?: boolean; selectedComponentId?: string | null; onComponentClick?: (componentId: string) => void; - onContainerClick?: (containerId: string) => void; onBackgroundClick?: () => void; className?: string; } ``` -**내부 컴포넌트**: - -| 컴포넌트 | 역할 | -|----------|------| -| `ContainerRenderer` | 컨테이너 재귀 렌더링 | -| `ComponentRendererV4` | v4 컴포넌트 렌더링 | - -**핵심 함수**: +**CSS Grid 스타일 생성**: ```typescript -// 반응형 규칙 적용 -function applyResponsiveRules(container: PopContainerV4, viewportWidth: number): PopContainerV4 - -// 크기 제약 → CSS 스타일 -function calculateSizeStyle(size: PopSizeConstraintV4, settings: PopGlobalSettingsV4): React.CSSProperties - -// 정렬 값 변환 -function mapAlignment(value: string): React.CSSProperties["alignItems"] -function mapJustify(value: string): React.CSSProperties["justifyContent"] - -// 컴포넌트 내용 렌더링 -function renderComponentContent(component: PopComponentDefinitionV4, ...): React.ReactNode +const gridStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, + gridAutoRows: `${breakpoint.rowHeight}px`, + gap: `${breakpoint.gap}px`, + padding: `${breakpoint.padding}px`, +}; ``` ---- +**컴포넌트 위치 변환**: -### `frontend/components/pop/designer/renderers/ComponentRenderer.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | 개별 컴포넌트 렌더러 (디자인 모드용) | +```typescript +const convertPosition = (position: PopGridPosition): React.CSSProperties => ({ + gridColumn: `${position.col} / span ${position.colSpan}`, + gridRow: `${position.row} / span ${position.rowSpan}`, +}); +``` --- ### `frontend/components/pop/designer/renderers/index.ts` ```typescript -export { PopLayoutRenderer, hasBaseLayout, getEffectiveModeLayout } from "./PopLayoutRenderer"; -export { ComponentRenderer } from "./ComponentRenderer"; -export { PopFlexRenderer } from "./PopFlexRenderer"; +export { default as PopRenderer, default } from "./PopRenderer"; ``` --- @@ -446,85 +302,55 @@ export { PopFlexRenderer } from "./PopFlexRenderer"; | 항목 | 내용 | |------|------| -| 역할 | POP 레이아웃 전체 타입 시스템 | -| 라인 수 | 1442줄 | +| 역할 | POP 레이아웃 v5 타입 시스템 | +| 버전 | v5 전용 (레거시 제거됨) | -**주요 타입** (v4): +**핵심 타입**: ```typescript -// v4 레이아웃 -interface PopLayoutDataV4 { - version: "pop-4.0"; - root: PopContainerV4; - components: Record; - dataFlow: PopDataFlow; - settings: PopGlobalSettingsV4; - metadata?: PopLayoutMetadata; -} +// 그리드 모드 +type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; -// v4 컨테이너 -interface PopContainerV4 { - id: string; - type: "stack"; - direction: "horizontal" | "vertical"; - wrap: boolean; +// 그리드 브레이크포인트 +interface GridBreakpoint { + label: string; + columns: number; + minWidth: number; + maxWidth: number; + rowHeight: number; gap: number; - alignItems: "start" | "center" | "end" | "stretch"; - justifyContent: "start" | "center" | "end" | "space-between"; - padding?: number; - responsive?: PopResponsiveRuleV4[]; - children: (string | PopContainerV4)[]; + padding: number; } -// v4 크기 제약 -interface PopSizeConstraintV4 { - width: "fixed" | "fill" | "hug"; - height: "fixed" | "fill" | "hug"; - fixedWidth?: number; - fixedHeight?: number; - minWidth?: number; - maxWidth?: number; - minHeight?: number; -} - -// v4 반응형 규칙 -interface PopResponsiveRuleV4 { - breakpoint: number; - direction?: "horizontal" | "vertical"; - gap?: number; - hidden?: boolean; -} -``` - -**주요 타입** (v3): - -```typescript -// v3 레이아웃 -interface PopLayoutDataV3 { - version: "pop-3.0"; - layouts: { - tablet_landscape: PopModeLayoutV3; - tablet_portrait: PopModeLayoutV3; - mobile_landscape: PopModeLayoutV3; - mobile_portrait: PopModeLayoutV3; - }; - components: Record; +// v5 레이아웃 +interface PopLayoutDataV5 { + version: "pop-5.0"; + gridConfig: PopGridConfig; + components: Record; dataFlow: PopDataFlow; - settings: PopGlobalSettings; + settings: PopGlobalSettingsV5; metadata?: PopLayoutMetadata; -} - -// v3 모드별 레이아웃 -interface PopModeLayoutV3 { - componentPositions: Record; + overrides?: Record; } // 그리드 위치 -interface GridPosition { - col: number; - row: number; - colSpan: number; - rowSpan: number; +interface PopGridPosition { + col: number; // 시작 열 (1부터) + row: number; // 시작 행 (1부터) + colSpan: number; // 열 크기 + rowSpan: number; // 행 크기 +} + +// v5 컴포넌트 정의 +interface PopComponentDefinitionV5 { + id: string; + type: PopComponentType; + label?: string; + position: PopGridPosition; + visibility?: { modes: GridMode[]; defaultVisible: boolean }; + dataBinding?: PopDataBinding; + style?: PopStylePreset; + config?: PopComponentConfig; } ``` @@ -532,21 +358,11 @@ interface GridPosition { | 함수 | 역할 | |------|------| -| `createEmptyPopLayoutV4()` | 빈 v4 레이아웃 생성 | -| `createEmptyPopLayoutV3()` | 빈 v3 레이아웃 생성 | -| `addComponentToV4Layout()` | v4에 컴포넌트 추가 | -| `removeComponentFromV4Layout()` | v4에서 컴포넌트 삭제 | -| `updateComponentInV4Layout()` | v4 컴포넌트 수정 | -| `updateContainerV4()` | v4 컨테이너 수정 | -| `findContainerV4()` | v4 컨테이너 찾기 | -| `addComponentToV3Layout()` | v3에 컴포넌트 추가 | -| `removeComponentFromV3Layout()` | v3에서 컴포넌트 삭제 | -| `updateComponentPositionInModeV3()` | v3 특정 모드 위치 수정 | -| `isV4Layout()` | v4 타입 가드 | -| `isV3Layout()` | v3 타입 가드 | -| `ensureV3Layout()` | v1/v2/v3 → v3 변환 | -| `migrateV2ToV3()` | v2 → v3 마이그레이션 | -| `migrateV1ToV3()` | v1 → v3 마이그레이션 | +| `createEmptyPopLayoutV5()` | 빈 v5 레이아웃 생성 | +| `addComponentToV5Layout()` | v5에 컴포넌트 추가 | +| `createComponentDefinitionV5()` | v5 컴포넌트 정의 생성 | +| `isV5Layout()` | v5 타입 가드 | +| `detectGridMode()` | 뷰포트 너비로 모드 감지 | --- @@ -558,7 +374,32 @@ export * from "./pop-layout"; --- -## 6. Management 파일 +## 6. Utils 파일 + +### `frontend/components/pop/designer/utils/gridUtils.ts` + +| 항목 | 내용 | +|------|------| +| 역할 | 그리드 위치 계산 유틸리티 | +| 용도 | 좌표 변환, 겹침 감지, 자동 배치 | + +**주요 함수**: + +| 함수 | 역할 | +|------|------| +| `convertPositionToMode()` | 12칸 기준 위치를 다른 모드로 변환 | +| `isOverlapping()` | 두 위치 겹침 여부 확인 | +| `resolveOverlaps()` | 겹침 해결 (아래로 밀기) | +| `mouseToGridPosition()` | 마우스 좌표 → 그리드 좌표 | +| `gridToPixelPosition()` | 그리드 좌표 → 픽셀 좌표 | +| `isValidPosition()` | 위치 유효성 검사 | +| `clampPosition()` | 위치 범위 조정 | +| `findNextEmptyPosition()` | 다음 빈 위치 찾기 | +| `autoLayoutComponents()` | 자동 배치 | + +--- + +## 7. Management 파일 ### `frontend/components/pop/management/PopCategoryTree.tsx` @@ -607,7 +448,7 @@ export { PopScreenFlowView } from "./PopScreenFlowView"; --- -## 7. Dashboard 파일 +## 8. Dashboard 파일 ### `frontend/components/pop/dashboard/PopDashboard.tsx` @@ -618,134 +459,27 @@ export { PopScreenFlowView } from "./PopScreenFlowView"; --- -### `frontend/components/pop/dashboard/DashboardHeader.tsx` +### 기타 Dashboard 컴포넌트 -| 항목 | 내용 | +| 파일 | 역할 | |------|------| -| 역할 | 상단 헤더 | -| 표시 | 로고, 시간, 사용자 정보 | +| `DashboardHeader.tsx` | 상단 헤더 | +| `DashboardFooter.tsx` | 하단 푸터 | +| `MenuGrid.tsx` | 메뉴 그리드 | +| `KpiBar.tsx` | KPI 요약 바 | +| `NoticeBanner.tsx` | 공지 배너 | +| `NoticeList.tsx` | 공지 목록 | +| `ActivityList.tsx` | 최근 활동 목록 | --- -### `frontend/components/pop/dashboard/DashboardFooter.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | 하단 푸터 | - ---- - -### `frontend/components/pop/dashboard/MenuGrid.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | 메뉴 그리드 | -| 스타일 | 앱 아이콘 형태 | - ---- - -### `frontend/components/pop/dashboard/KpiBar.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | KPI 요약 바 | -| 표시 | 핵심 지표 수치 | - ---- - -### `frontend/components/pop/dashboard/NoticeBanner.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | 공지 배너 | -| 스타일 | 슬라이드 배너 | - ---- - -### `frontend/components/pop/dashboard/NoticeList.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | 공지 목록 | -| 스타일 | 리스트 형태 | - ---- - -### `frontend/components/pop/dashboard/ActivityList.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | 최근 활동 목록 | - ---- - -### `frontend/components/pop/dashboard/index.ts` - -```typescript -export { PopDashboard } from "./PopDashboard"; -export { DashboardHeader } from "./DashboardHeader"; -export { DashboardFooter } from "./DashboardFooter"; -export { MenuGrid } from "./MenuGrid"; -export { KpiBar } from "./KpiBar"; -export { NoticeBanner } from "./NoticeBanner"; -export { NoticeList } from "./NoticeList"; -export { ActivityList } from "./ActivityList"; -``` - ---- - -### `frontend/components/pop/dashboard/types.ts` - -대시보드 관련 타입 정의 - ---- - -### `frontend/components/pop/dashboard/data.ts` - -대시보드 샘플/목업 데이터 - ---- - -### `frontend/components/pop/dashboard/dashboard.css` - -대시보드 전용 스타일 - ---- - -## 8. Library 파일 +## 9. Library 파일 ### `frontend/lib/api/popScreenGroup.ts` | 항목 | 내용 | |------|------| | 역할 | POP 화면 그룹 API 클라이언트 | -| 라인 수 | 183줄 | - -**타입**: - -```typescript -interface PopScreenGroup extends ScreenGroup { - children?: PopScreenGroup[]; -} - -interface CreatePopScreenGroupRequest { - group_name: string; - group_code: string; - description?: string; - icon?: string; - display_order?: number; - parent_group_id?: number | null; - target_company_code?: string; -} - -interface UpdatePopScreenGroupRequest { - group_name?: string; - description?: string; - icon?: string; - display_order?: number; - is_active?: boolean; -} -``` **API 함수**: @@ -757,12 +491,6 @@ async function deletePopScreenGroup(id: number): Promise<...> async function ensurePopRootGroup(): Promise<...> ``` -**유틸리티**: - -```typescript -function buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[] -``` - --- ### `frontend/lib/registry/PopComponentRegistry.ts` @@ -770,56 +498,6 @@ function buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[] | 항목 | 내용 | |------|------| | 역할 | POP 컴포넌트 중앙 레지스트리 | -| 라인 수 | 268줄 | - -**타입**: - -```typescript -interface PopComponentDefinition { - id: string; - name: string; - description: string; - category: PopComponentCategory; - icon?: string; - component: React.ComponentType; - configPanel?: React.ComponentType; - defaultProps?: Record; - touchOptimized?: boolean; - minTouchArea?: number; - supportedDevices?: ("mobile" | "tablet")[]; - createdAt?: Date; - updatedAt?: Date; -} - -type PopComponentCategory = - | "display" - | "input" - | "action" - | "layout" - | "feedback"; -``` - -**메서드**: - -```typescript -class PopComponentRegistry { - static registerComponent(definition: PopComponentDefinition): void - static unregisterComponent(id: string): void - static getComponent(id: string): PopComponentDefinition | undefined - static getComponentByUrl(url: string): PopComponentDefinition | undefined - static getAllComponents(): PopComponentDefinition[] - static getComponentsByCategory(category: PopComponentCategory): PopComponentDefinition[] - static getComponentsByDevice(device: "mobile" | "tablet"): PopComponentDefinition[] - static searchComponents(query: string): PopComponentDefinition[] - static getComponentCount(): number - static getStatsByCategory(): Record - static addEventListener(callback: (event: PopComponentRegistryEvent) => void): void - static removeEventListener(callback: (event: PopComponentRegistryEvent) => void): void - static clear(): void - static hasComponent(id: string): boolean - static debug(): void -} -``` --- @@ -828,39 +506,11 @@ class PopComponentRegistry { | 항목 | 내용 | |------|------| | 역할 | POP 컴포넌트 설정 스키마 | -| 라인 수 | 232줄 | | 검증 | Zod 기반 | -**기본값**: - -```typescript -const popCardListDefaults = { ... } -const popTouchButtonDefaults = { ... } -const popScannerInputDefaults = { ... } -const popStatusBadgeDefaults = { ... } -``` - -**스키마**: - -```typescript -const popCardListOverridesSchema = z.object({ ... }) -const popTouchButtonOverridesSchema = z.object({ ... }) -const popScannerInputOverridesSchema = z.object({ ... }) -const popStatusBadgeOverridesSchema = z.object({ ... }) -``` - -**유틸리티**: - -```typescript -function getPopComponentUrl(componentType: string): string -function getPopComponentDefaults(componentType: string): Record -function getPopDefaultsByUrl(componentUrl: string): Record -function parsePopOverridesByUrl(componentUrl: string, overrides: Record): Record -``` - --- -## 9. 루트 컴포넌트 파일 +## 10. 루트 컴포넌트 파일 ### `frontend/components/pop/index.ts` @@ -873,24 +523,6 @@ export * from "./dashboard"; --- -### `frontend/components/pop/types.ts` - -POP 공통 타입 정의 - ---- - -### `frontend/components/pop/data.ts` - -POP 샘플/목업 데이터 - ---- - -### `frontend/components/pop/styles.css` - -POP 전역 스타일 - ---- - ### 기타 루트 레벨 컴포넌트 | 파일 | 역할 | @@ -912,14 +544,28 @@ POP 전역 스타일 | 폴더 | 파일 수 | 설명 | |------|---------|------| -| `app/(pop)` | 6 | App Router 페이지 | -| `components/pop/designer` | 12 | 디자이너 모듈 | +| `app/(pop)` | 4 | App Router 페이지 | +| `components/pop/designer` | 9 | 디자이너 모듈 (v5) | | `components/pop/management` | 5 | 관리 모듈 | | `components/pop/dashboard` | 12 | 대시보드 모듈 | | `components/pop` (루트) | 15 | 루트 컴포넌트 | | `lib` | 3 | 라이브러리 | -| **총계** | **53** | | +| **총계** | **48** | | --- -*이 문서는 POP 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다.* +## 삭제된 파일 (v5 통합으로 제거) + +| 파일 | 이유 | +|------|------| +| `PopCanvasV4.tsx` | v4 Flexbox 캔버스 | +| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 | +| `PopLayoutRenderer.tsx` | v3 CSS Grid 렌더러 | +| `ComponentRenderer.tsx` | 레거시 컴포넌트 렌더러 | +| `ComponentEditorPanelV4.tsx` | v4 편집 패널 | +| `PopPanel.tsx` | 레거시 팔레트 패널 | +| `test-v4/page.tsx` | v4 테스트 페이지 | + +--- + +*이 문서는 POP 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다. (v5 그리드 시스템 기준)* diff --git a/popdocs/GRID_CODING_PLAN.md b/popdocs/GRID_CODING_PLAN.md new file mode 100644 index 00000000..60760e96 --- /dev/null +++ b/popdocs/GRID_CODING_PLAN.md @@ -0,0 +1,763 @@ +# POP 그리드 시스템 코딩 계획 + +> 작성일: 2026-02-05 +> 상태: 코딩 준비 완료 + +--- + +## 작업 목록 + +``` +Phase 5.1: 타입 정의 ───────────────────────────── + [ ] 1. v5 타입 정의 (PopLayoutDataV5, PopGridConfig 등) + [ ] 2. 브레이크포인트 상수 정의 + [ ] 3. v5 생성/변환 함수 + +Phase 5.2: 그리드 렌더러 ───────────────────────── + [ ] 4. PopGridRenderer.tsx 생성 + [ ] 5. 위치 변환 로직 (12칸→4칸) + +Phase 5.3: 디자이너 UI ─────────────────────────── + [ ] 6. PopCanvasV5.tsx 생성 + [ ] 7. 드래그 스냅 기능 + [ ] 8. ComponentEditorPanelV5.tsx + +Phase 5.4: 통합 ────────────────────────────────── + [ ] 9. 자동 변환 알고리즘 + [ ] 10. PopDesigner.tsx 통합 +``` + +--- + +## Phase 5.1: 타입 정의 + +### 작업 1: v5 타입 정의 + +**파일**: `frontend/components/pop/designer/types/pop-layout.ts` + +**추가할 코드**: + +```typescript +// ======================================== +// v5.0 그리드 기반 레이아웃 +// ======================================== +// 핵심: CSS Grid로 정확한 위치 지정 +// - 열/행 좌표로 배치 (col, row) +// - 칸 단위 크기 (colSpan, rowSpan) + +/** + * 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 +} + +/** + * 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; +} + +/** + * 그리드 위치 + */ +export interface PopGridPosition { + col: number; // 시작 열 (1부터, 최대 12) + row: number; // 시작 행 (1부터) + colSpan: number; // 차지할 열 수 (1~12) + rowSpan: number; // 차지할 행 수 (1~) +} + +/** + * v5 전역 설정 + */ +export interface PopGlobalSettingsV5 { + // 터치 최소 크기 (px) + touchTargetMin: number; // 기본 48 + + // 모드 + mode: "normal" | "industrial"; +} + +/** + * v5 모드별 오버라이드 + */ +export interface PopModeOverrideV5 { + // 컴포넌트별 위치 오버라이드 + positions?: Record>; + + // 컴포넌트별 숨김 + hidden?: string[]; +} +``` + +### 작업 2: 브레이크포인트 상수 + +**파일**: `frontend/components/pop/designer/types/pop-layout.ts` + +```typescript +// ======================================== +// 그리드 브레이크포인트 +// ======================================== + +export type GridMode = + | "mobile_portrait" + | "mobile_landscape" + | "tablet_portrait" + | "tablet_landscape"; + +export const GRID_BREAKPOINTS: Record = { + // 4~6인치 모바일 세로 + mobile_portrait: { + maxWidth: 599, + columns: 4, + rowHeight: 40, + gap: 8, + padding: 12, + label: "모바일 세로 (4칸)", + }, + + // 6~8인치 모바일 가로 / 작은 태블릿 + mobile_landscape: { + minWidth: 600, + maxWidth: 839, + columns: 6, + rowHeight: 44, + gap: 8, + padding: 16, + label: "모바일 가로 (6칸)", + }, + + // 8~10인치 태블릿 세로 + tablet_portrait: { + minWidth: 840, + maxWidth: 1023, + columns: 8, + rowHeight: 48, + gap: 12, + padding: 16, + label: "태블릿 세로 (8칸)", + }, + + // 10~14인치 태블릿 가로 (기본) + tablet_landscape: { + minWidth: 1024, + columns: 12, + rowHeight: 48, + gap: 16, + padding: 24, + label: "태블릿 가로 (12칸)", + }, +}; + +// 기본 모드 +export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape"; + +// 뷰포트 너비로 모드 감지 +export function detectGridMode(viewportWidth: number): GridMode { + if (viewportWidth < 600) return "mobile_portrait"; + if (viewportWidth < 840) return "mobile_landscape"; + if (viewportWidth < 1024) return "tablet_portrait"; + return "tablet_landscape"; +} +``` + +### 작업 3: v5 생성/변환 함수 + +**파일**: `frontend/components/pop/designer/types/pop-layout.ts` + +```typescript +// ======================================== +// 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", + }, +}); + +/** + * v5 레이아웃 여부 확인 + */ +export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { + return layout?.version === "pop-5.0"; +}; + +/** + * v5 컴포넌트 정의 생성 + */ +export const createComponentDefinitionV5 = ( + id: string, + type: PopComponentType, + position: PopGridPosition, + label?: string +): PopComponentDefinitionV5 => ({ + id, + type, + label, + position, +}); + +/** + * 컴포넌트 타입별 기본 크기 (칸 단위) + */ +export const DEFAULT_COMPONENT_SIZE: Record = { + "pop-field": { colSpan: 3, rowSpan: 1 }, + "pop-button": { colSpan: 2, rowSpan: 1 }, + "pop-list": { colSpan: 12, rowSpan: 4 }, + "pop-indicator": { colSpan: 3, rowSpan: 2 }, + "pop-scanner": { colSpan: 6, rowSpan: 2 }, + "pop-numpad": { colSpan: 4, rowSpan: 5 }, + "pop-spacer": { colSpan: 1, rowSpan: 1 }, + "pop-break": { colSpan: 12, rowSpan: 0 }, +}; + +/** + * v4 → v5 마이그레이션 + */ +export const migrateV4ToV5 = (layoutV4: PopLayoutDataV4): PopLayoutDataV5 => { + const componentsV4 = Object.values(layoutV4.components); + const componentsV5: Record = {}; + + // Flexbox 순서 → Grid 위치 변환 + let currentRow = 1; + let currentCol = 1; + const columns = 12; + + componentsV4.forEach((comp) => { + // 픽셀 → 칸 변환 (대략적) + const colSpan = comp.size.width === "fill" + ? columns + : Math.max(1, Math.min(12, Math.round((comp.size.fixedWidth || 100) / 85))); + const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48)); + + // 줄바꿈 체크 + if (currentCol + colSpan - 1 > columns) { + currentRow += 1; + currentCol = 1; + } + + componentsV5[comp.id] = { + id: comp.id, + type: comp.type, + label: comp.label, + position: { + col: currentCol, + row: currentRow, + colSpan, + rowSpan, + }, + visibility: comp.visibility, + dataBinding: comp.dataBinding, + config: comp.config, + }; + + currentCol += colSpan; + }); + + return { + version: "pop-5.0", + gridConfig: { + rowHeight: 48, + gap: layoutV4.settings.defaultGap, + padding: layoutV4.settings.defaultPadding, + }, + components: componentsV5, + dataFlow: layoutV4.dataFlow, + settings: { + touchTargetMin: layoutV4.settings.touchTargetMin, + mode: layoutV4.settings.mode, + }, + }; +}; +``` + +--- + +## Phase 5.2: 그리드 렌더러 + +### 작업 4: PopGridRenderer.tsx + +**파일**: `frontend/components/pop/designer/renderers/PopGridRenderer.tsx` + +```typescript +"use client"; + +import React, { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { + PopLayoutDataV5, + PopComponentDefinitionV5, + PopGridPosition, + GridMode, + GRID_BREAKPOINTS, + detectGridMode, +} from "../types/pop-layout"; + +interface PopGridRendererProps { + layout: PopLayoutDataV5; + viewportWidth: number; + currentMode?: GridMode; + isDesignMode?: boolean; + selectedComponentId?: string | null; + onComponentClick?: (componentId: string) => void; + onBackgroundClick?: () => void; + className?: string; +} + +export function PopGridRenderer({ + layout, + viewportWidth, + currentMode, + isDesignMode = false, + selectedComponentId, + onComponentClick, + onBackgroundClick, + className, +}: PopGridRendererProps) { + const { gridConfig, components, overrides } = layout; + + // 현재 모드 (자동 감지 또는 지정) + const mode = currentMode || detectGridMode(viewportWidth); + const breakpoint = GRID_BREAKPOINTS[mode]; + + // CSS Grid 스타일 + const gridStyle = useMemo((): React.CSSProperties => ({ + display: "grid", + gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, + gridAutoRows: `${breakpoint.rowHeight}px`, + gap: `${breakpoint.gap}px`, + padding: `${breakpoint.padding}px`, + minHeight: "100%", + }), [breakpoint]); + + // visibility 체크 + const isVisible = (comp: PopComponentDefinitionV5): boolean => { + if (!comp.visibility) return true; + return comp.visibility[mode] !== false; + }; + + // 위치 변환 (12칸 기준 → 현재 모드 칸 수) + const convertPosition = (position: PopGridPosition): React.CSSProperties => { + const sourceColumns = 12; // 항상 12칸 기준으로 저장 + const targetColumns = breakpoint.columns; + + if (sourceColumns === targetColumns) { + return { + gridColumn: `${position.col} / span ${position.colSpan}`, + gridRow: `${position.row} / span ${position.rowSpan}`, + }; + } + + // 비율 계산 + 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 + newColSpan - 1 > targetColumns) { + newColSpan = targetColumns - newCol + 1; + } + + return { + gridColumn: `${newCol} / span ${Math.max(1, newColSpan)}`, + gridRow: `${position.row} / span ${position.rowSpan}`, + }; + }; + + // 오버라이드 적용 + const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => { + const override = overrides?.[mode]?.positions?.[comp.id]; + if (override) { + return { ...comp.position, ...override }; + } + return comp.position; + }; + + return ( +
{ + if (e.target === e.currentTarget) { + onBackgroundClick?.(); + } + }} + > + {Object.values(components).map((comp) => { + if (!isVisible(comp)) return null; + + const position = getEffectivePosition(comp); + const positionStyle = convertPosition(position); + + return ( +
{ + e.stopPropagation(); + onComponentClick?.(comp.id); + }} + > + {/* 컴포넌트 내용 */} + +
+ ); + })} +
+ ); +} + +// 컴포넌트 내용 렌더링 +function ComponentContent({ + component, + isDesignMode +}: { + component: PopComponentDefinitionV5; + isDesignMode: boolean; +}) { + const typeLabels: Record = { + "pop-field": "필드", + "pop-button": "버튼", + "pop-list": "리스트", + "pop-indicator": "인디케이터", + "pop-scanner": "스캐너", + "pop-numpad": "숫자패드", + "pop-spacer": "스페이서", + "pop-break": "줄바꿈", + }; + + if (isDesignMode) { + return ( +
+
+ + {component.label || typeLabels[component.type] || component.type} + +
+
+ + {typeLabels[component.type]} + +
+
+ ); + } + + // 실제 컴포넌트 렌더링 (Phase 4에서 구현) + return ( +
+ + {component.label || typeLabels[component.type]} + +
+ ); +} + +export default PopGridRenderer; +``` + +### 작업 5: 위치 변환 유틸리티 + +**파일**: `frontend/components/pop/designer/utils/gridUtils.ts` + +```typescript +import { PopGridPosition, GridMode, GRID_BREAKPOINTS } from "../types/pop-layout"; + +/** + * 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, + }; +} + +/** + * 두 위치가 겹치는지 확인 + */ +export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean { + // 열 겹침 + const colOverlap = !(a.col + a.colSpan - 1 < b.col || b.col + b.colSpan - 1 < a.col); + // 행 겹침 + const rowOverlap = !(a.row + a.rowSpan - 1 < b.row || b.row + b.rowSpan - 1 < 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; + + // 기존 배치와 겹치면 아래로 이동 + let attempts = 0; + while (attempts < 100) { + 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; +} + +/** + * 마우스 좌표 → 그리드 좌표 변환 + */ +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; + + // 칸 너비 계산 + const totalGap = gap * (columns - 1); + const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns; + + // 그리드 좌표 계산 (1부터 시작) + const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1)); + const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1); + + return { col, row }; +} + +/** + * 그리드 좌표 → 픽셀 좌표 변환 + */ +export function gridToPixelPosition( + col: number, + row: 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, + height: rowHeight, + }; +} +``` + +--- + +## Phase 5.3: 디자이너 UI + +### 작업 6-7: PopCanvasV5.tsx + +**파일**: `frontend/components/pop/designer/PopCanvasV5.tsx` + +핵심 기능: +- 그리드 배경 표시 (바둑판) +- 4개 모드 프리셋 버튼 +- 드래그 앤 드롭 (칸에 스냅) +- 컴포넌트 리사이즈 (칸 단위) + +### 작업 8: ComponentEditorPanelV5.tsx + +**파일**: `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx` + +핵심 기능: +- 위치 편집 (col, row 입력) +- 크기 편집 (colSpan, rowSpan 입력) +- visibility 체크박스 + +--- + +## Phase 5.4: 통합 + +### 작업 9: 자동 변환 알고리즘 + +이미 `gridUtils.ts`에 포함 + +### 작업 10: PopDesigner.tsx 통합 + +**수정 파일**: `frontend/components/pop/designer/PopDesigner.tsx` + +변경 사항: +- v5 레이아웃 상태 추가 +- v3/v4/v5 자동 판별 +- 새 화면 → v5로 시작 +- v4 → v5 마이그레이션 옵션 + +--- + +## 파일 목록 + +| 상태 | 파일 | 작업 | +|------|------|------| +| 수정 | `types/pop-layout.ts` | v5 타입, 상수, 함수 추가 | +| 생성 | `renderers/PopGridRenderer.tsx` | 그리드 렌더러 | +| 생성 | `utils/gridUtils.ts` | 유틸리티 함수 | +| 생성 | `PopCanvasV5.tsx` | 그리드 캔버스 | +| 생성 | `panels/ComponentEditorPanelV5.tsx` | 속성 패널 | +| 수정 | `PopDesigner.tsx` | v5 통합 | + +--- + +## 시작 순서 + +``` +1. pop-layout.ts에 v5 타입 추가 (작업 1-3) + ↓ +2. PopGridRenderer.tsx 생성 (작업 4) + ↓ +3. gridUtils.ts 생성 (작업 5) + ↓ +4. PopCanvasV5.tsx 생성 (작업 6-7) + ↓ +5. ComponentEditorPanelV5.tsx 생성 (작업 8) + ↓ +6. PopDesigner.tsx 수정 (작업 9-10) + ↓ +7. 테스트 +``` + +--- + +*다음 단계: Phase 5.1 작업 1 시작 (v5 타입 정의)* diff --git a/popdocs/GRID_SYSTEM_DESIGN.md b/popdocs/GRID_SYSTEM_DESIGN.md new file mode 100644 index 00000000..7203763c --- /dev/null +++ b/popdocs/GRID_SYSTEM_DESIGN.md @@ -0,0 +1,329 @@ +# POP 화면 그리드 시스템 설계 + +> 작성일: 2026-02-05 +> 상태: 계획 (Plan) +> 관련: Softr, Ant Design, Material Design 분석 기반 + +--- + +## 1. 목적 + +POP 화면의 반응형 레이아웃을 **일관성 있고 예측 가능하게** 만들기 위한 그리드 시스템 설계 + +### 현재 문제 +- 픽셀 단위 자유 배치 → 화면 크기별로 깨짐 +- 컴포넌트 크기 규칙 없음 → 디자인 불일치 +- 반응형 규칙 부족 → 모드별 수동 조정 필요 + +### 목표 +- 그리드 기반 배치로 일관성 확보 +- 크기 프리셋으로 디자인 통일 +- 자동 반응형으로 작업량 감소 + +--- + +## 2. 대상 디바이스 + +### 지원 범위 + +| 구분 | 크기 범위 | 기준 해상도 | 비고 | +|------|----------|-------------|------| +| 모바일 | 4~8인치 | 375x667 (세로) | 산업용 PDA 포함 | +| 태블릿 | 8~14인치 | 1024x768 (가로) | 기본 기준 | + +### 참고: 산업용 디바이스 해상도 + +| 디바이스 | 화면 크기 | 해상도 | +|----------|----------|--------| +| Zebra TC57 PDA | 5인치 | 720x1280 | +| Honeywell CT47 | 5.5인치 | 2160x1080 | +| Honeywell RT10A | 10.1인치 | 1920x1200 | + +--- + +## 3. 그리드 시스템 설계 + +### 3.1 브레이크포인트 (Breakpoints) + +Material Design 가이드라인 기반으로 4단계 정의: + +| 모드 | 약어 | 너비 범위 | 대표 디바이스 | 그리드 칸 수 | +|------|------|----------|---------------|-------------| +| 모바일 세로 | `mp` | ~599px | 4~6인치 폰 | **4 columns** | +| 모바일 가로 | `ml` | 600~839px | 폰 가로, 7인치 태블릿 | **6 columns** | +| 태블릿 세로 | `tp` | 840~1023px | 8~10인치 태블릿 세로 | **8 columns** | +| 태블릿 가로 | `tl` | 1024px~ | 10~14인치 태블릿 가로 | **12 columns** | + +### 3.2 기준 해상도 + +| 모드 | 기준 너비 | 기준 높이 | 비고 | +|------|----------|----------|------| +| 모바일 세로 | 375px | 667px | iPhone SE 기준 | +| 모바일 가로 | 667px | 375px | - | +| 태블릿 세로 | 768px | 1024px | iPad 기준 | +| **태블릿 가로** | **1024px** | **768px** | **기본 설계 모드** | + +### 3.3 그리드 구조 + +``` +태블릿 가로 (12 columns) +┌──────────────────────────────────────────────────────────────┐ +│ ← 16px →│ Col │ 16px │ Col │ 16px │ ... │ Col │← 16px →│ +│ margin │ 1 │ gap │ 2 │ gap │ │ 12 │ margin │ +└──────────────────────────────────────────────────────────────┘ + +모바일 세로 (4 columns) +┌────────────────────────┐ +│← 16px →│ Col │ 8px │ Col │ 8px │ Col │ 8px │ Col │← 16px →│ +│ margin │ 1 │ gap │ 2 │ gap │ 3 │ gap │ 4 │ margin │ +└────────────────────────┘ +``` + +### 3.4 마진/간격 규칙 + +8px 기반 간격 시스템 (Material Design 표준): + +| 속성 | 태블릿 | 모바일 | 용도 | +|------|--------|--------|------| +| screenPadding | 24px | 16px | 화면 가장자리 여백 | +| gapSm | 8px | 8px | 컴포넌트 사이 최소 간격 | +| gapMd | 16px | 12px | 기본 간격 | +| gapLg | 24px | 16px | 섹션 간 간격 | +| rowGap | 16px | 12px | 줄 사이 간격 | + +--- + +## 4. 컴포넌트 크기 시스템 + +### 4.1 열 단위 (Span) 크기 + +픽셀 대신 **열 단위(span)** 로 크기 지정: + +| 크기 | 태블릿 가로 (12col) | 태블릿 세로 (8col) | 모바일 (4col) | +|------|--------------------|--------------------|---------------| +| XS | 1 span | 1 span | 1 span | +| S | 2 span | 2 span | 2 span | +| M | 3 span | 2 span | 2 span | +| L | 4 span | 4 span | 4 span (full) | +| XL | 6 span | 4 span | 4 span (full) | +| Full | 12 span | 8 span | 4 span | + +### 4.2 높이 프리셋 + +| 프리셋 | 픽셀값 | 용도 | +|--------|--------|------| +| `xs` | 32px | 배지, 아이콘 버튼 | +| `sm` | 48px | 일반 버튼, 입력 필드 | +| `md` | 80px | 카드, 인디케이터 | +| `lg` | 120px | 큰 카드, 리스트 아이템 | +| `xl` | 200px | 대형 영역 | +| `auto` | 내용 기반 | 가변 높이 | + +### 4.3 컴포넌트별 기본값 + +| 컴포넌트 | 태블릿 span | 모바일 span | 높이 | 비고 | +|----------|------------|-------------|------|------| +| pop-field | 3 (M) | 2 (S) | sm | 입력/표시 | +| pop-button | 2 (S) | 2 (S) | sm | 액션 버튼 | +| pop-list | 12 (Full) | 4 (Full) | auto | 데이터 목록 | +| pop-indicator | 3 (M) | 2 (S) | md | KPI 표시 | +| pop-scanner | 6 (XL) | 4 (Full) | lg | 스캔 영역 | +| pop-numpad | 6 (XL) | 4 (Full) | auto | 숫자 패드 | +| pop-spacer | 1 (XS) | 1 (XS) | - | 빈 공간 | +| pop-break | Full | Full | 0 | 줄바꿈 | + +--- + +## 5. 반응형 규칙 + +### 5.1 자동 조정 + +설계자가 별도 설정하지 않아도 자동 적용: + +``` +태블릿 가로 (12col): [A:3] [B:3] [C:3] [D:3] → 한 줄 +태블릿 세로 (8col): [A:2] [B:2] [C:2] [D:2] → 한 줄 +모바일 (4col): [A:2] [B:2] → 두 줄 + [C:2] [D:2] +``` + +### 5.2 수동 오버라이드 + +필요시 모드별 설정 가능: + +```typescript +interface ResponsiveOverride { + // 크기 변경 + span?: number; + height?: HeightPreset; + + // 표시/숨김 + hidden?: boolean; + + // 내부 요소 숨김 (컴포넌트별) + hideElements?: string[]; +} +``` + +### 5.3 표시/숨김 예시 + +``` +태블릿: [제품명] [수량] [단가] [합계] [비고] +모바일: [제품명] [수량] [비고] ← 단가, 합계 숨김 +``` + +설정: +```typescript +{ + id: "unit-price", + type: "pop-field", + visibility: { + mobile_portrait: false, + mobile_landscape: false + } +} +``` + +--- + +## 6. 데이터 구조 (제안) + +### 6.1 레이아웃 데이터 (v5 제안) + +```typescript +interface PopLayoutDataV5 { + version: "5.0"; + + // 그리드 설정 (전역) + gridConfig: { + tablet: { columns: 12; gap: 16; padding: 24 }; + mobile: { columns: 4; gap: 8; padding: 16 }; + }; + + // 컴포넌트 목록 (순서대로) + components: PopComponentV5[]; + + // 모드별 오버라이드 (선택) + modeOverrides?: { + [mode: string]: { + gridConfig?: Partial; + componentOverrides?: Record; + }; + }; +} +``` + +### 6.2 컴포넌트 데이터 + +```typescript +interface PopComponentV5 { + id: string; + type: PopComponentType; + + // 크기 (span 단위) + size: { + span: number; // 기본 열 개수 (1~12) + height: HeightPreset; // xs, sm, md, lg, xl, auto + }; + + // 반응형 크기 (선택) + responsiveSize?: { + mobile?: { span?: number; height?: HeightPreset }; + tablet_portrait?: { span?: number; height?: HeightPreset }; + }; + + // 표시/숨김 + visibility?: { + [mode: string]: boolean; + }; + + // 컴포넌트별 설정 + config?: any; + + // 데이터 바인딩 + dataBinding?: any; +} +``` + +--- + +## 7. 현재 v4와의 관계 + +### 7.1 v4 유지 사항 +- Flexbox 기반 렌더링 +- 오버라이드 시스템 +- visibility 속성 + +### 7.2 변경 사항 + +| v4 | v5 (제안) | +|----|-----------| +| `fixedWidth: number` | `span: 1~12` | +| `minWidth`, `maxWidth` | 그리드 기반 자동 계산 | +| 자유 픽셀 | 열 단위 프리셋 | + +### 7.3 마이그레이션 방향 + +``` +v4 fixedWidth: 200px +↓ +v5 span: 3 (태블릿 기준 약 25%) +``` + +--- + +## 8. 구현 우선순위 + +### Phase 1: 프리셋만 적용 (최소 변경) +- [ ] 높이 프리셋 드롭다운 +- [ ] 너비 프리셋 드롭다운 (XS~Full) +- [ ] 기존 Flexbox 렌더링 유지 + +### Phase 2: 그리드 시스템 도입 +- [ ] 브레이크포인트 감지 +- [ ] 그리드 칸 수 자동 변경 +- [ ] span → 픽셀 자동 계산 + +### Phase 3: 반응형 자동화 +- [ ] 모드별 자동 span 변환 +- [ ] 줄바꿈 자동 처리 +- [ ] 오버라이드 최소화 + +--- + +## 9. 참고 자료 + +### 분석 대상 + +| 도구 | 핵심 특징 | 적용 가능 요소 | +|------|----------|---------------| +| **Softr** | 블록 기반, 제약 기반 레이아웃 | 컨테이너 슬롯 방식 | +| **Ant Design** | 24열 그리드, 8px 간격 | 그리드 시스템, 간격 규칙 | +| **Material Design** | 4/8/12열, 반응형 브레이크포인트 | 디바이스별 칸 수 | + +### 핵심 원칙 + +1. **Flexbox는 도구**: 그리드 시스템 안에서 사용 +2. **제약은 자유**: 규칙이 있어야 일관된 디자인 가능 +3. **최소 설정, 최대 효과**: 기본값이 좋으면 오버라이드 불필요 + +--- + +## 10. FAQ + +### Q1: 기존 v4 화면은 어떻게 되나요? +A: 하위 호환 유지. v4 화면은 v4로 계속 동작. + +### Q2: 컴포넌트를 그리드 칸 사이에 배치할 수 있나요? +A: 아니요. 칸 단위로만 배치. 이게 일관성의 핵심. + +### Q3: 그리드 칸 수를 바꿀 수 있나요? +A: 기본값(4/6/8/12) 권장. 필요시 프로젝트 레벨 설정 가능. + +### Q4: Flexbox와 Grid 중 뭘 쓰나요? +A: 둘 다. Grid로 칸 나누고, Flexbox로 칸 안에서 정렬. + +--- + +*이 문서는 계획 단계이며, 실제 구현 시 수정될 수 있습니다.* +*최종 업데이트: 2026-02-05* diff --git a/popdocs/GRID_SYSTEM_PLAN.md b/popdocs/GRID_SYSTEM_PLAN.md new file mode 100644 index 00000000..5ee366b5 --- /dev/null +++ b/popdocs/GRID_SYSTEM_PLAN.md @@ -0,0 +1,480 @@ +# POP 그리드 시스템 도입 계획 + +> 작성일: 2026-02-05 +> 상태: 계획 승인, 구현 대기 + +--- + +## 개요 + +### 목표 +현재 Flexbox 기반 v4 시스템을 **CSS Grid 기반 v5 시스템**으로 전환하여 +4~14인치 화면에서 일관된 배치와 예측 가능한 반응형 레이아웃 구현 + +### 핵심 변경점 + +| 항목 | v4 (현재) | v5 (그리드) | +|------|----------|-------------| +| 배치 방식 | Flexbox 흐름 | **Grid 위치 지정** | +| 크기 단위 | 픽셀 (200px) | **칸 (col, row)** | +| 위치 지정 | 순서대로 자동 | **열/행 좌표** | +| 줄바꿈 | 자동 (넘치면) | **명시적 (row 지정)** | + +--- + +## Phase 구조 + +``` +[Phase 5.1] [Phase 5.2] [Phase 5.3] [Phase 5.4] +그리드 타입 정의 → 그리드 렌더러 → 디자이너 UI → 반응형 자동화 + 1주 1주 1~2주 1주 +``` + +--- + +## Phase 5.1: 그리드 타입 정의 + +### 목표 +v5 레이아웃 데이터 구조 설계 + +### 작업 항목 + +- [ ] `PopLayoutDataV5` 인터페이스 정의 +- [ ] `PopGridConfig` 인터페이스 (그리드 설정) +- [ ] `PopComponentPositionV5` 인터페이스 (위치: col, row, colSpan, rowSpan) +- [ ] `PopSizeConstraintV5` 인터페이스 (칸 기반 크기) +- [ ] 브레이크포인트 상수 정의 +- [ ] `createEmptyPopLayoutV5()` 생성 함수 +- [ ] `isV5Layout()` 타입 가드 + +### 데이터 구조 설계 + +```typescript +// v5 레이아웃 +interface PopLayoutDataV5 { + version: "pop-5.0"; + + // 그리드 설정 + gridConfig: PopGridConfig; + + // 컴포넌트 목록 + components: Record; + + // 모드별 오버라이드 + overrides?: { + mobile_portrait?: PopModeOverrideV5; + mobile_landscape?: PopModeOverrideV5; + tablet_portrait?: PopModeOverrideV5; + }; + + // 기존 호환 + dataFlow: PopDataFlow; + settings: PopGlobalSettingsV5; +} + +// 그리드 설정 +interface PopGridConfig { + // 모드별 칸 수 + columns: { + tablet_landscape: 12; // 기본 (10~14인치) + tablet_portrait: 8; // 8~10인치 세로 + mobile_landscape: 6; // 6~8인치 가로 + mobile_portrait: 4; // 4~6인치 세로 + }; + + // 행 높이 (px) - 1행의 기본 높이 + rowHeight: number; // 기본 48px + + // 간격 + gap: number; // 기본 8px + padding: number; // 기본 16px +} + +// 컴포넌트 정의 +interface PopComponentDefinitionV5 { + id: string; + type: PopComponentType; + label?: string; + + // 위치 (열/행 좌표) - 기본 모드(태블릿 가로) 기준 + position: { + col: number; // 시작 열 (1부터) + row: number; // 시작 행 (1부터) + colSpan: number; // 차지할 열 수 (1~12) + rowSpan: number; // 차지할 행 수 (1~) + }; + + // 모드별 표시/숨김 + visibility?: { + tablet_landscape?: boolean; + tablet_portrait?: boolean; + mobile_landscape?: boolean; + mobile_portrait?: boolean; + }; + + // 기존 속성 + dataBinding?: PopDataBinding; + config?: PopComponentConfig; +} +``` + +### 브레이크포인트 정의 + +```typescript +// 브레이크포인트 상수 +const GRID_BREAKPOINTS = { + // 4~6인치 모바일 세로 + mobile_portrait: { + maxWidth: 599, + columns: 4, + rowHeight: 40, + gap: 8, + padding: 12, + }, + + // 6~8인치 모바일 가로 / 작은 태블릿 + mobile_landscape: { + minWidth: 600, + maxWidth: 839, + columns: 6, + rowHeight: 44, + gap: 8, + padding: 16, + }, + + // 8~10인치 태블릿 세로 + tablet_portrait: { + minWidth: 840, + maxWidth: 1023, + columns: 8, + rowHeight: 48, + gap: 12, + padding: 16, + }, + + // 10~14인치 태블릿 가로 (기본) + tablet_landscape: { + minWidth: 1024, + columns: 12, + rowHeight: 48, + gap: 16, + padding: 24, + }, +} as const; +``` + +### 산출물 +- `frontend/components/pop/designer/types/pop-layout-v5.ts` + +--- + +## Phase 5.2: 그리드 렌더러 + +### 목표 +CSS Grid 기반 렌더러 구현 + +### 작업 항목 + +- [ ] `PopGridRenderer.tsx` 생성 +- [ ] CSS Grid 스타일 계산 로직 +- [ ] 브레이크포인트 감지 및 칸 수 자동 변경 +- [ ] 컴포넌트 위치 렌더링 (grid-column, grid-row) +- [ ] 모드별 자동 위치 재계산 (12칸→4칸 변환) +- [ ] visibility 처리 +- [ ] 기존 PopFlexRenderer와 공존 + +### 렌더링 로직 + +```typescript +// CSS Grid 스타일 생성 +function calculateGridStyle(config: PopGridConfig, mode: string): React.CSSProperties { + const columns = config.columns[mode]; + const { rowHeight, gap, padding } = config; + + return { + display: 'grid', + gridTemplateColumns: `repeat(${columns}, 1fr)`, + gridAutoRows: `${rowHeight}px`, + gap: `${gap}px`, + padding: `${padding}px`, + }; +} + +// 컴포넌트 위치 스타일 +function calculatePositionStyle( + position: PopComponentPositionV5['position'], + sourceColumns: number, // 원본 모드 칸 수 (12) + targetColumns: number // 현재 모드 칸 수 (4) +): React.CSSProperties { + // 12칸 → 4칸 변환 예시 + // col: 7, colSpan: 3 → col: 3, colSpan: 1 + const ratio = targetColumns / sourceColumns; + const newCol = Math.max(1, Math.ceil(position.col * ratio)); + const newColSpan = Math.max(1, Math.round(position.colSpan * ratio)); + + return { + gridColumn: `${newCol} / span ${Math.min(newColSpan, targetColumns - newCol + 1)}`, + gridRow: `${position.row} / span ${position.rowSpan}`, + }; +} +``` + +### 산출물 +- `frontend/components/pop/designer/renderers/PopGridRenderer.tsx` + +--- + +## Phase 5.3: 디자이너 UI + +### 목표 +그리드 기반 편집 UI 구현 + +### 작업 항목 + +- [ ] `PopCanvasV5.tsx` 생성 (그리드 캔버스) +- [ ] 그리드 배경 표시 (바둑판 모양) +- [ ] 컴포넌트 드래그 배치 (칸에 스냅) +- [ ] 컴포넌트 리사이즈 (칸 단위) +- [ ] 위치 편집 패널 (col, row, colSpan, rowSpan) +- [ ] 모드 전환 시 그리드 칸 수 변경 표시 +- [ ] v4/v5 자동 판별 및 전환 + +### UI 구조 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ← 목록 화면명 *변경됨 [↶][↷] 그리드 레이아웃 (v5) [저장] │ +├─────────────────────────────────────────────────────────────────┤ +│ 미리보기: [모바일↕ 4칸] [모바일↔ 6칸] [태블릿↕ 8칸] [태블릿↔ 12칸] │ +├────────────┬────────────────────────────────────┬───────────────┤ +│ │ 1 2 3 4 5 6 ... 12 │ │ +│ 컴포넌트 │ ┌───────────┬───────────┐ │ 위치 │ +│ │1│ A │ B │ │ 열: [1-12] │ +│ 필드 │ ├───────────┴───────────┤ │ 행: [1-99] │ +│ 버튼 │2│ C │ │ 너비: [1-12]│ +│ 리스트 │ ├───────────┬───────────┤ │ 높이: [1-10]│ +│ 인디케이터 │3│ D │ E │ │ │ +│ ... │ └───────────┴───────────┘ │ 표시 설정 │ +│ │ │ [x] 태블릿↔ │ +│ │ (그리드 배경 표시) │ [x] 모바일↕ │ +└────────────┴────────────────────────────────────┴───────────────┘ +``` + +### 드래그 앤 드롭 로직 + +```typescript +// 마우스 위치 → 그리드 좌표 변환 +function mouseToGridPosition( + mouseX: number, + mouseY: number, + gridConfig: PopGridConfig, + canvasRect: DOMRect +): { col: number; row: number } { + const { columns, rowHeight, gap, padding } = gridConfig; + + // 캔버스 내 상대 위치 + const relX = mouseX - canvasRect.left - padding; + const relY = mouseY - canvasRect.top - padding; + + // 칸 너비 계산 + const totalGap = gap * (columns - 1); + const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns; + + // 그리드 좌표 계산 + const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1)); + const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1); + + return { col, row }; +} +``` + +### 산출물 +- `frontend/components/pop/designer/PopCanvasV5.tsx` +- `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx` + +--- + +## Phase 5.4: 반응형 자동화 + +### 목표 +모드 전환 시 자동 레이아웃 조정 + +### 작업 항목 + +- [ ] 12칸 → 4칸 자동 변환 알고리즘 +- [ ] 겹침 감지 및 자동 재배치 +- [ ] 모드별 오버라이드 저장 +- [ ] "자동 배치" vs "수동 고정" 선택 +- [ ] 변환 미리보기 + +### 자동 변환 알고리즘 + +```typescript +// 12칸 → 4칸 변환 전략 +function convertLayoutToMode( + components: PopComponentDefinitionV5[], + sourceMode: 'tablet_landscape', // 12칸 + targetMode: 'mobile_portrait' // 4칸 +): PopComponentDefinitionV5[] { + const sourceColumns = 12; + const targetColumns = 4; + const ratio = targetColumns / sourceColumns; // 0.333 + + // 1. 각 컴포넌트 위치 변환 + const converted = components.map(comp => { + const newCol = Math.max(1, Math.ceil(comp.position.col * ratio)); + const newColSpan = Math.max(1, Math.round(comp.position.colSpan * ratio)); + + return { + ...comp, + position: { + ...comp.position, + col: newCol, + colSpan: Math.min(newColSpan, targetColumns), + }, + }; + }); + + // 2. 겹침 감지 및 해결 + return resolveOverlaps(converted, targetColumns); +} + +// 겹침 해결 (아래로 밀기) +function resolveOverlaps( + components: PopComponentDefinitionV5[], + columns: number +): PopComponentDefinitionV5[] { + // 행 단위로 그리드 점유 상태 추적 + const grid: boolean[][] = []; + + // row 순서대로 처리 + const sorted = [...components].sort((a, b) => + a.position.row - b.position.row || a.position.col - b.position.col + ); + + return sorted.map(comp => { + let { row, col, colSpan, rowSpan } = comp.position; + + // 배치 가능한 위치 찾기 + while (isOccupied(grid, row, col, colSpan, rowSpan, columns)) { + row++; // 아래로 이동 + } + + // 그리드에 표시 + markOccupied(grid, row, col, colSpan, rowSpan); + + return { + ...comp, + position: { row, col, colSpan, rowSpan }, + }; + }); +} +``` + +### 산출물 +- `frontend/components/pop/designer/utils/gridLayoutUtils.ts` + +--- + +## 마이그레이션 전략 + +### v4 → v5 변환 + +```typescript +function migrateV4ToV5(layoutV4: PopLayoutDataV4): PopLayoutDataV5 { + const componentsList = Object.values(layoutV4.components); + + // Flexbox 순서 → Grid 위치 변환 + let currentRow = 1; + let currentCol = 1; + const columns = 12; + + const componentsV5: Record = {}; + + componentsList.forEach((comp, index) => { + // 기본 크기 추정 (픽셀 → 칸) + const colSpan = Math.max(1, Math.round((comp.size.fixedWidth || 100) / 85)); + const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48)); + + // 줄바꿈 체크 + if (currentCol + colSpan - 1 > columns) { + currentRow++; + currentCol = 1; + } + + componentsV5[comp.id] = { + ...comp, + position: { + col: currentCol, + row: currentRow, + colSpan, + rowSpan, + }, + }; + + currentCol += colSpan; + }); + + return { + version: "pop-5.0", + gridConfig: { /* 기본값 */ }, + components: componentsV5, + dataFlow: layoutV4.dataFlow, + settings: { /* 변환 */ }, + }; +} +``` + +### 하위 호환 + +| 버전 | 처리 방식 | +|------|----------| +| v1~v2 | v3로 변환 후 v5로 | +| v3 | v5로 직접 변환 | +| v4 | v5로 직접 변환 | +| v5 | 그대로 사용 | + +--- + +## 일정 (예상) + +| Phase | 작업 | 예상 기간 | +|-------|------|----------| +| 5.1 | 타입 정의 | 2~3일 | +| 5.2 | 그리드 렌더러 | 3~5일 | +| 5.3 | 디자이너 UI | 5~7일 | +| 5.4 | 반응형 자동화 | 3~5일 | +| - | 테스트 및 버그 수정 | 2~3일 | +| **총** | | **약 2~3주** | + +--- + +## 리스크 및 대응 + +| 리스크 | 영향 | 대응 | +|--------|------|------| +| 기존 v4 화면 깨짐 | 높음 | 하위 호환 유지, v4 렌더러 보존 | +| 자동 변환 품질 | 중간 | 수동 오버라이드로 보완 | +| 드래그 UX 복잡 | 중간 | 스냅 기능으로 편의성 확보 | +| 성능 저하 | 낮음 | CSS Grid는 네이티브 성능 | + +--- + +## 성공 기준 + +1. **배치 예측 가능**: "2열 3행"이라고 하면 정확히 그 위치에 표시 +2. **일관된 디자인**: 12칸 → 4칸 전환 시 비율 유지 +3. **쉬운 편집**: 드래그로 칸에 스냅되어 배치 +4. **하위 호환**: 기존 v4 화면이 정상 동작 + +--- + +## 관련 문서 + +- [GRID_SYSTEM_DESIGN.md](./GRID_SYSTEM_DESIGN.md) - 그리드 시스템 설계 상세 +- [PLAN.md](./PLAN.md) - 전체 POP 개발 계획 +- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - 현재 v4 스펙 + +--- + +*최종 업데이트: 2026-02-05* diff --git a/popdocs/INDEX.md b/popdocs/INDEX.md new file mode 100644 index 00000000..68d7de20 --- /dev/null +++ b/popdocs/INDEX.md @@ -0,0 +1,83 @@ +# 기능별 색인 + +> **용도**: "이 기능 어디있어?", "비슷한 기능 찾아줘" +> **검색 팁**: Ctrl+F로 기능명, 키워드 검색 + +--- + +## 렌더링 + +| 기능 | 파일 | 함수/컴포넌트 | 설명 | +|------|------|--------------|------| +| 그리드 렌더링 | PopRenderer.tsx | `PopRenderer` | CSS Grid 기반 v5 렌더링 | +| 위치 변환 | gridUtils.ts | `convertPositionToMode()` | 12칸 → 4/6/8칸 변환 | +| 모드 감지 | pop-layout.ts | `detectGridMode()` | 뷰포트 너비로 모드 판별 | +| 컴포넌트 스타일 | PopRenderer.tsx | `convertPosition()` | 그리드 좌표 → CSS | + +## 드래그 앤 드롭 + +| 기능 | 파일 | 함수/컴포넌트 | 설명 | +|------|------|--------------|------| +| 드롭 영역 | PopCanvas.tsx | `useDrop` | 캔버스에 컴포넌트 드롭 | +| 좌표 계산 | gridUtils.ts | `mouseToGridPosition()` | 마우스 → 그리드 좌표 | +| 빈 위치 찾기 | gridUtils.ts | `findNextEmptyPosition()` | 자동 배치 | +| DnD 타입 정의 | PopCanvas.tsx | `DND_ITEM_TYPES` | 인라인 정의 | + +## 편집 + +| 기능 | 파일 | 함수/컴포넌트 | 설명 | +|------|------|--------------|------| +| 위치 편집 | ComponentEditorPanel.tsx | position 탭 | col, row 수정 | +| 크기 편집 | ComponentEditorPanel.tsx | position 탭 | colSpan, rowSpan 수정 | +| 라벨 편집 | ComponentEditorPanel.tsx | settings 탭 | 컴포넌트 라벨 | +| 표시/숨김 | ComponentEditorPanel.tsx | visibility 탭 | 모드별 표시 | + +## 레이아웃 관리 + +| 기능 | 파일 | 함수/컴포넌트 | 설명 | +|------|------|--------------|------| +| 컴포넌트 추가 | pop-layout.ts | `addComponentToV5Layout()` | v5에 컴포넌트 추가 | +| 빈 레이아웃 | pop-layout.ts | `createEmptyPopLayoutV5()` | 초기 레이아웃 생성 | +| 타입 가드 | pop-layout.ts | `isV5Layout()` | v5 여부 확인 | + +## 상태 관리 + +| 기능 | 파일 | 함수/컴포넌트 | 설명 | +|------|------|--------------|------| +| 레이아웃 상태 | PopDesigner.tsx | `useState` | 메인 레이아웃 | +| 히스토리 | PopDesigner.tsx | `history[]`, `historyIndex` | Undo/Redo | +| 선택 상태 | PopDesigner.tsx | `selectedComponentId` | 현재 선택 | +| 모드 상태 | PopDesigner.tsx | `currentMode` | 그리드 모드 | + +## 저장/로드 + +| 기능 | 파일 | 함수/컴포넌트 | 설명 | +|------|------|--------------|------| +| 레이아웃 로드 | PopDesigner.tsx | `useEffect` | 화면 로드 시 | +| 레이아웃 저장 | PopDesigner.tsx | `handleSave()` | 저장 버튼 | +| API 호출 | screen.ts (lib/api) | `screenApi.saveLayoutPop()` | 백엔드 통신 | + +## 뷰포트/줌 + +| 기능 | 파일 | 함수/컴포넌트 | 설명 | +|------|------|--------------|------| +| 프리셋 전환 | PopCanvas.tsx | `VIEWPORT_PRESETS` | 4개 모드 | +| 줌 컨트롤 | PopCanvas.tsx | `canvasScale` | 30%~150% | +| 패닝 | PopCanvas.tsx | Space + 드래그 | 캔버스 이동 | + +--- + +## 파일별 주요 기능 + +| 파일 | 핵심 기능 | +|------|----------| +| PopDesigner.tsx | 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리 | +| PopCanvas.tsx | DnD, 줌, 패닝, 모드 전환, 그리드 표시 | +| PopRenderer.tsx | CSS Grid 렌더링, 위치 변환, 컴포넌트 표시 | +| ComponentEditorPanel.tsx | 속성 편집 (위치, 크기, 설정, 표시) | +| pop-layout.ts | 타입 정의, 유틸리티 함수, 상수 | +| gridUtils.ts | 좌표 계산, 겹침 감지, 자동 배치 | + +--- + +*새 기능 추가 시 해당 카테고리 테이블에 행 추가* diff --git a/popdocs/PLAN.md b/popdocs/PLAN.md index 976211b2..18911719 100644 --- a/popdocs/PLAN.md +++ b/popdocs/PLAN.md @@ -48,11 +48,20 @@ ## 작업 순서 ``` -[Phase 1] [Phase 2] [Phase 3] [Phase 4] -v4 기본 구조 → 오버라이드 기능 → 컴포넌트 숨김 → 순서 오버라이드 - 완료 다음 계획 계획 +[Phase 1~3] [Phase 4] [Phase 5] +v4 Flexbox → 실제 컴포넌트 → 그리드 시스템 (v5) + 완료 다음 계획 승인 ``` +### Phase 5: 그리드 시스템 (v5) - 신규 계획 + +``` +[Phase 5.1] [Phase 5.2] [Phase 5.3] [Phase 5.4] +그리드 타입 정의 → 그리드 렌더러 → 디자이너 UI → 반응형 자동화 +``` + +**상세 계획**: [GRID_SYSTEM_PLAN.md](./GRID_SYSTEM_PLAN.md) + --- ## Phase 1: 기본 구조 (완료) @@ -127,6 +136,58 @@ v4 기본 구조 → 오버라이드 기능 → 컴포넌트 숨김 → --- +## Phase 5: 그리드 시스템 (v5) - 계획 승인 + +> 상세 계획: [GRID_SYSTEM_PLAN.md](./GRID_SYSTEM_PLAN.md) + +### 개요 + +Flexbox 흐름 기반 → **CSS Grid 위치 지정** 방식으로 전환 + +| 항목 | v4 (현재) | v5 (그리드) | +|------|----------|-------------| +| 배치 | Flexbox 흐름 | Grid 좌표 (열/행) | +| 크기 | 픽셀 (200px) | 칸 (colSpan, rowSpan) | +| 줄바꿈 | 자동 | 명시적 | + +### Phase 5.1: 그리드 타입 정의 + +- [ ] `PopLayoutDataV5` 인터페이스 +- [ ] `PopGridConfig` (칸 수, 행 높이, 간격) +- [ ] `PopComponentPositionV5` (col, row, colSpan, rowSpan) +- [ ] 브레이크포인트 상수 (4칸/6칸/8칸/12칸) + +### Phase 5.2: 그리드 렌더러 + +- [ ] `PopGridRenderer.tsx` 생성 +- [ ] CSS Grid 스타일 계산 +- [ ] 브레이크포인트 감지 및 칸 수 변경 +- [ ] 위치 변환 (12칸 → 4칸) + +### Phase 5.3: 디자이너 UI + +- [ ] `PopCanvasV5.tsx` (그리드 캔버스) +- [ ] 바둑판 배경 표시 +- [ ] 드래그 스냅 (칸에 맞춤) +- [ ] 위치 편집 패널 + +### Phase 5.4: 반응형 자동화 + +- [ ] 자동 변환 알고리즘 (12칸 → 4칸) +- [ ] 겹침 감지 및 재배치 +- [ ] 모드별 오버라이드 + +### 브레이크포인트 + +| 모드 | 화면 범위 | 그리드 칸 수 | +|------|----------|-------------| +| 모바일 세로 | ~599px (4~6인치) | 4칸 | +| 모바일 가로 | 600~839px (6~8인치) | 6칸 | +| 태블릿 세로 | 840~1023px (8~10인치) | 8칸 | +| 태블릿 가로 | 1024px~ (10~14인치) | 12칸 | + +--- + ## 완료된 기능 목록 ### v4 타입 정의 @@ -241,4 +302,4 @@ scaledSize = originalSize * scale --- -*최종 업데이트: 2026-02-04* +*최종 업데이트: 2026-02-05 (Phase 5 그리드 시스템 계획 추가)* diff --git a/popdocs/PROBLEMS.md b/popdocs/PROBLEMS.md new file mode 100644 index 00000000..7491896e --- /dev/null +++ b/popdocs/PROBLEMS.md @@ -0,0 +1,62 @@ +# 문제-해결 색인 + +> **용도**: "이전에 비슷한 문제 어떻게 해결했어?" +> **검색 팁**: Ctrl+F로 키워드 검색 (에러 메시지, 컴포넌트명 등) + +--- + +## 렌더링 관련 + +| 문제 | 해결 | 날짜 | 키워드 | +|------|------|------|--------| +| rowSpan이 적용 안됨 | gridTemplateRows를 `1fr`로 변경 | 2026-02-02 | grid, rowSpan, CSS | +| 컴포넌트 크기 스케일 안됨 | viewportWidth 기반 scale 계산 추가 | 2026-02-04 | scale, viewport, 반응형 | + +## DnD (드래그앤드롭) 관련 + +| 문제 | 해결 | 날짜 | 키워드 | +|------|------|------|--------| +| useDrag 에러 (뷰어에서) | isDesignMode 체크 후 early return | 2026-02-04 | DnD, useDrag, 뷰어 | +| DndProvider 중복 에러 | 최상위에서만 Provider 사용 | 2026-02-04 | DndProvider, react-dnd | + +## 타입 관련 + +| 문제 | 해결 | 날짜 | 키워드 | +|------|------|------|--------| +| 인터페이스 이름 불일치 | V5 접미사 제거, 통일 | 2026-02-05 | 타입, interface, Props | +| v3/v4 타입 혼재 | v5 전용으로 통합, 레거시 삭제 | 2026-02-05 | 버전, 타입, 마이그레이션 | + +## 레이아웃 관련 + +| 문제 | 해결 | 날짜 | 키워드 | +|------|------|------|--------| +| Flexbox 배치 예측 불가 | CSS Grid로 전환 (v5) | 2026-02-05 | Flexbox, Grid, 반응형 | +| 4모드 각각 배치 힘듦 | 제약조건 기반 시스템 (v4) | 2026-02-03 | 모드, 반응형, 제약조건 | +| 4모드 자동 전환 안됨 | useResponsiveMode 훅 추가 | 2026-02-01 | 모드, 훅, 반응형 | + +## 저장/로드 관련 + +| 문제 | 해결 | 날짜 | 키워드 | +|------|------|------|--------| +| 레이아웃 버전 충돌 | isV5Layout 타입 가드로 분기 | 2026-02-05 | 버전, 로드, 타입가드 | +| 빈 레이아웃 판별 실패 | components 존재 여부로 판별 | 2026-02-04 | 빈 레이아웃, 로드 | + +## UI/UX 관련 + +| 문제 | 해결 | 날짜 | 키워드 | +|------|------|------|--------| +| root 레이아웃 오염 | tempLayout 도입 (임시 상태 분리) | 2026-02-04 | tempLayout, 상태, 오염 | +| 속성 패널 다른 모드 수정 | isDefaultMode 체크로 비활성화 | 2026-02-04 | 속성패널, 모드, 비활성화 | + +--- + +## 해결 안 된 문제 (진행 중) + +| 문제 | 상태 | 관련 파일 | +|------|------|----------| +| PopCanvas 타입 오류 | 미해결 | PopCanvas.tsx:76 | +| 팔레트 UI 없음 | 미해결 | PopDesigner.tsx | + +--- + +*새 문제-해결 추가 시 해당 카테고리 테이블에 행 추가* diff --git a/popdocs/README.md b/popdocs/README.md index 54301756..ee3754ce 100644 --- a/popdocs/README.md +++ b/popdocs/README.md @@ -1,12 +1,74 @@ # POP 화면 시스템 -> Point of Production - 현장 작업자용 모바일/태블릿 화면 +> **AI 에이전트 시작점**: 이 파일 → STATUS.md 순서로 읽으세요. +> 저장 요청 시: [SAVE_RULES.md](./SAVE_RULES.md) 참조 --- -## Quick Reference +## 현재 상태 -### 주요 경로 +| 항목 | 값 | +|------|-----| +| 버전 | **v5** (CSS Grid 기반) | +| 상태 | **기본 기능 완료** | +| 다음 | 실제 테스트, Phase 4 (실제 컴포넌트 구현) | + +**마지막 업데이트**: 2026-02-05 + +--- + +## 마지막 대화 요약 + +> (B)(C)(D) 모두 완료. 팔레트 UI 추가, 타입 오류 수정, 문서 v5 기준 통일. +> 다음: 실제 테스트 후 Phase 4 (실제 컴포넌트 렌더링, 데이터 바인딩) 진행. + +--- + +## 빠른 경로 + +| 알고 싶은 것 | 문서 | +|--------------|------| +| 지금 뭐 해야 해? | [STATUS.md](./STATUS.md) | +| 저장/조회 규칙 | [SAVE_RULES.md](./SAVE_RULES.md) | +| 왜 v5로 바꿨어? | [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md) | +| 이전 문제 해결 | [PROBLEMS.md](./PROBLEMS.md) | +| 코드 어디 있어? | [FILES.md](./FILES.md) | +| 기능별 색인 | [INDEX.md](./INDEX.md) | +| 변경 히스토리 | [CHANGELOG.md](./CHANGELOG.md) | + +--- + +## 핵심 파일 + +| 파일 | 역할 | 경로 | +|------|------|------| +| 타입 정의 | v5 레이아웃 타입 | `frontend/components/pop/designer/types/pop-layout.ts` | +| 캔버스 | 그리드 캔버스 + DnD | `frontend/components/pop/designer/PopCanvas.tsx` | +| 렌더러 | CSS Grid 렌더링 | `frontend/components/pop/designer/renderers/PopRenderer.tsx` | +| 디자이너 | 메인 컴포넌트 | `frontend/components/pop/designer/PopDesigner.tsx` | + +--- + +## 문서 구조 + +``` +[Layer 1: 먼저 읽기] +README.md (지금 여기) → STATUS.md + +[Layer 2: 필요시 읽기] +CHANGELOG, PROBLEMS, INDEX, FILES, ARCHITECTURE, SPEC, PLAN + +[Layer 3: 심화] +decisions/, sessions/, archive/ +``` + +**컨텍스트 효율화**: 모든 문서를 읽지 마세요. 필요한 것만 단계적으로. + +--- + +## POP이란? + +**Point of Production** - 현장 작업자용 모바일/태블릿 화면 시스템 | 용도 | 경로 | |------|------| @@ -14,103 +76,19 @@ | 관리 | `/admin/screenMng/popScreenMngList` | | API | `/api/screen-management/layout-pop/:screenId` | -### 핵심 파일 +--- -| 작업 | 파일 | -|------|------| -| 타입 | `frontend/components/pop/designer/types/pop-layout.ts` | -| 렌더러 | `frontend/components/pop/designer/renderers/` | -| 디자이너 | `frontend/components/pop/designer/PopDesigner.tsx` | +## v5 그리드 시스템 (현재) -### 현재 상태 +| 모드 | 화면 너비 | 칸 수 | +|------|----------|-------| +| mobile_portrait | ~599px | 4칸 | +| mobile_landscape | 600~839px | 6칸 | +| tablet_portrait | 840~1023px | 8칸 | +| tablet_landscape | 1024px~ | 12칸 | -- **버전**: v4.0 (제약조건 기반) ✅ -- **Phase**: Phase 3 완료 (visibility + 줄바꿈) -- **다음**: Phase 4 (실제 컴포넌트 구현) +**핵심**: 컴포넌트를 칸 단위로 배치 (col, row, colSpan, rowSpan) --- -## 문서 구조 - -| 파일 | 용도 | -|------|------| -| [SPEC.md](./SPEC.md) | 기술 스펙 | -| [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) | v4 통합 설계 모드 | -| [PLAN.md](./PLAN.md) | 계획/로드맵 | -| [CHANGELOG.md](./CHANGELOG.md) | 변경 이력 | -| [decisions/](./decisions/) | 중요 결정 기록 (ADR) | -| [components-spec.md](./components-spec.md) | 컴포넌트 상세 | -| [archive/](./archive/) | 이전 문서 | - ---- - -## 저장/조회 시스템 - -### 역할 분담 - -| 저장소 | 역할 | 특징 | -|--------|------|------| -| **rangraph** | AI 장기 기억 | 시맨틱 검색, 요약 저장 | -| **popdocs** | 상세 기록 | 파일 기반, 히스토리 | - -### 저장 요청 - -| 요청 예시 | AI 행동 | -|----------|--------| -| "@CHANGELOG.md 오늘 작업 정리해줘" | 파일 형식 맞춰 추가 + rangraph 요약 | -| "이 결정 저장해줘" | rangraph save_decision + decisions/ ADR | -| "해결됐어" | rangraph save_lesson + CHANGELOG Fixed | -| "작업 완료" | rangraph workflow_submit + CHANGELOG Added | - -### 조회 요청 - -| 요청 예시 | AI 행동 | -|----------|--------| -| "어제 뭐했지?" | rangraph 검색 | -| "지금 뭐하는 중이었지?" | rangraph workflow_status | -| "이 버그 전에도 있었어?" | rangraph search_memory | -| "v4 관련 작업들" | rangraph search_memory "v4" | - ---- - -## v4 핵심 (요약) - -``` -v3: 4개 모드 각각 위치 설정 → 4배 작업량 -v4: 3가지 규칙만 설정 → 자동 적응 - -핵심 규칙: -1. 크기: fixed(고정) / fill(채움) / hug(맞춤) -2. 배치: direction, wrap, gap -3. 반응형: breakpoint별 변경 - -Phase 3 추가: -4. visibility: 모드별 표시/숨김 -5. pop-break: 강제 줄바꿈 -``` - -상세: [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - ---- - -## Phase 3 완료 사항 (2026-02-04) ✅ - -### 새 기능 -- **visibility 속성**: 모드별 컴포넌트 표시/숨김 제어 -- **pop-break 컴포넌트**: Flexbox 강제 줄바꿈 (`flex-basis: 100%`) -- **컴포넌트 오버라이드 병합**: 모드별 설정 변경 (리스트 컬럼 수 등) -- **오버라이드 정리 로직**: 컴포넌트 삭제 시 모든 오버라이드 자동 정리 - -### 사용 예시 -``` -태블릿: [A] [B] [C] [D] (한 줄) -모바일: [A] [B] (두 줄, 줄바꿈 적용) - [C] [D] -``` - -### 참고 -- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계 - ---- - -*최종 업데이트: 2026-02-04 (Phase 3 완료)* +*상세: [SPEC.md](./SPEC.md) | 히스토리: [CHANGELOG.md](./CHANGELOG.md)* diff --git a/popdocs/SAVE_RULES.md b/popdocs/SAVE_RULES.md new file mode 100644 index 00000000..4d2bff9b --- /dev/null +++ b/popdocs/SAVE_RULES.md @@ -0,0 +1,539 @@ +# popdocs 사용 규칙 + +> **AI 에이전트 필독**: 이 문서는 popdocs 폴더 사용법입니다. +> 사용자가 "@popdocs"와 함께 요청하면 이 규칙을 참조하세요. + +--- + +## 요청 유형 인식 + +### 키워드로 요청 유형 판별 + +| 유형 | 키워드 예시 | 행동 | +|------|------------|------| +| **저장** | 저장해줘, 기록해줘, 정리해줘, 추가해줘 | → 저장 규칙 따르기 | +| **조회** | 찾아줘, 검색해줘, 뭐 있어?, 어디있어? | → 조회 규칙 따르기 | +| **분석** | 분석해줘, 비교해줘, 어떻게 달라? | → 분석 규칙 따르기 | +| **수정** | 수정해줘, 업데이트해줘, 고쳐줘 | → 수정 규칙 따르기 | +| **요약** | 요약해줘, 정리해서 보여줘, 보고서 | → 요약 규칙 따르기 | +| **작업시작** | 시작하자, 이어서 하자, 뭐 해야 해? | → 작업 시작 규칙 | + +### 요청 유형별 행동 + +``` +[저장 요청] +"@popdocs 오늘 작업 저장해줘" +→ SAVE_RULES.md 저장 섹션 → 적절한 파일에 저장 → 동기화 + +[조회 요청] +"@popdocs 이전에 DnD 문제 어떻게 해결했어?" +→ PROBLEMS.md 검색 → 관련 내용만 반환 + +[분석 요청] +"@popdocs v4랑 v5 뭐가 달라?" +→ decisions/ 또는 CHANGELOG 검색 → 비교표 생성 + +[수정 요청] +"@popdocs STATUS 업데이트해줘" +→ STATUS.md 수정 → README.md 동기화 + +[요약 요청] +"@popdocs 이번 주 작업 요약해줘" +→ sessions/ 해당 기간 검색 → 요약 생성 + +[작업 시작] +"@popdocs 오늘 작업 시작하자" +→ README → STATUS → 중단점 확인 → 작업 시작 +``` + +--- + +## 컨텍스트 효율화 원칙 + +### Progressive Disclosure (점진적 공개) + +**핵심**: 모든 문서를 한 번에 읽지 마세요. 필요한 것만 단계적으로. + +``` +Layer 1 (진입점) → README.md, STATUS.md (먼저 읽기, ~100줄) +Layer 2 (상세) → 필요한 문서만 선택적으로 +Layer 3 (심화) → 코드 파일, archive/ (필요시만) +``` + +### Token as Currency (토큰은 자원) + +| 원칙 | 설명 | +|------|------| +| **관련성 > 최신성** | 모든 히스토리 대신 관련 있는 것만 | +| **요약 > 전문** | 긴 내용 대신 요약 먼저 확인 | +| **링크 > 복사** | 내용 복사 대신 파일 경로 참조 | +| **테이블 > 산문** | 긴 설명 대신 표로 압축 | +| **검색 > 전체읽기** | Ctrl+F 키워드 검색 활용 | + +### Context Bloat 방지 + +``` +❌ 잘못된 방법: +"모든 문서를 읽고 파악한 후 작업하겠습니다" +→ 1,300줄 이상 낭비 + +✅ 올바른 방법: +"README → STATUS → 필요한 섹션만" +→ 평균 50~100줄로 작업 가능 +``` + +--- + +## 문서 구조 (3계층) + +``` +popdocs/ +│ +├── [Layer 1: 진입점] ───────────────────────── +│ ├── README.md ← 시작점 (현재 상태 요약) +│ ├── STATUS.md ← 진행 상태, 다음 작업 +│ └── SAVE_RULES.md ← 사용 규칙 (지금 읽는 문서) +│ +├── [Layer 2: 상세 문서] ───────────────────────── +│ ├── CHANGELOG.md ← 변경 이력 (날짜별) +│ ├── PROBLEMS.md ← 문제-해결 색인 +│ ├── INDEX.md ← 기능별 색인 +│ ├── ARCHITECTURE.md ← 코드 구조 +│ ├── FILES.md ← 파일 목록 +│ ├── SPEC.md ← 기술 스펙 +│ └── PLAN.md ← 계획 +│ +├── [Layer 3: 심화/기록] ───────────────────────── +│ ├── decisions/ ← ADR (결정 기록) +│ ├── sessions/ ← 날짜별 작업 기록 +│ └── archive/ ← 보관 (레거시) +│ +└── [외부 참조] ───────────────────────── + └── 실제 코드 → frontend/components/pop/designer/ +``` + +--- + +## 조회 규칙 (읽기) + +### 작업 시작 시 + +``` +1. README.md 읽기 (~60줄) + └→ 현재 상태, 다음 작업 확인 + +2. STATUS.md 읽기 (~40줄) + └→ 상세 진행 상황, 중단점 확인 + +3. 필요한 문서만 선택적으로 +``` + +### 요청별 조회 경로 + +| 사용자 요청 | 조회 경로 | +|-------------|----------| +| "지금 뭐 해야 해?" | README → STATUS | +| "어제 뭐 했어?" | sessions/어제날짜.md | +| "이전에 비슷한 문제?" | PROBLEMS.md (키워드 검색) | +| "이 기능 어디있어?" | INDEX.md 또는 FILES.md | +| "왜 이렇게 결정했어?" | decisions/ | +| "전체 히스토리" | CHANGELOG.md (기간 한정) | +| "코드 구조 알려줘" | ARCHITECTURE.md | +| "v4랑 v5 뭐가 달라?" | decisions/003 또는 CHANGELOG | + +### 효율적 검색 + +``` +# 전체 파일 읽지 말고 키워드 검색 +PROBLEMS.md에서 "DnD" 검색 → 관련 행만 +CHANGELOG.md에서 "2026-02-05" 검색 → 해당 날짜만 +FILES.md에서 "렌더러" 검색 → 관련 파일만 +``` + +--- + +## 저장 규칙 (쓰기) + +### 저장 유형별 위치 + +| 요청 패턴 | 저장 위치 | 형식 | +|----------|----------|------| +| "오늘 작업 저장/정리해줘" | sessions/YYYY-MM-DD.md | 세션 템플릿 | +| "이 결정 기록해줘" | decisions/NNN-제목.md | ADR 템플릿 | +| "이 문제 해결 기록해줘" | PROBLEMS.md | 행 추가 | +| "작업 내용 추가해줘" | CHANGELOG.md | 섹션 추가 | +| "현재 상태 업데이트" | STATUS.md | 상태 수정 | +| "기능 색인 추가해줘" | INDEX.md | 행 추가 | + +### 저장 후 필수 동기화 + +``` +저장 완료 후 항상: +1. STATUS.md 업데이트 (진행 상태, 다음 작업) +2. README.md "마지막 대화 요약" 업데이트 (1-2줄) +``` + +--- + +## 분석/비교 규칙 + +### 비교 요청 시 + +``` +사용자: "@popdocs v4랑 v5 뭐가 달라?" + +AI 행동: +1. decisions/003-v5-grid-system.md 확인 (있으면) +2. 없으면 CHANGELOG에서 관련 날짜 검색 +3. 비교표 형식으로 응답 + +응답 형식: +| 항목 | v4 | v5 | +|------|----|----| +| 배치 | Flexbox | CSS Grid | +| ... | ... | ... | +``` + +### 분석 요청 시 + +``` +사용자: "@popdocs 이번 달 작업 분석해줘" + +AI 행동: +1. sessions/ 폴더에서 해당 기간 파일 목록 +2. 각 파일의 "요약" 섹션만 추출 +3. 종합 분석 제공 +``` + +--- + +## 수정 규칙 + +### 문서 수정 요청 시 + +``` +사용자: "@popdocs STATUS 업데이트해줘" + +AI 행동: +1. STATUS.md 읽기 +2. 변경 내용 적용 +3. README.md 동기화 (마지막 대화 요약) +4. 변경 내용 사용자에게 확인 +``` + +### 여러 문서 수정 시 + +``` +수정 순서: +상세 문서 먼저 → STATUS.md → README.md +(역방향: 진입점이 항상 최신 상태 유지) +``` + +--- + +## 요약/보고서 규칙 + +### 요약 요청 시 + +``` +사용자: "@popdocs 이번 주 요약해줘" + +AI 행동: +1. sessions/ 해당 기간 파일 확인 +2. 각 파일의 "요약" + "완료" 섹션 추출 +3. 압축된 형식으로 응답 + +응답 형식: +## 이번 주 요약 (02-01 ~ 02-05) +| 날짜 | 주요 작업 | +|------|----------| +| 02-05 | v5 통합 완료 | +| 02-04 | ... | +``` + +### 보고서 생성 요청 시 + +``` +사용자: "@popdocs 진행 보고서 만들어줘" + +AI 행동: +1. STATUS.md (현재 상태) +2. CHANGELOG.md (최근 작업) +3. sessions/ (상세 기록) +4. 보고서 형식으로 조합 + +→ 새 파일 생성하지 말고 응답으로 제공 +→ 필요시 사용자가 저장 요청 +``` + +--- + +## 예외 처리 + +### 분류 불가 시 + +``` +사용자: "@popdocs 이거 저장해줘" (유형 불명확) + +AI 행동: +→ 사용자에게 질문: "어떤 유형으로 저장할까요?" + - 오늘 작업 기록 (sessions/) + - 문제-해결 기록 (PROBLEMS.md) + - 결정 사항 (decisions/) + - 변경 이력 (CHANGELOG.md) +``` + +### 복합 요청 시 + +``` +사용자: "@popdocs 문제 해결했고 결정도 내렸어. 저장해줘" + +AI 행동 (순서): +1. PROBLEMS.md에 문제-해결 추가 +2. decisions/에 ADR 생성 (필요시) +3. CHANGELOG.md에 섹션 추가 +4. STATUS.md 업데이트 +5. README.md 동기화 +``` + +### 전체 조회 요청 시 + +``` +사용자: "@popdocs 전체 히스토리 보여줘" + +AI 행동: +→ "기간을 지정해주시면 더 정확히 찾아드릴게요." + - 예: "최근 1주일", "2월 작업", "v5 관련" + +→ 기간 없이 강행 시: CHANGELOG.md 최근 5개 항목만 +``` + +### 파일 없음 시 + +``` +사용자: "@popdocs 어제 작업 보여줘" (sessions/어제.md 없음) + +AI 행동: +→ "어제 작업 기록이 없습니다. CHANGELOG.md에서 찾아볼까요?" +``` + +### 키워드 검색 실패 시 + +``` +사용자: "@popdocs DnD 문제 찾아줘" (PROBLEMS.md에 없음) + +AI 행동: +→ "PROBLEMS.md에서 못 찾았습니다. 다른 곳도 검색할까요?" + - CHANGELOG.md + - INDEX.md + - sessions/ +``` + +--- + +## 동기화 규칙 + +### 항상 동기화해야 하는 쌍 + +| 변경 문서 | 동기화 대상 | +|----------|-----------| +| sessions/ 생성 | STATUS.md (최근 세션) | +| PROBLEMS.md 추가 | - | +| decisions/ 생성 | STATUS.md (관련 결정), CHANGELOG.md | +| CHANGELOG.md 추가 | STATUS.md (진행 상태) | +| STATUS.md 수정 | README.md (마지막 요약) | + +### 불일치 발견 시 + +``` +README.md와 STATUS.md 내용이 다르면: +→ STATUS.md를 정본(正本)으로 +→ README.md를 STATUS.md 기준으로 업데이트 +``` + +--- + +## 정리 규칙 + +### 주기적 정리 (수동 요청 시) + +| 대상 | 조건 | 조치 | +|------|------|------| +| sessions/ | 30일 이상 | archive/sessions/로 이동 | +| PROBLEMS.md | 100행 초과 | 카테고리별 분리 검토 | +| CHANGELOG.md | 연도 변경 | 이전 연도 archive/로 | + +### 정리 요청 패턴 + +``` +사용자: "@popdocs 오래된 파일 정리해줘" + +AI 행동: +1. sessions/ 30일 이상 파일 목록 제시 +2. 사용자 확인 후 archive/로 이동 +3. 강제 삭제하지 않음 +``` + +--- + +## 템플릿 + +### 세션 기록 (sessions/YYYY-MM-DD.md) + +```markdown +# YYYY-MM-DD 작업 기록 + +## 요약 +(한 줄 요약 - 50자 이내) + +## 완료 +- [x] 작업1 +- [x] 작업2 + +## 미완료 +- [ ] 작업3 (이유: ...) + +## 중단점 +> (내일 이어서 할 때 바로 시작할 수 있는 정보) + +## 대화 핵심 +- 키워드1: 설명 +- 키워드2: 설명 + +## 관련 링크 +- CHANGELOG: #YYYY-MM-DD +- ADR: decisions/NNN (있으면) +``` + +### 문제-해결 (PROBLEMS.md 행 추가) + +```markdown +| 문제 | 해결 | 날짜 | 키워드 | +|------|------|------|--------| +| (에러/문제 설명) | (해결 방법) | YYYY-MM-DD | 검색용 | +``` + +### ADR (decisions/NNN-제목.md) + +```markdown +# ADR-NNN: 제목 + +**날짜**: YYYY-MM-DD +**상태**: 채택됨 + +## 배경 (왜) +(2-3문장) + +## 결정 (무엇) +(핵심 결정 사항) + +## 대안 +| 옵션 | 장점 | 단점 | 결과 | +|------|------|------|------| + +## 교훈 +- (배운 점) +``` + +### CHANGELOG 섹션 + +```markdown +## [YYYY-MM-DD] 제목 + +### 배경 +(왜 - 2-3문장) + +### 작업 +- [x] 완료1 +- [ ] 미완료 → STATUS.md로 + +### 다음 +→ STATUS.md 참조 +``` + +--- + +## 토큰 비용 가이드 + +| 문서 | 평균 줄 수 | 언제 읽나 | +|------|-----------|----------| +| README.md | ~60 | 항상 (진입점) | +| STATUS.md | ~40 | 항상 (진입점) | +| SAVE_RULES.md | ~350 | 저장/조회 요청 시 | +| sessions/날짜.md | ~30 | 해당 날짜만 | +| PROBLEMS.md | ~100 | 키워드 검색 | +| INDEX.md | ~80 | 키워드 검색 | +| CHANGELOG.md | ~600 | 날짜/키워드 검색 | +| ARCHITECTURE.md | ~300 | 구조 파악 시 | +| FILES.md | ~200 | 파일 찾을 때 | + +**권장**: README + STATUS = ~100줄로 작업 시작 + +--- + +## 예시 워크플로우 + +### 1. 작업 시작 + +``` +사용자: "@popdocs 오늘 작업 시작하자" + +AI: +1. README.md 읽기 (60줄) +2. STATUS.md 읽기 (40줄) +3. 중단점 확인 +4. "중단점: PopCanvas.tsx line 76 타입 오류. 여기서 시작할까요?" +``` + +### 2. 자료 검색 + +``` +사용자: "@popdocs DnD 관련 자료 찾아줘" + +AI: +1. PROBLEMS.md에서 "DnD" 검색 +2. INDEX.md에서 "DnD" 검색 +3. 관련 내용 종합해서 응답 +4. 전체 파일 읽지 않음 +``` + +### 3. 비교 요청 + +``` +사용자: "@popdocs v4랑 v5 비교해줘" + +AI: +1. decisions/003-v5-grid-system.md 확인 +2. "대안 검토" 섹션에서 비교표 추출 +3. 필요시 CHANGELOG 보충 +``` + +### 4. 작업 저장 + +``` +사용자: "@popdocs 오늘 작업 저장해줘" + +AI: +1. sessions/2026-02-05.md 생성 +2. STATUS.md 업데이트 +3. README.md "마지막 대화 요약" 업데이트 +4. "저장 완료. 내일 이어서 할 중단점도 기록했습니다." +``` + +### 5. 불명확한 요청 + +``` +사용자: "@popdocs 이거 저장해줘" + +AI: +"어떤 유형으로 저장할까요? +1. 오늘 작업 기록 (sessions/) +2. 문제-해결 (PROBLEMS.md) +3. 결정 사항 (decisions/) +4. 변경 이력 (CHANGELOG.md)" +``` + +--- + +*2025-2026 AI 컨텍스트 엔지니어링 기반* +*Progressive Disclosure, Agentic Context Engineering, Token as Currency* diff --git a/popdocs/SPEC.md b/popdocs/SPEC.md index a1fb5404..d5849298 100644 --- a/popdocs/SPEC.md +++ b/popdocs/SPEC.md @@ -1,43 +1,122 @@ # POP 기술 스펙 +**버전: v5 (CSS Grid 기반)** + --- -## v4 핵심 규칙 (3가지) +## v5 핵심 규칙 -### 1. 크기 규칙 (Size) +### 1. 그리드 시스템 -| 모드 | 설명 | 예시 | -|------|------|------| -| `fixed` | 고정 px | 버튼 48px | -| `fill` | 부모 채움 | 입력창 100% | -| `hug` | 내용 맞춤 | 라벨 | +| 모드 | 화면 너비 | 칸 수 | 대상 | +|------|----------|-------|------| +| mobile_portrait | ~599px | 4칸 | 4~6인치 | +| mobile_landscape | 600~839px | 6칸 | 7인치 | +| tablet_portrait | 840~1023px | 8칸 | 8~10인치 | +| tablet_landscape | 1024px~ | 12칸 | 10~14인치 (기본) | -### 2. 배치 규칙 (Layout) - -| 항목 | 옵션 | -|------|------| -| direction | horizontal / vertical | -| wrap | true / false | -| gap | 8 / 16 / 24 px | -| alignItems | start / center / end / stretch | - -### 3. 반응형 규칙 (Responsive) +### 2. 위치 지정 ```typescript -{ - direction: "horizontal", - responsive: [{ breakpoint: 768, direction: "vertical" }] +interface PopGridPosition { + col: number; // 시작 열 (1부터) + row: number; // 시작 행 (1부터) + colSpan: number; // 열 크기 (1~12) + rowSpan: number; // 행 크기 (1~) } ``` +### 3. 브레이크포인트 설정 + +```typescript +const GRID_BREAKPOINTS = { + mobile_portrait: { + columns: 4, + rowHeight: 48, + gap: 8, + padding: 12 + }, + mobile_landscape: { + columns: 6, + rowHeight: 44, + gap: 8, + padding: 16 + }, + tablet_portrait: { + columns: 8, + rowHeight: 52, + gap: 12, + padding: 20 + }, + tablet_landscape: { + columns: 12, + rowHeight: 56, + gap: 12, + padding: 24 + }, +}; +``` + +--- + +## 데이터 구조 + +### v5 레이아웃 + +```typescript +interface PopLayoutDataV5 { + version: "pop-5.0"; + metadata: { + screenId: number; + createdAt: string; + updatedAt: string; + }; + gridConfig: { + defaultMode: GridMode; + maxRows: number; + }; + components: PopComponentDefinitionV5[]; + globalSettings: { + backgroundColor: string; + padding: number; + }; +} +``` + +### v5 컴포넌트 + +```typescript +interface PopComponentDefinitionV5 { + id: string; + type: PopComponentType; // "pop-label" | "pop-button" | ... + label: string; + gridPosition: PopGridPosition; + config: PopComponentConfig; + visibility: Record; // 모드별 표시/숨김 + modeOverrides?: Record; // 모드별 오버라이드 +} +``` + +### 컴포넌트 타입 + +```typescript +type PopComponentType = + | "pop-label" // 텍스트 라벨 + | "pop-button" // 버튼 + | "pop-input" // 입력 필드 + | "pop-select" // 선택 박스 + | "pop-grid" // 데이터 그리드 + | "pop-container"; // 컨테이너 +``` + --- ## 크기 프리셋 ### 터치 요소 -| 요소 | 일반 | 산업 | -|------|-----|------| +| 요소 | 일반 | 산업용 | +|------|-----|-------| | 버튼 높이 | 48px | 60px | | 입력창 높이 | 48px | 56px | | 터치 영역 | 48px | 60px | @@ -62,60 +141,64 @@ ## 반응형 원칙 ``` -누르는 것 → 고정 (48px) -읽는 것 → 범위 (clamp) -담는 것 → 비율 (%) +누르는 것 → 고정 (48px) - 버튼, 터치 영역 +읽는 것 → 범위 (clamp) - 텍스트 +담는 것 → 칸 (colSpan) - 컨테이너 ``` --- -## 데이터 구조 +## 위치 변환 -### v3 (현재) +12칸 기준으로 설계 → 다른 모드에서 자동 변환 ```typescript -interface PopLayoutDataV3 { - version: "pop-3.0"; - layouts: { - tablet_landscape: { componentPositions: Record }; - // ... 4개 모드 - }; - components: Record; -} -``` +// 12칸 → 4칸 변환 예시 +const ratio = 4 / 12; // = 0.333 -### v4 (계획) - -```typescript -interface PopLayoutDataV4 { - version: "pop-4.0"; - root: PopContainer; - components: Record; -} - -interface PopContainer { - type: "stack"; - direction: "horizontal" | "vertical"; - children: (string | PopContainer)[]; - responsive?: { breakpoint: number; direction?: string }[]; -} +original: { col: 1, colSpan: 6 } // 12칸에서 절반 +converted: { col: 1, colSpan: 2 } // 4칸에서 절반 ``` --- ## Troubleshooting -### 캔버스 rowSpan 문제 +### 컴포넌트가 얇게 보임 -- **증상**: 컴포넌트가 얇게 보임 +- **증상**: rowSpan이 적용 안됨 - **원인**: gridTemplateRows 고정 px - **해결**: `1fr` 사용 -### 4모드 전환 안 됨 +### 모드 전환 안 됨 -- **증상**: 크기 줄여도 레이아웃 유지 -- **해결**: useResponsiveMode 훅 사용 +- **증상**: 화면 크기 변경해도 레이아웃 유지 +- **해결**: `detectGridMode()` 사용 + +### 겹침 발생 + +- **증상**: 컴포넌트끼리 겹침 +- **해결**: `resolveOverlaps()` 호출 --- -*상세 archive 참조: `archive/V4_CORE_RULES.md`, `archive/SIZE_PRESETS.md`* +## 타입 가드 + +```typescript +// v5 레이아웃 판별 +function isV5Layout(data: any): data is PopLayoutDataV5 { + return data?.version === "pop-5.0"; +} + +// 사용 예시 +if (isV5Layout(savedData)) { + setLayout(savedData); +} else { + setLayout(createEmptyPopLayoutV5()); +} +``` + +--- + +*상세 아키텍처: [ARCHITECTURE.md](./ARCHITECTURE.md)* +*파일 목록: [FILES.md](./FILES.md)* diff --git a/popdocs/STATUS.md b/popdocs/STATUS.md new file mode 100644 index 00000000..8e1d0a69 --- /dev/null +++ b/popdocs/STATUS.md @@ -0,0 +1,68 @@ +# 현재 상태 + +> **마지막 업데이트**: 2026-02-05 +> **담당**: POP 화면 디자이너 + +--- + +## 진행 상태 + +| 단계 | 상태 | 설명 | +|------|------|------| +| v5 타입 정의 | 완료 | `pop-layout.ts` | +| v5 렌더러 | 완료 | `PopRenderer.tsx` | +| v5 캔버스 | 완료 | `PopCanvas.tsx` | +| v5 편집 패널 | 완료 | `ComponentEditorPanel.tsx` | +| v5 유틸리티 | 완료 | `gridUtils.ts` | +| 레거시 삭제 | 완료 | v1~v4 코드, 데이터 | +| 문서 정리 | **완료** | popdocs v5 기준 재정비 | +| 컴포넌트 팔레트 | **완료** | `ComponentPalette.tsx` | +| 타입 오류 수정 | **완료** | PopCanvas.tsx:76 | +| 드래그앤드롭 | **완료** | 팔레트 → 캔버스 연결 | + +--- + +## 다음 작업 (우선순위) + +1. **실제 테스트** + - 디자이너 페이지에서 컴포넌트 드래그앤드롭 테스트 + - 저장/로드 동작 확인 + +2. **실제 컴포넌트 구현** (Phase 4) + - pop-label, pop-button 등 실제 렌더링 + - 데이터 바인딩 연결 + +3. **추가 기능** + - 컴포넌트 복사/붙여넣기 + - 다중 선택 + - 정렬 도우미 + +--- + +## 알려진 문제 + +| 문제 | 상태 | 비고 | +|------|------|------| +| 타입 이름 불일치 | 해결됨 | V5 접미사 제거 | +| 팔레트 없음 | 해결됨 | ComponentPalette.tsx 추가 | + +--- + +## 최근 세션 + +| 날짜 | 요약 | 상세 | +|------|------|------| +| 2026-02-05 | v5 통합, 문서 재정비, 팔레트 UI 추가 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) | + +--- + +## 관련 결정 + +| ADR | 제목 | 날짜 | +|-----|------|------| +| 003 | v5 CSS Grid 채택 | 2026-02-05 | +| 001 | v4 제약조건 기반 | 2026-02-03 | + +--- + +*전체 히스토리: [CHANGELOG.md](./CHANGELOG.md)* diff --git a/popdocs/PHASE3_SUMMARY.md b/popdocs/archive/PHASE3_SUMMARY.md similarity index 100% rename from popdocs/PHASE3_SUMMARY.md rename to popdocs/archive/PHASE3_SUMMARY.md diff --git a/popdocs/STORAGE_RULES.md b/popdocs/archive/STORAGE_RULES.md similarity index 100% rename from popdocs/STORAGE_RULES.md rename to popdocs/archive/STORAGE_RULES.md diff --git a/popdocs/V4_UNIFIED_DESIGN_SPEC.md b/popdocs/archive/V4_UNIFIED_DESIGN_SPEC.md similarity index 100% rename from popdocs/V4_UNIFIED_DESIGN_SPEC.md rename to popdocs/archive/V4_UNIFIED_DESIGN_SPEC.md diff --git a/popdocs/decisions/003-v5-grid-system.md b/popdocs/decisions/003-v5-grid-system.md new file mode 100644 index 00000000..a90551a5 --- /dev/null +++ b/popdocs/decisions/003-v5-grid-system.md @@ -0,0 +1,143 @@ +# ADR-003: v5 CSS Grid 기반 그리드 시스템 채택 + +**날짜**: 2026-02-05 +**상태**: 채택됨 +**의사결정자**: 프로젝트 담당자, 상급자 + +--- + +## 배경 + +### 문제 상황 + +v4 Flexbox 기반 레이아웃으로 반응형 구현을 시도했으나 실패: + +1. **배치 예측 불가능**: 컴포넌트가 자유롭게 움직이지만 원하는 위치에 안 감 +2. **캔버스 방식의 한계**: "그리듯이" 배치하면 화면 크기별로 깨짐 +3. **규칙 부재**: 어디에 뭘 배치해야 하는지 기준이 없음 + +### 상급자 피드백 + +> "이런 식이면 나중에 문제가 생긴다." +> +> "스크린의 픽셀 규격과 마진 간격 규칙을 설정해라. +> 큰 화면 디자인의 전체 프레임 규격과 사이즈 간격 규칙을 정한 다음에 +> 거기에 컴포넌트를 끼워 맞추듯 우리의 규칙 내로 움직이게 바탕을 잡아라." + +### 연구 내용 + +| 도구 | 핵심 특징 | 적용 가능 요소 | +|------|----------|---------------| +| **Softr** | 블록 기반, 제약 기반 레이아웃 | 컨테이너 슬롯 방식 | +| **Ant Design** | 24열 그리드, 8px 간격 | 그리드 시스템, 간격 규칙 | +| **Material Design** | 4/8/12열, 반응형 브레이크포인트 | 디바이스별 칸 수 | + +--- + +## 결정 + +**CSS Grid 기반 그리드 시스템 (v5) 채택** + +### 핵심 규칙 + +| 모드 | 화면 너비 | 칸 수 | 대상 디바이스 | +|------|----------|-------|--------------| +| mobile_portrait | ~599px | 4칸 | 4~6인치 모바일 | +| mobile_landscape | 600~839px | 6칸 | 7인치 모바일 | +| tablet_portrait | 840~1023px | 8칸 | 8~10인치 태블릿 | +| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 (기본) | + +### 컴포넌트 배치 + +```typescript +interface PopGridPosition { + col: number; // 시작 열 (1부터) + row: number; // 시작 행 (1부터) + colSpan: number; // 열 크기 (1~12) + rowSpan: number; // 행 크기 (1~) +} +``` + +### v4 대비 변경점 + +| 항목 | v4 (Flexbox) | v5 (Grid) | +|------|-------------|-----------| +| 배치 방식 | 흐름 기반 (자동) | 좌표 기반 (명시적) | +| 크기 단위 | 픽셀 (200px) | 칸 (colSpan: 3) | +| 예측성 | 낮음 | 높음 | +| 반응형 | 복잡한 규칙 | 칸 수 변환 | + +--- + +## 대안 검토 + +### A. v4 Flexbox 유지 (기각) + +- **장점**: 기존 코드 활용 가능 +- **단점**: 상급자 지적한 문제 해결 안됨 (규칙 부재) +- **결과**: 기각 + +### B. 자유 배치 (절대 좌표) (기각) + +- **장점**: 완전한 자유도 +- **단점**: 반응형 불가능, 화면별로 전부 다시 배치 필요 +- **결과**: 기각 + +### C. CSS Grid 그리드 시스템 (채택) + +- **장점**: + - 규칙 기반으로 예측 가능 + - 반응형 자동화 (12칸 → 4칸 변환) + - Material Design 표준 준수 +- **단점**: + - 기존 v4 데이터 호환 불가 + - 자유도 제한 (칸 단위로만) +- **결과**: **채택** + +--- + +## 영향 + +### 변경 필요 + +- [x] 타입 정의 (`PopLayoutDataV5`, `PopGridPosition`) +- [x] 렌더러 (`PopRenderer.tsx` - CSS Grid) +- [x] 캔버스 (`PopCanvas.tsx` - 그리드 표시) +- [x] 유틸리티 (`gridUtils.ts` - 좌표 계산) +- [x] 레거시 삭제 (v1~v4 코드, 데이터) + +### 호환성 + +- v1~v4 레이아웃: **삭제** (마이그레이션 없이 초기화) +- 새 화면: v5로만 생성 + +### 제한 사항 + +- 컴포넌트는 칸 단위로만 배치 (칸 사이 배치 불가) +- 12칸 기준으로 설계 후 다른 모드는 자동 변환 + +--- + +## 교훈 + +1. **규칙이 자유를 만든다**: 제약이 있어야 일관된 디자인 가능 +2. **상급자 피드백 중요**: "프레임 규격 먼저" 조언이 핵심 방향 제시 +3. **연구 후 결정**: Softr, Ant Design 분석이 구체적 방향 제시 +4. **과감한 삭제**: 레거시 유지보다 깔끔한 재시작이 나음 + +--- + +## 참조 + +- Softr: https://www.softr.io +- Ant Design Grid: https://ant.design/components/grid +- Material Design Layout: https://m3.material.io/foundations/layout +- GRID_SYSTEM_DESIGN.md: 상세 설계 스펙 + +--- + +## 관련 + +- [ADR-001](./001-v4-constraint-based.md): v4 제약조건 기반 (이전 시도) +- [CHANGELOG 2026-02-05](../CHANGELOG.md#2026-02-05): 작업 내역 +- [sessions/2026-02-05](../sessions/2026-02-05.md): 대화 기록 diff --git a/popdocs/sessions/2026-02-05.md b/popdocs/sessions/2026-02-05.md new file mode 100644 index 00000000..0e60325b --- /dev/null +++ b/popdocs/sessions/2026-02-05.md @@ -0,0 +1,85 @@ +# 2026-02-05 작업 기록 + +## 요약 +v5 그리드 시스템 통합 완료, popdocs 문서 구조 재정비 + +--- + +## 완료 + +### v5 통합 작업 +- [x] 레거시 파일 삭제 (PopCanvasV4, PopFlexRenderer, PopLayoutRenderer 등) +- [x] 파일명 정규화 (V5 접미사 제거) +- [x] 뷰어 페이지 v5 전용으로 업데이트 +- [x] 백엔드 screenManagementService v5 전용 단순화 +- [x] DB 기존 레이아웃 데이터 삭제 + +### 문서 재정비 작업 +- [x] SAVE_RULES.md 생성 (AI 저장/조회 규칙) +- [x] README.md 재작성 (진입점 역할) +- [x] STATUS.md 생성 (현재 상태) +- [x] PROBLEMS.md 생성 (문제-해결 색인) +- [x] INDEX.md 생성 (기능별 색인) +- [x] sessions/ 폴더 구조 도입 + +--- + +## 미완료 + +- [ ] 컴포넌트 팔레트 UI 추가 (PopDesigner.tsx 좌측) +- [ ] PopCanvas.tsx 타입 오류 수정 (line 76) +- [ ] ARCHITECTURE.md v5 기준 업데이트 +- [ ] CHANGELOG.md 오늘 작업 추가 + +--- + +## 중단점 + +> **다음 작업자 참고**: +> +> 1. **타입 오류**: PopCanvas.tsx line 76 +> - `}: PopCanvasV5Props)` → `}: PopCanvasProps)`로 변경 +> - 인터페이스는 이미 `PopCanvasProps`로 정의됨 (line 48) +> +> 2. **팔레트 UI**: PopDesigner.tsx에 컴포넌트 팔레트 추가 필요 +> - 위치: 좌측 ResizablePanel (현재 비어있음) +> - 참고: 이전 ComponentPaletteV4.tsx (삭제됨, archive에서 참고 가능) +> - DnD 타입: PopCanvas.tsx에 `DND_ITEM_TYPES` 인라인 정의됨 +> +> 3. **문서**: ARCHITECTURE.md가 아직 v3/v4 기준임 + +--- + +## 대화 핵심 + +### v5 전환 배경 +- **문제**: v4 Flexbox로 반응형 시도 → 배치 예측 불가능 +- **상급자 피드백**: "스크린 규격과 마진 간격 규칙을 먼저 정해라" +- **연구**: Softr, Ant Design, Material Design 분석 +- **결정**: CSS Grid 기반 그리드 시스템 채택 + +### popdocs 재정비 배경 +- **문제**: 문서 구조가 AI 에이전트 진입점 역할 못함 +- **해결**: Progressive Disclosure 적용, 저장/조회 규칙 명시화 +- **참고**: 2025-2026 AI 컨텍스트 엔지니어링 최신 기법 + +### 핵심 결정 +- Layer 1 (진입점): README, STATUS, SAVE_RULES +- Layer 2 (상세): CHANGELOG, PROBLEMS, INDEX 등 +- Layer 3 (심화): decisions/, sessions/, archive/ + +--- + +## 관련 링크 + +- ADR: [decisions/003-v5-grid-system.md](../decisions/003-v5-grid-system.md) +- CHANGELOG: 오늘 작업 추가 필요 +- 삭제된 파일 목록: FILES.md 하단 "삭제된 파일" 섹션 + +--- + +## 메모 + +- POPUPDATE.md (루트)는 별도로 유지 (전체 프로젝트 기록용) +- popdocs/는 POP 디자이너 개발 전용 +- rangraph 연동 고려 (장기 기억 검색용)