From 8c045acab354b2e12df6b196531bcc5d9c77e315 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 2 Feb 2026 15:15:01 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat(pop):=20POP=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - screen_layouts_pop 테이블용 CRUD API 추가 (getLayoutPop, saveLayoutPop, deleteLayoutPop, getScreenIdsWithPopLayout) - 멀티테넌시 권한 체크 포함 Frontend API: - screenApi에 POP 레이아웃 함수 4개 추가 POP 관리 페이지: - popScreenMngList 신규 생성 - isPop prop으로 미리보기 URL 분기 (/pop/screens/{id}) - CreateScreenModal에서 POP 화면 생성 시 빈 레이아웃 자동 생성 POP 디자이너: - PopDesigner, PopCanvas, PopPanel, SectionGrid 컴포넌트 구현 - react-dnd로 팔레트→캔버스 드래그앤드롭 - react-grid-layout으로 컴포넌트 자유 배치/리사이즈 - 그리드 단순화: 고정 셀 크기(40px) 기반 자동 계산, 그리드 점 제거 - onLayoutChange를 onDragStop/onResizeStop으로 변경하여 드롭 시 크기 유지 --- POPREADME.md | 658 ++++++++++++++++++ POPUPDATE.md | 630 +++++++++++++++++ .../controllers/screenManagementController.ts | 84 +++ .../src/routes/screenManagementRoutes.ts | 10 + .../src/services/screenManagementService.ts | 206 ++++++ docs/pop/PROJECT_ARCHITECTURE.md | 285 ++++++++ docs/pop/components-spec.md | 290 ++++++++ .../admin/screenMng/popScreenMngList/page.tsx | 406 +++++++++++ .../app/(pop)/pop/screens/[screenId]/page.tsx | 305 ++++++++ .../components/pop/designer/PopCanvas.tsx | 378 ++++++++++ .../components/pop/designer/PopDesigner.tsx | 352 ++++++++++ .../components/pop/designer/SectionGrid.tsx | 352 ++++++++++ frontend/components/pop/designer/index.ts | 24 + .../pop/designer/panels/PopPanel.tsx | 509 ++++++++++++++ .../components/pop/designer/panels/index.ts | 2 + .../components/pop/designer/types/index.ts | 2 + .../pop/designer/types/pop-layout.ts | 363 ++++++++++ .../components/screen/CreateScreenModal.tsx | 18 +- frontend/components/screen/ScreenDesigner.tsx | 65 +- .../components/screen/ScreenRelationFlow.tsx | 4 +- .../components/screen/ScreenSettingModal.tsx | 18 +- .../components/screen/toolbar/SlimToolbar.tsx | 4 +- frontend/components/ui/resizable.tsx | 45 ++ frontend/lib/api/screen.ts | 26 + frontend/lib/registry/PopComponentRegistry.ts | 267 +++++++ frontend/lib/registry/pop-components/index.ts | 24 + frontend/lib/schemas/popComponentConfig.ts | 231 ++++++ frontend/package-lock.json | 113 +-- frontend/package.json | 2 + 29 files changed, 5611 insertions(+), 62 deletions(-) create mode 100644 POPREADME.md create mode 100644 POPUPDATE.md create mode 100644 docs/pop/PROJECT_ARCHITECTURE.md create mode 100644 docs/pop/components-spec.md create mode 100644 frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx create mode 100644 frontend/app/(pop)/pop/screens/[screenId]/page.tsx create mode 100644 frontend/components/pop/designer/PopCanvas.tsx create mode 100644 frontend/components/pop/designer/PopDesigner.tsx create mode 100644 frontend/components/pop/designer/SectionGrid.tsx create mode 100644 frontend/components/pop/designer/index.ts create mode 100644 frontend/components/pop/designer/panels/PopPanel.tsx create mode 100644 frontend/components/pop/designer/panels/index.ts create mode 100644 frontend/components/pop/designer/types/index.ts create mode 100644 frontend/components/pop/designer/types/pop-layout.ts create mode 100644 frontend/components/ui/resizable.tsx create mode 100644 frontend/lib/registry/PopComponentRegistry.ts create mode 100644 frontend/lib/registry/pop-components/index.ts create mode 100644 frontend/lib/schemas/popComponentConfig.ts diff --git a/POPREADME.md b/POPREADME.md new file mode 100644 index 00000000..d1fe8519 --- /dev/null +++ b/POPREADME.md @@ -0,0 +1,658 @@ +# POP 화면 시스템 구현 계획서 + +## 개요 + +Vexplor 서비스 내에서 POP(Point of Production) 화면을 구성할 수 있는 시스템을 구현합니다. +기존 Vexplor와 충돌 없이 별도 공간에서 개발하되, 장기적으로 통합 가능하도록 동일한 서비스 로직을 사용합니다. + +--- + +## 핵심 원칙 + +| 원칙 | 설명 | +|------|------| +| **충돌 방지** | POP 전용 공간에서 개발 | +| **통합 준비** | 기본 서비스 로직은 Vexplor와 동일 | +| **데이터 공유** | 같은 DB, 같은 데이터 소스 사용 | + +--- + +## 아키텍처 개요 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ [데이터베이스] │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ screen_ │ │ screen_layouts_ │ │ screen_layouts_ │ │ +│ │ definitions │ │ v2 (데스크톱) │ │ pop (POP) │ │ +│ │ (공통) │ └─────────────────┘ └─────────────────┘ │ +│ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ [백엔드 API] │ +│ /screen-management/screens/:id/layout-v2 (데스크톱) │ +│ /screen-management/screens/:id/layout-pop (POP) │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────┐ +│ [프론트엔드 - 데스크톱] │ │ [프론트엔드 - POP] │ +│ │ │ │ +│ app/(main)/ │ │ app/(pop)/ │ +│ lib/registry/ │ │ lib/registry/ │ +│ components/ │ │ pop-components/ │ +│ components/screen/ │ │ components/pop/ │ +└─────────────────────────┘ └─────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────┐ +│ PC 브라우저 │ │ 모바일/태블릿 브라우저 │ +│ (마우스 + 키보드) │ │ (터치 + 스캐너) │ +└─────────────────────────┘ └─────────────────────────┘ +``` + +--- + +## 1. 데이터베이스 변경사항 + +### 1-1. 테이블 추가/유지 현황 + +| 구분 | 테이블명 | 변경 내용 | 비고 | +|------|----------|----------|------| +| **추가** | `screen_layouts_pop` | POP 레이아웃 저장용 | 신규 테이블 | +| **유지** | `screen_definitions` | 변경 없음 | 공통 사용 | +| **유지** | `screen_layouts_v2` | 변경 없음 | 데스크톱 전용 | + +### 1-2. 신규 테이블 DDL + +```sql +-- 마이그레이션 파일: db/migrations/XXX_create_screen_layouts_pop.sql + +CREATE TABLE screen_layouts_pop ( + layout_id SERIAL PRIMARY KEY, + screen_id INTEGER NOT NULL REFERENCES screen_definitions(screen_id), + company_code VARCHAR(20) NOT NULL, + layout_data JSONB NOT NULL DEFAULT '{}'::jsonb, -- 반응형 레이아웃 JSON + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + updated_by VARCHAR(50), + UNIQUE(screen_id, company_code) +); + +CREATE INDEX idx_pop_screen_id ON screen_layouts_pop(screen_id); +CREATE INDEX idx_pop_company_code ON screen_layouts_pop(company_code); + +COMMENT ON TABLE screen_layouts_pop IS 'POP 화면 레이아웃 저장 테이블 (모바일/태블릿 반응형)'; +COMMENT ON COLUMN screen_layouts_pop.layout_data IS 'V2 형식의 레이아웃 JSON (반응형 구조)'; +``` + +### 1-3. 레이아웃 JSON 구조 (V2 형식 동일) + +```json +{ + "version": "2.0", + "components": [ + { + "id": "comp_xxx", + "url": "@/lib/registry/pop-components/pop-card-list", + "position": { "x": 0, "y": 0 }, + "size": { "width": 100, "height": 50 }, + "displayOrder": 0, + "overrides": { + "tableName": "user_info", + "columns": ["id", "name", "status"], + "cardStyle": "compact" + } + } + ], + "updatedAt": "2026-01-29T12:00:00Z" +} +``` + +--- + +## 2. 백엔드 변경사항 + +### 2-1. 파일 수정 목록 + +| 구분 | 파일 경로 | 변경 내용 | +|------|----------|----------| +| **수정** | `backend-node/src/services/screenManagementService.ts` | POP 레이아웃 CRUD 함수 추가 | +| **수정** | `backend-node/src/routes/screenManagementRoutes.ts` | POP API 엔드포인트 추가 | + +### 2-2. 추가 API 엔드포인트 + +``` +GET /screen-management/screens/:screenId/layout-pop # POP 레이아웃 조회 +POST /screen-management/screens/:screenId/layout-pop # POP 레이아웃 저장 +DELETE /screen-management/screens/:screenId/layout-pop # POP 레이아웃 삭제 +``` + +### 2-3. screenManagementService.ts 추가 함수 + +```typescript +// 기존 함수 (유지) +getScreenLayoutV2(screenId, companyCode) +saveLayoutV2(screenId, companyCode, layoutData) + +// 추가 함수 (신규) - 로직은 V2와 동일, 테이블명만 다름 +getScreenLayoutPop(screenId, companyCode) +saveLayoutPop(screenId, companyCode, layoutData) +deleteLayoutPop(screenId, companyCode) +``` + +--- + +## 3. 프론트엔드 변경사항 + +### 3-1. 폴더 구조 + +``` +frontend/ +├── app/ +│ └── (pop)/ # [기존] POP 라우팅 그룹 +│ ├── layout.tsx # [수정] POP 전용 레이아웃 +│ ├── pop/ +│ │ └── page.tsx # [기존] POP 메인 +│ └── screens/ # [추가] POP 화면 뷰어 +│ └── [screenId]/ +│ └── page.tsx # [추가] POP 동적 화면 +│ +├── lib/ +│ ├── api/ +│ │ └── screen.ts # [수정] POP API 함수 추가 +│ │ +│ ├── registry/ +│ │ ├── pop-components/ # [추가] POP 전용 컴포넌트 +│ │ │ ├── pop-card-list/ +│ │ │ │ ├── PopCardListComponent.tsx +│ │ │ │ ├── PopCardListConfigPanel.tsx +│ │ │ │ └── index.ts +│ │ │ ├── pop-touch-button/ +│ │ │ ├── pop-scanner-input/ +│ │ │ └── index.ts # POP 컴포넌트 내보내기 +│ │ │ +│ │ ├── PopComponentRegistry.ts # [추가] POP 컴포넌트 레지스트리 +│ │ └── ComponentRegistry.ts # [유지] 기존 유지 +│ │ +│ ├── schemas/ +│ │ └── popComponentConfig.ts # [추가] POP용 Zod 스키마 +│ │ +│ └── utils/ +│ └── layoutPopConverter.ts # [추가] POP 레이아웃 변환기 +│ +└── components/ + └── pop/ # [기존] POP UI 컴포넌트 + ├── PopScreenDesigner.tsx # [추가] POP 화면 설계 도구 + ├── PopPreview.tsx # [추가] POP 미리보기 + └── PopDynamicRenderer.tsx # [추가] POP 동적 렌더러 +``` + +### 3-2. 파일별 상세 내용 + +#### A. 신규 파일 (추가) + +| 파일 | 역할 | 기반 | +|------|------|------| +| `app/(pop)/screens/[screenId]/page.tsx` | POP 화면 뷰어 | `app/(main)/screens/[screenId]/page.tsx` 참고 | +| `lib/registry/PopComponentRegistry.ts` | POP 컴포넌트 등록 | `ComponentRegistry.ts` 구조 동일 | +| `lib/registry/pop-components/*` | POP 전용 컴포넌트 | 신규 개발 | +| `lib/schemas/popComponentConfig.ts` | POP Zod 스키마 | `componentConfig.ts` 구조 동일 | +| `lib/utils/layoutPopConverter.ts` | POP 레이아웃 변환 | `layoutV2Converter.ts` 구조 동일 | +| `components/pop/PopScreenDesigner.tsx` | POP 화면 설계 | 신규 개발 | +| `components/pop/PopDynamicRenderer.tsx` | POP 동적 렌더러 | `DynamicComponentRenderer.tsx` 참고 | + +#### B. 수정 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `lib/api/screen.ts` | `getLayoutPop()`, `saveLayoutPop()` 함수 추가 | +| `app/(pop)/layout.tsx` | POP 전용 레이아웃 스타일 적용 | + +#### C. 유지 파일 (변경 없음) + +| 파일 | 이유 | +|------|------| +| `lib/registry/ComponentRegistry.ts` | 데스크톱 전용, 분리 유지 | +| `lib/schemas/componentConfig.ts` | 데스크톱 전용, 분리 유지 | +| `lib/utils/layoutV2Converter.ts` | 데스크톱 전용, 분리 유지 | +| `app/(main)/*` | 데스크톱 전용, 변경 없음 | + +--- + +## 4. 서비스 로직 흐름 + +### 4-1. 데스크톱 (기존 - 변경 없음) + +``` +[사용자] → /screens/123 접속 + ↓ +[app/(main)/screens/[screenId]/page.tsx] + ↓ +[getLayoutV2(screenId)] → API 호출 + ↓ +[screen_layouts_v2 테이블] → 레이아웃 JSON 반환 + ↓ +[DynamicComponentRenderer] → 컴포넌트 렌더링 + ↓ +[ComponentRegistry] → 컴포넌트 찾기 + ↓ +[lib/registry/components/table-list] → 컴포넌트 실행 + ↓ +[화면 표시] +``` + +### 4-2. POP (신규 - 동일 로직) + +``` +[사용자] → /pop/screens/123 접속 + ↓ +[app/(pop)/screens/[screenId]/page.tsx] + ↓ +[getLayoutPop(screenId)] → API 호출 + ↓ +[screen_layouts_pop 테이블] → 레이아웃 JSON 반환 + ↓ +[PopDynamicRenderer] → 컴포넌트 렌더링 + ↓ +[PopComponentRegistry] → 컴포넌트 찾기 + ↓ +[lib/registry/pop-components/pop-card-list] → 컴포넌트 실행 + ↓ +[화면 표시] +``` + +--- + +## 5. 로직 변경 여부 + +| 구분 | 로직 변경 | 설명 | +|------|----------|------| +| 데이터베이스 CRUD | **없음** | 동일한 SELECT/INSERT/UPDATE 패턴 | +| API 호출 방식 | **없음** | 동일한 REST API 패턴 | +| 컴포넌트 렌더링 | **없음** | 동일한 URL 기반 + overrides 방식 | +| Zod 스키마 검증 | **없음** | 동일한 검증 로직 | +| 레이아웃 JSON 구조 | **없음** | 동일한 V2 JSON 구조 사용 | + +**결론: 로직 변경 없음, 파일/테이블 분리만 진행** + +--- + +## 6. 데스크톱 vs POP 비교 + +| 구분 | Vexplor (데스크톱) | POP (모바일/태블릿) | +|------|-------------------|---------------------| +| **타겟 기기** | PC (마우스+키보드) | 모바일/태블릿 (터치) | +| **화면 크기** | 1920x1080 고정 | 반응형 (다양한 크기) | +| **UI 스타일** | 테이블 중심, 작은 버튼 | 카드 중심, 큰 터치 버튼 | +| **입력 방식** | 키보드 타이핑 | 터치, 스캐너, 음성 | +| **사용 환경** | 사무실 | 현장, 창고, 공장 | +| **레이아웃 테이블** | `screen_layouts_v2` | `screen_layouts_pop` | +| **컴포넌트 경로** | `lib/registry/components/` | `lib/registry/pop-components/` | +| **레지스트리** | `ComponentRegistry.ts` | `PopComponentRegistry.ts` | + +--- + +## 7. 장기 통합 시나리오 + +### Phase 1: 분리 개발 (현재 목표) + +``` +[데스크톱] [POP] +ComponentRegistry PopComponentRegistry +components/ pop-components/ +screen_layouts_v2 screen_layouts_pop +``` + +### Phase 2: 부분 통합 (향후) + +``` +[통합 가능한 부분] +- 공통 유틸리티 함수 +- 공통 Zod 스키마 +- 공통 타입 정의 + +[분리 유지] +- 플랫폼별 컴포넌트 +- 플랫폼별 레이아웃 +``` + +### Phase 3: 완전 통합 (최종) + +``` +[단일 컴포넌트 레지스트리] +ComponentRegistry +├── components/ (공통) +├── desktop-components/ (데스크톱 전용) +└── pop-components/ (POP 전용) + +[단일 레이아웃 테이블] (선택사항) +screen_layouts +├── platform = 'desktop' +└── platform = 'pop' +``` + +--- + +## 8. V2 공통 요소 (통합 핵심) + +POP과 데스크톱이 장기적으로 통합될 수 있는 **핵심 기반**입니다. + +### 8-1. 공통 유틸리티 함수 + +**파일 위치:** `frontend/lib/schemas/componentConfig.ts`, `frontend/lib/utils/layoutV2Converter.ts` + +#### 핵심 병합/추출 함수 (가장 중요!) + +| 함수명 | 역할 | 사용 시점 | +|--------|------|----------| +| `deepMerge()` | 객체 깊은 병합 | 기본값 + overrides 합칠 때 | +| `mergeComponentConfig()` | 기본값 + 커스텀 병합 | **렌더링 시** (화면 표시) | +| `extractCustomConfig()` | 기본값과 다른 부분만 추출 | **저장 시** (DB 저장) | +| `isDeepEqual()` | 두 객체 깊은 비교 | 변경 여부 판단 | + +```typescript +// 예시: 저장 시 차이값만 추출 +const defaults = { showHeader: true, pageSize: 20 }; +const fullConfig = { showHeader: true, pageSize: 50, customField: "test" }; +const overrides = extractCustomConfig(fullConfig, defaults); +// 결과: { pageSize: 50, customField: "test" } (차이값만!) +``` + +#### URL 처리 함수 + +| 함수명 | 역할 | 예시 | +|--------|------|------| +| `getComponentUrl()` | 타입 → URL 변환 | `"v2-table-list"` → `"@/lib/registry/components/v2-table-list"` | +| `getComponentTypeFromUrl()` | URL → 타입 추출 | `"@/lib/registry/components/v2-table-list"` → `"v2-table-list"` | + +#### 기본값 조회 함수 + +| 함수명 | 역할 | +|--------|------| +| `getComponentDefaults()` | 컴포넌트 타입으로 기본값 조회 | +| `getDefaultsByUrl()` | URL로 기본값 조회 | + +#### V2 로드/저장 함수 (핵심!) + +| 함수명 | 역할 | 사용 시점 | +|--------|------|----------| +| `loadComponentV2()` | 컴포넌트 로드 (기본값 병합) | DB → 화면 | +| `saveComponentV2()` | 컴포넌트 저장 (차이값 추출) | 화면 → DB | +| `loadLayoutV2()` | 레이아웃 전체 로드 | DB → 화면 | +| `saveLayoutV2()` | 레이아웃 전체 저장 | 화면 → DB | + +#### 변환 함수 + +| 함수명 | 역할 | +|--------|------| +| `convertV2ToLegacy()` | V2 → Legacy 변환 (하위 호환) | +| `convertLegacyToV2()` | Legacy → V2 변환 | +| `isValidV2Layout()` | V2 레이아웃인지 검증 | +| `isLegacyLayout()` | 레거시 레이아웃인지 확인 | + +### 8-2. 공통 Zod 스키마 + +**파일 위치:** `frontend/lib/schemas/componentConfig.ts` + +#### 핵심 스키마 (필수!) + +```typescript +// 컴포넌트 기본 구조 +export const componentV2Schema = z.object({ + id: z.string(), + url: z.string(), + position: z.object({ x: z.number(), y: z.number() }), + size: z.object({ width: z.number(), height: z.number() }), + displayOrder: z.number().default(0), + overrides: z.record(z.string(), z.any()).default({}), +}); + +// 레이아웃 기본 구조 +export const layoutV2Schema = z.object({ + version: z.string().default("2.0"), + components: z.array(componentV2Schema).default([]), + updatedAt: z.string().optional(), + screenResolution: z.object({...}).optional(), + gridSettings: z.any().optional(), +}); +``` + +#### 컴포넌트별 overrides 스키마 (25개+) + +| 스키마명 | 컴포넌트 | 주요 기본값 | +|----------|----------|------------| +| `v2TableListOverridesSchema` | 테이블 리스트 | displayMode: "table", pageSize: 20 | +| `v2ButtonPrimaryOverridesSchema` | 버튼 | text: "저장", variant: "primary" | +| `v2SplitPanelLayoutOverridesSchema` | 분할 레이아웃 | splitRatio: 30, resizable: true | +| `v2SectionCardOverridesSchema` | 섹션 카드 | padding: "md", collapsible: false | +| `v2TabsWidgetOverridesSchema` | 탭 위젯 | orientation: "horizontal" | +| `v2RepeaterOverridesSchema` | 리피터 | renderMode: "inline" | + +#### 스키마 레지스트리 (자동 매핑) + +```typescript +const componentOverridesSchemaRegistry = { + "v2-table-list": v2TableListOverridesSchema, + "v2-button-primary": v2ButtonPrimaryOverridesSchema, + "v2-split-panel-layout": v2SplitPanelLayoutOverridesSchema, + // ... 25개+ 컴포넌트 +}; +``` + +### 8-3. 공통 타입 정의 + +**파일 위치:** `frontend/types/v2-core.ts`, `frontend/types/v2-components.ts` + +#### 핵심 공통 타입 (v2-core.ts) + +```typescript +// 웹 입력 타입 +export type WebType = + | "text" | "textarea" | "email" | "tel" | "url" + | "number" | "decimal" + | "date" | "datetime" + | "select" | "dropdown" | "radio" | "checkbox" | "boolean" + | "code" | "entity" | "file" | "image" | "button" + | "container" | "group" | "list" | "tree" | "custom"; + +// 버튼 액션 타입 +export type ButtonActionType = + | "save" | "cancel" | "delete" | "edit" | "copy" | "add" + | "search" | "reset" | "submit" + | "close" | "popup" | "modal" + | "navigate" | "newWindow" + | "control" | "transferData" | "quickInsert"; + +// 위치/크기 +export interface Position { x: number; y: number; z?: number; } +export interface Size { width: number; height: number; } + +// 공통 스타일 +export interface CommonStyle { + margin?: string; + padding?: string; + border?: string; + backgroundColor?: string; + color?: string; + fontSize?: string; + // ... 30개+ 속성 +} + +// 유효성 검사 +export interface ValidationRule { + type: "required" | "minLength" | "maxLength" | "pattern" | "min" | "max" | "email" | "url"; + value?: unknown; + message: string; +} +``` + +#### V2 컴포넌트 타입 (v2-components.ts) + +```typescript +// 10개 통합 컴포넌트 타입 +export type V2ComponentType = + | "V2Input" | "V2Select" | "V2Date" | "V2Text" | "V2Media" + | "V2List" | "V2Layout" | "V2Group" | "V2Biz" | "V2Hierarchy"; + +// 공통 속성 +export interface V2BaseProps { + id: string; + label?: string; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + tableName?: string; + columnName?: string; + position?: Position; + size?: Size; + style?: CommonStyle; + validation?: ValidationRule[]; +} +``` + +### 8-4. POP 통합 시 공유/분리 기준 + +#### 반드시 공유 (그대로 사용) + +| 구분 | 파일/요소 | 이유 | +|------|----------|------| +| **유틸리티** | `deepMerge`, `extractCustomConfig`, `mergeComponentConfig` | 저장/로드 로직 동일 | +| **스키마** | `componentV2Schema`, `layoutV2Schema` | JSON 구조 동일 | +| **타입** | `Position`, `Size`, `WebType`, `ButtonActionType` | 기본 구조 동일 | + +#### POP 전용으로 분리 + +| 구분 | 파일/요소 | 이유 | +|------|----------|------| +| **overrides 스키마** | `popCardListOverridesSchema` 등 | POP 컴포넌트 전용 기본값 | +| **스키마 레지스트리** | `popComponentOverridesSchemaRegistry` | POP 컴포넌트 매핑 | +| **기본값 레지스트리** | `popComponentDefaultsRegistry` | POP 컴포넌트 기본값 | + +### 8-5. 추천 폴더 구조 (공유 분리) + +``` +frontend/lib/schemas/ +├── componentConfig.ts # 기존 (데스크톱) +├── popComponentConfig.ts # 신규 (POP) - 구조는 동일 +└── shared/ # 신규 (공유) - 향후 통합 시 + ├── baseSchemas.ts # componentV2Schema, layoutV2Schema + ├── mergeUtils.ts # deepMerge, extractCustomConfig 등 + └── types.ts # Position, Size 등 +``` + +--- + +## 9. 작업 우선순위 + +### [ ] 1단계: 데이터베이스 + +- [ ] `screen_layouts_pop` 테이블 생성 마이그레이션 작성 +- [ ] 마이그레이션 실행 및 검증 + +### [ ] 2단계: 백엔드 API + +- [ ] `screenManagementService.ts`에 POP 함수 추가 + - [ ] `getScreenLayoutPop()` + - [ ] `saveLayoutPop()` + - [ ] `deleteLayoutPop()` +- [ ] `screenManagementRoutes.ts`에 엔드포인트 추가 + - [ ] `GET /screens/:screenId/layout-pop` + - [ ] `POST /screens/:screenId/layout-pop` + - [ ] `DELETE /screens/:screenId/layout-pop` + +### [ ] 3단계: 프론트엔드 기반 + +- [ ] `lib/api/screen.ts`에 POP API 함수 추가 + - [ ] `getLayoutPop()` + - [ ] `saveLayoutPop()` +- [ ] `lib/registry/PopComponentRegistry.ts` 생성 +- [ ] `lib/schemas/popComponentConfig.ts` 생성 +- [ ] `lib/utils/layoutPopConverter.ts` 생성 + +### [ ] 4단계: POP 컴포넌트 개발 + +- [ ] `lib/registry/pop-components/` 폴더 구조 생성 +- [ ] 기본 컴포넌트 개발 + - [ ] `pop-card-list` (카드형 리스트) + - [ ] `pop-touch-button` (터치 버튼) + - [ ] `pop-scanner-input` (스캐너 입력) + - [ ] `pop-status-badge` (상태 배지) + +### [ ] 5단계: POP 화면 페이지 + +- [ ] `app/(pop)/screens/[screenId]/page.tsx` 생성 +- [ ] `components/pop/PopDynamicRenderer.tsx` 생성 +- [ ] `app/(pop)/layout.tsx` 수정 (POP 전용 스타일) + +### [ ] 6단계: POP 화면 디자이너 (선택) + +- [ ] `components/pop/PopScreenDesigner.tsx` 생성 +- [ ] `components/pop/PopPreview.tsx` 생성 +- [ ] 관리자 메뉴에 POP 화면 설계 기능 추가 + +--- + +## 10. 참고 파일 위치 + +### 데스크톱 참고 파일 (기존) + +| 구분 | 파일 경로 | +|------|----------| +| 화면 페이지 | `frontend/app/(main)/screens/[screenId]/page.tsx` | +| 컴포넌트 레지스트리 | `frontend/lib/registry/ComponentRegistry.ts` | +| 동적 렌더러 | `frontend/lib/registry/DynamicComponentRenderer.tsx` | +| Zod 스키마 | `frontend/lib/schemas/componentConfig.ts` | +| 레이아웃 변환기 | `frontend/lib/utils/layoutV2Converter.ts` | +| 화면 API | `frontend/lib/api/screen.ts` | +| 백엔드 서비스 | `backend-node/src/services/screenManagementService.ts` | +| 백엔드 라우트 | `backend-node/src/routes/screenManagementRoutes.ts` | + +### 관련 문서 + +| 문서 | 경로 | +|------|------| +| V2 아키텍처 | `docs/DDD1542/COMPONENT_LAYOUT_V2_ARCHITECTURE.md` | +| 화면관리 설계 | `docs/kjs/화면관리_시스템_설계.md` | + +--- + +## 11. 주의사항 + +### 멀티테넌시 + +- 모든 테이블에 `company_code` 필수 +- 모든 쿼리에 `company_code` 필터링 적용 +- 최고 관리자(`company_code = "*"`)는 모든 데이터 조회 가능 + +### 충돌 방지 + +- 기존 데스크톱 파일 수정 최소화 +- POP 전용 폴더/파일에서 작업 +- 공통 로직은 별도 유틸리티로 분리 + +### 테스트 + +- 데스크톱 기능 회귀 테스트 필수 +- POP 반응형 테스트 (모바일/태블릿) +- 멀티테넌시 격리 테스트 + +--- + +## 변경 이력 + +| 날짜 | 버전 | 내용 | +|------|------|------| +| 2026-01-29 | 1.0 | 초기 계획서 작성 | +| 2026-01-29 | 1.1 | V2 공통 요소 (통합 핵심) 섹션 추가 | + +--- + +## 작성자 + +- 작성일: 2026-01-29 +- 프로젝트: Vexplor POP 화면 시스템 diff --git a/POPUPDATE.md b/POPUPDATE.md new file mode 100644 index 00000000..139cd8f9 --- /dev/null +++ b/POPUPDATE.md @@ -0,0 +1,630 @@ +# 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 +// 변경 전 + + +// 변경 후 + +``` + +상태 업데이트는 드래그/리사이즈 완료 후에만 실행 + +--- + +*최종 업데이트: 2026-02-02* diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 83dd2b32..53ff1b96 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -732,6 +732,90 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) => } }; +// ======================================== +// POP 레이아웃 관리 (모바일/태블릿) +// ======================================== + +// POP 레이아웃 조회 +export const getLayoutPop = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode, userType } = req.user as any; + const layout = await screenManagementService.getLayoutPop( + parseInt(screenId), + companyCode, + userType + ); + res.json({ success: true, data: layout }); + } catch (error) { + console.error("POP 레이아웃 조회 실패:", error); + res + .status(500) + .json({ success: false, message: "POP 레이아웃 조회에 실패했습니다." }); + } +}; + +// POP 레이아웃 저장 +export const saveLayoutPop = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode, userId } = req.user as any; + const layoutData = req.body; + + await screenManagementService.saveLayoutPop( + parseInt(screenId), + layoutData, + companyCode, + userId + ); + res.json({ success: true, message: "POP 레이아웃이 저장되었습니다." }); + } catch (error) { + console.error("POP 레이아웃 저장 실패:", error); + res + .status(500) + .json({ success: false, message: "POP 레이아웃 저장에 실패했습니다." }); + } +}; + +// POP 레이아웃 삭제 +export const deleteLayoutPop = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + + await screenManagementService.deleteLayoutPop( + parseInt(screenId), + companyCode + ); + res.json({ success: true, message: "POP 레이아웃이 삭제되었습니다." }); + } catch (error) { + console.error("POP 레이아웃 삭제 실패:", error); + res + .status(500) + .json({ success: false, message: "POP 레이아웃 삭제에 실패했습니다." }); + } +}; + +// POP 레이아웃 존재하는 화면 ID 목록 조회 +export const getScreenIdsWithPopLayout = async (req: AuthenticatedRequest, res: Response) => { + try { + const { companyCode } = req.user as any; + + const screenIds = await screenManagementService.getScreenIdsWithPopLayout(companyCode); + + res.json({ + success: true, + data: screenIds, + count: screenIds.length + }); + } catch (error) { + console.error("POP 레이아웃 화면 ID 목록 조회 실패:", error); + res + .status(500) + .json({ success: false, message: "POP 레이아웃 화면 ID 목록 조회에 실패했습니다." }); + } +}; + // 화면 코드 자동 생성 export const generateScreenCode = async ( req: AuthenticatedRequest, diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 3ca20366..456a74a0 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -26,6 +26,10 @@ import { getLayoutV1, getLayoutV2, saveLayoutV2, + getLayoutPop, + saveLayoutPop, + deleteLayoutPop, + getScreenIdsWithPopLayout, generateScreenCode, generateMultipleScreenCodes, assignScreenToMenu, @@ -84,6 +88,12 @@ router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url + router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides) router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장 +// POP 레이아웃 관리 (모바일/태블릿) +router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회 +router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장 +router.delete("/screens/:screenId/layout-pop", deleteLayoutPop); // POP: 레이아웃 삭제 +router.get("/pop-layout-screen-ids", getScreenIdsWithPopLayout); // POP: 레이아웃 존재하는 화면 ID 목록 + // 메뉴-화면 할당 관리 router.post("/screens/:screenId/assign-menu", assignScreenToMenu); router.get("/menus/:menuObjid/screens", getScreensByMenu); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 52ed357b..96ee11d2 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -4707,6 +4707,212 @@ export class ScreenManagementService { console.log(`V2 레이아웃 저장 완료`); } + + // ======================================== + // POP 레이아웃 관리 (모바일/태블릿) + // ======================================== + + /** + * POP 레이아웃 조회 + * - screen_layouts_pop 테이블에서 화면당 1개 레코드 조회 + * - V2와 동일한 로직, 테이블명만 다름 + */ + async getLayoutPop( + screenId: number, + companyCode: string, + userType?: string, + ): Promise { + console.log(`=== POP 레이아웃 로드 시작 ===`); + console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`); + + // SUPER_ADMIN 여부 확인 + const isSuperAdmin = userType === "SUPER_ADMIN"; + + // 권한 확인 + const screens = await query<{ + company_code: string | null; + table_name: string | null; + }>( + `SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId], + ); + + if (screens.length === 0) { + return null; + } + + const existingScreen = screens[0]; + + // SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음 + if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) { + throw new Error("이 화면의 POP 레이아웃을 조회할 권한이 없습니다."); + } + + let layout: { layout_data: any } | null = null; + + // SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회 + if (isSuperAdmin) { + // 1. 화면 정의의 회사 코드로 레이아웃 조회 + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = $2`, + [screenId, existingScreen.company_code], + ); + + // 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회 + if (!layout) { + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 + ORDER BY updated_at DESC + LIMIT 1`, + [screenId], + ); + } + } else { + // 일반 사용자: 회사별 우선, 없으면 공통(*) 조회 + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + + // 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회 + if (!layout && companyCode !== "*") { + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = '*'`, + [screenId], + ); + } + } + + if (!layout) { + console.log(`POP 레이아웃 없음: screen_id=${screenId}`); + return null; + } + + console.log( + `POP 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`, + ); + return layout.layout_data; + } + + /** + * POP 레이아웃 저장 + * - screen_layouts_pop 테이블에 화면당 1개 레코드 저장 + * - V2와 동일한 로직, 테이블명만 다름 + */ + async saveLayoutPop( + screenId: number, + layoutData: any, + companyCode: string, + userId?: string, + ): Promise { + console.log(`=== POP 레이아웃 저장 시작 ===`); + console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); + console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`); + + // 권한 확인 + const screens = await query<{ company_code: string | null }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId], + ); + + if (screens.length === 0) { + throw new Error("화면을 찾을 수 없습니다."); + } + + const existingScreen = screens[0]; + + if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다."); + } + + // 버전 정보 추가 + const dataToSave = { + version: "2.0", + ...layoutData + }; + + // UPSERT (있으면 업데이트, 없으면 삽입) + await query( + `INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by) + VALUES ($1, $2, $3, NOW(), NOW(), $4, $4) + ON CONFLICT (screen_id, company_code) + DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`, + [screenId, companyCode, JSON.stringify(dataToSave), userId || null], + ); + + console.log(`POP 레이아웃 저장 완료`); + } + + /** + * POP 레이아웃이 존재하는 화면 ID 목록 조회 + * - 옵션 B: POP 레이아웃 존재 여부로 화면 구분 + */ + async getScreenIdsWithPopLayout( + companyCode: string, + ): Promise { + console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`); + console.log(`회사 코드: ${companyCode}`); + + let result: { screen_id: number }[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 POP 레이아웃 조회 + result = await query<{ screen_id: number }>( + `SELECT DISTINCT screen_id FROM screen_layouts_pop`, + [], + ); + } else { + // 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회 + result = await query<{ screen_id: number }>( + `SELECT DISTINCT screen_id FROM screen_layouts_pop + WHERE company_code = $1 OR company_code = '*'`, + [companyCode], + ); + } + + const screenIds = result.map((r) => r.screen_id); + console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}개`); + return screenIds; + } + + /** + * POP 레이아웃 삭제 + */ + async deleteLayoutPop( + screenId: number, + companyCode: string, + ): Promise { + console.log(`=== POP 레이아웃 삭제 시작 ===`); + console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); + + // 권한 확인 + const screens = await query<{ company_code: string | null }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId], + ); + + if (screens.length === 0) { + throw new Error("화면을 찾을 수 없습니다."); + } + + const existingScreen = screens[0]; + + if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + throw new Error("이 화면의 POP 레이아웃을 삭제할 권한이 없습니다."); + } + + const result = await query( + `DELETE FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + + console.log(`POP 레이아웃 삭제 완료`); + return true; + } } // 서비스 인스턴스 export diff --git a/docs/pop/PROJECT_ARCHITECTURE.md b/docs/pop/PROJECT_ARCHITECTURE.md new file mode 100644 index 00000000..cb3752fd --- /dev/null +++ b/docs/pop/PROJECT_ARCHITECTURE.md @@ -0,0 +1,285 @@ +# VEXPLOR (WACE 솔루션) 프로젝트 아키텍처 + +> AI 에이전트 안내: Quick Reference 먼저 확인 후 필요한 섹션만 참조 + +--- + +## Quick Reference + +### 기술 스택 요약 + +| 영역 | 기술 | +|------|------| +| Frontend | Next.js 14, TypeScript, shadcn/ui, Tailwind CSS | +| Backend | Node.js + Express (주력), Java Spring (레거시) | +| Database | PostgreSQL (173개 테이블) | +| 핵심 기능 | 노코드 화면 빌더, 멀티테넌시, 워크플로우 | + +### 디렉토리 맵 + +``` +ERP-node/ +├── frontend/ # Next.js +│ ├── app/ # 라우팅 (main, auth, pop) +│ ├── components/ # UI 컴포넌트 (281개+) +│ └── lib/ # API, 레지스트리 (463개) +├── backend-node/ # Node.js 백엔드 +│ └── src/ +│ ├── controllers/ # 68개 +│ ├── services/ # 78개 +│ └── routes/ # 47개 +├── db/ # 마이그레이션 +└── docs/ # 문서 +``` + +### 핵심 테이블 + +| 분류 | 테이블 | +|------|--------| +| 화면 | screen_definitions, screen_layouts_v2, screen_layouts_pop | +| 메뉴 | menu_info, authority_master | +| 사용자 | user_info, company_mng | +| 플로우 | flow_definition, flow_step | + +--- + +## 1. 프로젝트 개요 + +### 1.1 제품 소개 +- **WACE 솔루션**: PLM(Product Lifecycle Management) + 노코드 화면 빌더 +- **멀티테넌시**: company_code 기반 회사별 데이터 격리 +- **마이그레이션**: JSP에서 Next.js로 완전 전환 + +### 1.2 핵심 기능 +1. **Screen Designer**: 드래그앤드롭 화면 구성 +2. **워크플로우**: 플로우 기반 업무 자동화 +3. **배치 시스템**: 스케줄 기반 작업 자동화 +4. **외부 연동**: DB, REST API 통합 +5. **리포트**: 동적 보고서 생성 +6. **다국어**: i18n 지원 + +--- + +## 2. Frontend 구조 + +### 2.1 라우트 그룹 + +``` +app/ +├── (main)/ # 메인 레이아웃 +│ ├── admin/ # 관리자 기능 +│ │ ├── screenMng/ # 화면 관리 +│ │ ├── systemMng/ # 시스템 관리 +│ │ ├── userMng/ # 사용자 관리 +│ │ └── automaticMng/ # 자동화 관리 +│ ├── screens/[screenId]/ # 동적 화면 뷰어 +│ └── dashboard/[id]/ # 대시보드 뷰어 +├── (auth)/ # 인증 +│ └── login/ +└── (pop)/ # POP 전용 + └── pop/screens/[screenId]/ +``` + +### 2.2 주요 컴포넌트 + +| 폴더 | 파일 수 | 역할 | +|------|---------|------| +| screen/ | 70+ | 화면 디자이너, 위젯 | +| admin/ | 137 | 테이블, 메뉴, 코드 관리 | +| dataflow/ | 101 | 노드 기반 플로우 에디터 | +| dashboard/ | 32 | 대시보드 빌더 | +| v2/ | 20+ | V2 컴포넌트 시스템 | +| pop/ | 26 | POP 전용 컴포넌트 | + +### 2.3 라이브러리 (lib/) + +``` +lib/ +├── api/ # API 클라이언트 (50개+) +│ ├── screen.ts # 화면 API +│ ├── menu.ts # 메뉴 API +│ └── flow.ts # 플로우 API +├── registry/ # 컴포넌트 레지스트리 (463개) +│ ├── DynamicComponentRenderer.tsx +│ └── components/ +├── v2-core/ # Zod 기반 타입 시스템 +└── utils/ # 유틸리티 (30개+) +``` + +--- + +## 3. Backend 구조 + +### 3.1 디렉토리 + +``` +backend-node/src/ +├── controllers/ # 68개 컨트롤러 +├── services/ # 78개 서비스 +├── routes/ # 47개 라우트 +├── middleware/ # 인증, 에러 처리 +├── database/ # DB 연결 +├── types/ # 26개 타입 정의 +└── utils/ # 16개 유틸 +``` + +### 3.2 주요 서비스 + +| 영역 | 서비스 | +|------|--------| +| 화면 | screenManagementService, layoutService | +| 데이터 | dataService, tableManagementService | +| 플로우 | flowDefinitionService, flowExecutionService | +| 배치 | batchService, batchSchedulerService | +| 외부연동 | externalDbConnectionService, externalCallService | + +### 3.3 API 엔드포인트 + +``` +# 화면 관리 +GET /api/screen-management/screens +GET /api/screen-management/screen/:id +POST /api/screen-management/screen +PUT /api/screen-management/screen/:id +GET /api/screen-management/layout-v2/:screenId +POST /api/screen-management/layout-v2/:screenId +GET /api/screen-management/layout-pop/:screenId +POST /api/screen-management/layout-pop/:screenId + +# 데이터 CRUD +GET /api/data/:tableName +POST /api/data/:tableName +PUT /api/data/:tableName/:id +DELETE /api/data/:tableName/:id + +# 인증 +POST /api/auth/login +POST /api/auth/logout +GET /api/auth/me +``` + +--- + +## 4. Database 구조 + +### 4.1 테이블 분류 (173개) + +| 분류 | 개수 | 주요 테이블 | +|------|------|-------------| +| 화면/레이아웃 | 15 | screen_definitions, screen_layouts_v2, screen_layouts_pop | +| 메뉴/권한 | 7 | menu_info, authority_master, rel_menu_auth | +| 사용자/회사 | 6 | user_info, company_mng, dept_info | +| 테이블/컬럼 | 8 | table_type_columns, column_labels | +| 다국어 | 4 | multi_lang_key_master, multi_lang_text | +| 플로우/배치 | 12 | flow_definition, flow_step, batch_configs | +| 외부연동 | 4 | external_db_connections, external_call_configs | +| 리포트 | 5 | report_master, report_layout, report_query | +| 대시보드 | 2 | dashboards, dashboard_elements | +| 컴포넌트 | 6 | component_standards, web_type_standards | +| 비즈니스 | 100+ | item_info, sales_order_mng, inventory_stock | + +### 4.2 화면 관련 테이블 상세 + +```sql +-- 화면 정의 +screen_definitions: screen_id, screen_name, table_name, company_code + +-- 데스크톱 레이아웃 (V2, 현재 사용) +screen_layouts_v2: id, screen_id, components(JSONB), grid_settings + +-- POP 레이아웃 +screen_layouts_pop: id, screen_id, components(JSONB), grid_settings + +-- 화면 그룹 +screen_groups: group_id, group_name, company_code +screen_group_screens: id, group_id, screen_id +``` + +### 4.3 멀티테넌시 + +```sql +-- 모든 테이블에 company_code 필수 +ALTER TABLE example_table ADD COLUMN company_code VARCHAR(20) NOT NULL; + +-- 모든 쿼리에 company_code 필터링 필수 +SELECT * FROM example_table WHERE company_code = $1; + +-- 예외: company_mng (회사 마스터 테이블) +``` + +--- + +## 5. 핵심 기능 상세 + +### 5.1 노코드 Screen Designer + +**아키텍처**: +``` +screen_definitions (화면 정의) + ↓ +screen_layouts_v2 (레이아웃, JSONB) + ↓ +DynamicComponentRenderer (동적 렌더링) + ↓ +registry/components (컴포넌트 라이브러리) +``` + +**컴포넌트 레지스트리**: +- V2 컴포넌트: Input, Select, Table, Button 등 +- 위젯: FlowWidget, CategoryWidget 등 +- 레이아웃: SplitPanel, TabPanel 등 + +### 5.2 워크플로우 (Flow) + +**테이블 구조**: +``` +flow_definition (플로우 정의) + ↓ +flow_step (단계 정의) + ↓ +flow_step_connection (단계 연결) + ↓ +flow_data_status (데이터 상태 추적) +``` + +### 5.3 POP 시스템 + +**별도 레이아웃 테이블**: +- `screen_layouts_pop`: POP 전용 레이아웃 +- 모바일/태블릿 반응형 지원 +- 제조 현장 최적화 컴포넌트 + +--- + +## 6. 개발 환경 + +### 6.1 로컬 개발 + +```bash +# Docker 실행 +docker-compose -f docker-compose.win.yml up -d + +# 프론트엔드: http://localhost:9771 +# 백엔드: http://localhost:8080 +``` + +### 6.2 데이터베이스 + +``` +Host: 39.117.244.52 +Port: 11132 +Database: plm +Username: postgres +``` + +--- + +## 7. 관련 문서 + +- [POPUPDATE.md](../POPUPDATE.md): POP 개발 기록 +- [docs/pop/components-spec.md](pop/components-spec.md): POP 컴포넌트 설계 +- [.cursorrules](../.cursorrules): 개발 가이드라인 + +--- + +*최종 업데이트: 2026-01-29* diff --git a/docs/pop/components-spec.md b/docs/pop/components-spec.md new file mode 100644 index 00000000..e12195ce --- /dev/null +++ b/docs/pop/components-spec.md @@ -0,0 +1,290 @@ +# POP 컴포넌트 상세 설계서 + +> AI 에이전트 안내: Quick Reference 먼저 확인 후 필요한 섹션만 참조 + +--- + +## Quick Reference + +### 총 컴포넌트 수: 13개 + +| 분류 | 개수 | 컴포넌트 | +|------|------|----------| +| 레이아웃 | 2 | container, tab-panel | +| 데이터 표시 | 4 | data-table, card-list, kpi-gauge, status-indicator | +| 입력 | 4 | number-pad, barcode-scanner, form-field, action-button | +| 특화 기능 | 3 | timer, alarm-list, process-flow | + +### 개발 우선순위 + +1단계: number-pad, status-indicator, kpi-gauge, action-button +2단계: data-table, card-list, barcode-scanner, timer +3단계: container, tab-panel, form-field, alarm-list, process-flow + +### POP UI 필수 원칙 + +- 버튼 최소 크기: 48px (1.5cm) +- 고대비 테마 지원 +- 단순 탭 조작 (스와이프 최소화) +- 알람에만 원색 사용 +- 숫자 우측 정렬 + +--- + +## 컴포넌트 목록 + +| # | 컴포넌트 | 역할 | +|---|----------|------| +| 1 | pop-container | 레이아웃 뼈대 | +| 2 | pop-tab-panel | 정보 분류 | +| 3 | pop-data-table | 대량 데이터 | +| 4 | pop-card-list | 시각적 목록 | +| 5 | pop-kpi-gauge | 목표 달성률 | +| 6 | pop-status-indicator | 상태 표시 | +| 7 | pop-number-pad | 수량 입력 | +| 8 | pop-barcode-scanner | 스캔 입력 | +| 9 | pop-form-field | 범용 입력 | +| 10 | pop-action-button | 작업 실행 | +| 11 | pop-timer | 시간 측정 | +| 12 | pop-alarm-list | 알람 관리 | +| 13 | pop-process-flow | 공정 현황 | + +--- + +## 1. pop-container + +역할: 모든 컴포넌트의 부모, 화면 뼈대 + +| 기능 | 설명 | +|------|------| +| 반응형 그리드 | 모바일/태블릿 자동 대응 | +| 플렉스 방향 | row, column, wrap | +| 간격 설정 | gap, padding | +| 배경/테두리 | 색상, 둥근모서리 | +| 스크롤 설정 | 가로/세로/없음 | + +--- + +## 2. pop-tab-panel + +역할: 정보 분류, 화면 공간 효율화 + +| 기능 | 설명 | +|------|------| +| 탭 모드 | 상단/하단/좌측 탭 | +| 아코디언 모드 | 접기/펼치기 | +| 아이콘 지원 | 탭별 아이콘 | +| 뱃지 표시 | 알림 개수 표시 | +| 기본 활성 탭 | 초기 선택 설정 | + +--- + +## 3. pop-data-table + +역할: 대량 데이터 표시, 선택, 편집 + +| 기능 | 설명 | +|------|------| +| 가상 스크롤 | 대량 데이터 성능 | +| 행 선택 | 단일/다중 선택 | +| 인라인 편집 | 셀 직접 수정 | +| 정렬/필터 | 컬럼별 정렬, 검색 | +| 고정 컬럼 | 좌측 컬럼 고정 | +| 행 색상 조건 | 상태별 배경색 | +| 큰 글씨 모드 | POP 전용 가독성 | + +--- + +## 4. pop-card-list + +역할: 시각적 강조가 필요한 목록 + +| 기능 | 설명 | +|------|------| +| 카드 레이아웃 | 1열/2열/3열 | +| 이미지 표시 | 부품/제품 사진 | +| 상태 뱃지 | 진행중/완료/대기 | +| 진행률 바 | 작업 진척도 | +| 스와이프 액션 | 완료/삭제 (선택적) | +| 클릭 이벤트 | 상세 모달 연결 | + +--- + +## 5. pop-kpi-gauge + +역할: 목표 대비 실적 시각화 + +| 기능 | 설명 | +|------|------| +| 게이지 타입 | 원형/반원형/수평바 | +| 목표값 | 기준선 표시 | +| 현재값 | 실시간 바인딩 | +| 색상 구간 | 위험/경고/정상 | +| 단위 표시 | %, 개, 초 등 | +| 애니메이션 | 값 변경 시 전환 | +| 라벨 | 제목, 부제목 | + +--- + +## 6. pop-status-indicator + +역할: 설비/공정 상태 즉시 파악 + +| 기능 | 설명 | +|------|------| +| 표시 타입 | 원형/사각/아이콘 | +| 상태 매핑 | 값 -> 색상/아이콘 자동 | +| 색상 설정 | 상태별 커스텀 | +| 크기 | S/M/L/XL | +| 깜빡임 | 알람 상태 강조 | +| 라벨 위치 | 상단/하단/우측 | +| 그룹 표시 | 여러 상태 한 줄 | + +--- + +## 7. pop-number-pad + +역할: 장갑 착용 상태 수량 입력 + +| 기능 | 설명 | +|------|------| +| 큰 버튼 | 최소 48px (1.5cm) | +| 레이아웃 | 전화기식/계산기식 | +| 소수점 | 허용/불허 | +| 음수 | 허용/불허 | +| 최소/최대값 | 범위 제한 | +| 단위 표시 | 개, kg, m 등 | +| 빠른 증감 | +1, +10, +100 버튼 | +| 진동 피드백 | 터치 확인 | +| 클리어 | 전체 삭제, 한 자리 삭제 | + +--- + +## 8. pop-barcode-scanner + +역할: 자재 투입, 로트 추적 + +| 기능 | 설명 | +|------|------| +| 카메라 스캔 | 모바일 카메라 연동 | +| 외부 스캐너 | USB/블루투스 연동 | +| 스캔 타입 | 바코드/QR/RFID | +| 연속 스캔 | 다중 입력 모드 | +| 이력 표시 | 최근 스캔 목록 | +| 유효성 검증 | 포맷 체크 | +| 자동 조회 | 스캔 후 API 연동 | +| 소리/진동 | 스캔 성공 피드백 | + +--- + +## 9. pop-form-field + +역할: 텍스트, 선택, 날짜 등 범용 입력 + +| 기능 | 설명 | +|------|------| +| 입력 타입 | text/number/date/time/select | +| 라벨 | 상단/좌측/플로팅 | +| 필수 표시 | 별표, 색상 | +| 유효성 검증 | 실시간 체크 | +| 에러 메시지 | 하단 표시 | +| 비활성화 | 읽기 전용 모드 | +| 큰 사이즈 | POP 전용 높이 | +| 도움말 | 툴팁/하단 설명 | + +--- + +## 10. pop-action-button + +역할: 작업 실행, 상태 변경 + +| 기능 | 설명 | +|------|------| +| 크기 | S/M/L/XL/전체너비 | +| 스타일 | primary/secondary/danger/success | +| 아이콘 | 좌측/우측/단독 | +| 로딩 상태 | 스피너 표시 | +| 비활성화 | 조건부 | +| 확인 다이얼로그 | 위험 작업 전 확인 | +| 길게 누르기 | 특수 동작 (선택적) | +| 뱃지 | 개수 표시 | + +--- + +## 11. pop-timer + +역할: 사이클 타임, 비가동 시간 측정 + +| 기능 | 설명 | +|------|------| +| 모드 | 스톱워치/카운트다운 | +| 시작/정지/리셋 | 기본 제어 | +| 랩 타임 | 구간 기록 | +| 목표 시간 | 초과 시 알림 | +| 표시 형식 | HH:MM:SS / MM:SS | +| 크기 | 작은/중간/큰 | +| 배경 색상 | 상태별 변경 | +| 자동 시작 | 조건부 트리거 | + +--- + +## 12. pop-alarm-list + +역할: 이상 상황 알림 및 확인 + +| 기능 | 설명 | +|------|------| +| 우선순위 | 긴급/경고/정보 | +| 색상 구분 | 레벨별 배경색 | +| 시간 표시 | 발생 시각 | +| 확인(Ack) | 알람 인지 처리 | +| 필터 | 미확인만/전체 | +| 정렬 | 시간순/우선순위순 | +| 상세 보기 | 클릭 시 모달 | +| 소리 알림 | 신규 알람 | + +--- + +## 13. pop-process-flow + +역할: 전체 공정 현황 시각화 + +| 기능 | 설명 | +|------|------| +| 노드 타입 | 공정/검사/대기 | +| 연결선 | 화살표, 분기 | +| 현재 위치 | 강조 표시 | +| 상태 색상 | 완료/진행/대기 | +| 클릭 이벤트 | 공정 상세 이동 | +| 가로/세로 | 방향 설정 | +| 축소/확대 | 핀치 줌 | +| 진행률 | 전체 대비 현재 | + +--- + +## 커버 가능한 시나리오 + +이 13개 컴포넌트 조합으로 대응 가능한 화면: + +- 작업 지시 화면 +- 실적 입력 화면 +- 품질 검사 화면 +- 설비 모니터링 대시보드 +- 자재 투입/출고 화면 +- 알람 관리 화면 +- 공정 현황판 + +--- + +## 기존 컴포넌트 재사용 가능 목록 + +| POP 컴포넌트 | 기존 컴포넌트 | 수정 필요 | +|-------------|--------------|----------| +| pop-data-table | v2-table-widget | 큰 글씨 모드 추가 | +| pop-form-field | v2-input-widget, v2-select-widget | 큰 사이즈 옵션 | +| pop-action-button | v2-button-widget | 크기/확인 다이얼로그 | +| pop-tab-panel | tab-widget | POP 스타일 적용 | + +--- + +*최종 업데이트: 2026-01-29* diff --git a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx new file mode 100644 index 00000000..6477800a --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Plus, RefreshCw, Search, LayoutGrid, LayoutList, Smartphone, Tablet, Eye } from "lucide-react"; +import { PopDesigner } from "@/components/pop/designer"; +import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; +import { ScreenDefinition } from "@/types/screen"; +import { screenApi } from "@/lib/api/screen"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import CreateScreenModal from "@/components/screen/CreateScreenModal"; +import { Badge } from "@/components/ui/badge"; + +// 단계별 진행을 위한 타입 정의 +type Step = "list" | "design"; +type ViewMode = "tree" | "table"; +type DevicePreview = "mobile" | "tablet"; + +export default function PopScreenManagementPage() { + const searchParams = useSearchParams(); + const [currentStep, setCurrentStep] = useState("list"); + const [selectedScreen, setSelectedScreen] = useState(null); + const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null); + const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState(null); + const [stepHistory, setStepHistory] = useState(["list"]); + const [viewMode, setViewMode] = useState("tree"); + const [screens, setScreens] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [devicePreview, setDevicePreview] = useState("tablet"); + + // POP 레이아웃 존재 화면 ID + const [popLayoutScreenIds, setPopLayoutScreenIds] = useState>(new Set()); + + // 화면 목록 및 POP 레이아웃 존재 여부 로드 + const loadScreens = useCallback(async () => { + try { + setLoading(true); + const [result, popScreenIds] = await Promise.all([ + screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" }), + screenApi.getScreenIdsWithPopLayout(), + ]); + + if (result.data && result.data.length > 0) { + setScreens(result.data); + } + setPopLayoutScreenIds(new Set(popScreenIds)); + } catch (error) { + console.error("POP 화면 목록 로드 실패:", error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadScreens(); + }, [loadScreens]); + + // 화면 목록 새로고침 이벤트 리스너 + useEffect(() => { + const handleScreenListRefresh = () => { + console.log("🔄 POP 화면 목록 새로고침 이벤트 수신"); + loadScreens(); + }; + + window.addEventListener("screen-list-refresh", handleScreenListRefresh); + return () => { + window.removeEventListener("screen-list-refresh", handleScreenListRefresh); + }; + }, [loadScreens]); + + // URL 쿼리 파라미터로 화면 디자이너 자동 열기 + useEffect(() => { + const openDesignerId = searchParams.get("openDesigner"); + if (openDesignerId && screens.length > 0) { + const screenId = parseInt(openDesignerId, 10); + const targetScreen = screens.find((s) => s.screenId === screenId); + if (targetScreen) { + setSelectedScreen(targetScreen); + setCurrentStep("design"); + setStepHistory(["list", "design"]); + } + } + }, [searchParams, screens]); + + // 화면 설계 모드일 때는 전체 화면 사용 + const isDesignMode = currentStep === "design"; + + // 다음 단계로 이동 + const goToNextStep = (nextStep: Step) => { + setStepHistory((prev) => [...prev, nextStep]); + setCurrentStep(nextStep); + }; + + // 특정 단계로 이동 + const goToStep = (step: Step) => { + setCurrentStep(step); + const stepIndex = stepHistory.findIndex((s) => s === step); + if (stepIndex !== -1) { + setStepHistory(stepHistory.slice(0, stepIndex + 1)); + } + }; + + // 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제) + const handleScreenSelect = (screen: ScreenDefinition) => { + setSelectedScreen(screen); + setSelectedGroup(null); + }; + + // 화면 디자인 핸들러 + const handleDesignScreen = (screen: ScreenDefinition) => { + setSelectedScreen(screen); + goToNextStep("design"); + }; + + // POP 화면 미리보기 (새 탭에서 열기) + const handlePreviewScreen = (screen: ScreenDefinition) => { + const previewUrl = `/pop/screens/${screen.screenId}?preview=true&device=${devicePreview}`; + window.open(previewUrl, "_blank", "width=800,height=900"); + }; + + // POP 화면만 필터링 (POP 레이아웃이 있는 화면만) + const popScreens = screens.filter((screen) => popLayoutScreenIds.has(screen.screenId)); + + // 검색어 필터링 + const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean); + const filteredScreens = popScreens.filter((screen) => { + if (searchKeywords.length > 1) { + return true; // 폴더 계층 검색 시 화면 필터링 없음 + } + if (!searchTerm) return true; + return ( + screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || + screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }); + + // POP 화면 수 + const popScreenCount = popLayoutScreenIds.size; + + // 화면 설계 모드일 때는 POP 전용 디자이너 사용 + if (isDesignMode && selectedScreen) { + return ( +
+ goToStep("list")} + onScreenUpdate={(updatedFields) => { + setSelectedScreen({ + ...selectedScreen, + ...updatedFields, + }); + }} + /> +
+ ); + } + + return ( +
+ {/* 페이지 헤더 */} +
+
+
+
+
+

POP 화면 관리

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

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

+
+
+
+ {/* 디바이스 미리보기 선택 */} + setDevicePreview(v as DevicePreview)}> + + + + 모바일 + + + + 태블릿 + + + +
+ {/* 뷰 모드 전환 */} + setViewMode(v as ViewMode)}> + + + + 트리 + + + + 테이블 + + + + + +
+
+
+ + {/* 메인 콘텐츠 */} + {popScreenCount === 0 ? ( + // POP 화면이 없을 때 빈 상태 표시 +
+
+ +
+

POP 화면이 없습니다

+

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

+ +
+ ) : viewMode === "tree" ? ( +
+ {/* 왼쪽: POP 화면 목록 */} +
+ {/* 검색 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9 h-9" + /> +
+ {/* POP 화면 수 표시 */} +
+ POP 화면 + + {popScreenCount}개 + +
+
+ {/* POP 화면 리스트 */} +
+ {filteredScreens.length === 0 ? ( +
+ 검색 결과가 없습니다 +
+ ) : ( +
+ {filteredScreens.map((screen) => ( +
handleScreenSelect(screen)} + onDoubleClick={() => handleDesignScreen(screen)} + > +
+
{screen.screenName}
+
+ {screen.screenCode} {screen.tableName && `| ${screen.tableName}`} +
+
+
+ + +
+
+ ))} +
+ )} +
+
+ + {/* 오른쪽: 관계 시각화 (React Flow) */} +
+ +
+
+ ) : ( + // 테이블 뷰 - POP 화면만 표시 +
+
+ + + + + + + + + + + + {filteredScreens.map((screen) => ( + handleScreenSelect(screen)} + > + + + + + + + ))} + +
화면명화면코드테이블명생성일작업
{screen.screenName}{screen.screenCode}{screen.tableName || "-"} + {screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR") : "-"} + +
+ + +
+
+
+
+ )} + + {/* 화면 생성 모달 */} + { + setIsCreateOpen(open); + if (!open) loadScreens(); + }} + onCreated={() => { + setIsCreateOpen(false); + loadScreens(); + }} + isPop={true} + /> + + {/* Scroll to Top 버튼 */} + +
+ ); +} diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx new file mode 100644 index 00000000..31de64bd --- /dev/null +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -0,0 +1,305 @@ +"use client"; + +import React, { useEffect, useState, useMemo } from "react"; +import { useParams, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Loader2, ArrowLeft, Smartphone, Tablet, Monitor, RotateCcw } from "lucide-react"; +import { screenApi } from "@/lib/api/screen"; +import { ScreenDefinition, LayoutData, ComponentData } 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"; +import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext"; +import { ScreenContextProvider } from "@/contexts/ScreenContext"; +import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; +import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; +import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; +import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; + +// POP 디바이스 타입 +type DeviceType = "mobile" | "tablet"; + +// 디바이스별 크기 +const DEVICE_SIZES = { + mobile: { width: 375, height: 812, label: "모바일" }, + tablet: { width: 768, height: 1024, label: "태블릿" }, +}; + +function PopScreenViewPage() { + const params = useParams(); + const searchParams = useSearchParams(); + const router = useRouter(); + const screenId = parseInt(params.screenId as string); + + // URL 쿼리에서 디바이스 타입 가져오기 (기본: tablet) + const deviceParam = searchParams.get("device") as DeviceType | null; + const [deviceType, setDeviceType] = useState(deviceParam || "tablet"); + + // 프리뷰 모드 (디자이너에서 열렸을 때) + const isPreviewMode = searchParams.get("preview") === "true"; + + // 사용자 정보 + const { user, userName, companyCode } = useAuth(); + + const [screen, setScreen] = useState(null); + const [layout, setLayout] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [formData, setFormData] = useState>({}); + const [selectedRowsData, setSelectedRowsData] = useState([]); + const [tableRefreshKey, setTableRefreshKey] = useState(0); + + // 컴포넌트 초기화 + useEffect(() => { + const initComponents = async () => { + try { + await initializeComponents(); + } catch (error) { + console.error("POP 화면 컴포넌트 초기화 실패:", error); + } + }; + initComponents(); + }, []); + + // 화면 및 POP 레이아웃 로드 + useEffect(() => { + const loadScreen = async () => { + try { + setLoading(true); + setError(null); + + // 화면 정보 로드 + const screenData = await screenApi.getScreen(screenId); + setScreen(screenData); + + // POP 레이아웃 로드 (screen_layouts_pop 테이블에서) + try { + const popLayout = await screenApi.getLayoutPop(screenId); + + if (popLayout && popLayout.components && popLayout.components.length > 0) { + // POP 레이아웃이 있으면 사용 + console.log("POP 레이아웃 로드:", popLayout.components?.length || 0, "개 컴포넌트"); + setLayout(popLayout as LayoutData); + } else { + // POP 레이아웃이 비어있으면 빈 레이아웃 + console.log("POP 레이아웃 없음, 빈 화면 표시"); + setLayout({ + screenId, + components: [], + gridSettings: { + columns: 12, + gap: 8, + padding: 16, + enabled: true, + size: 8, + color: "#e0e0e0", + opacity: 0.5, + snapToGrid: true, + }, + }); + } + } catch (layoutError) { + console.warn("POP 레이아웃 로드 실패:", layoutError); + setLayout({ + screenId, + components: [], + gridSettings: { + columns: 12, + gap: 8, + padding: 16, + enabled: true, + size: 8, + color: "#e0e0e0", + opacity: 0.5, + snapToGrid: true, + }, + }); + } + } catch (error) { + console.error("POP 화면 로드 실패:", error); + setError("화면을 불러오는데 실패했습니다."); + toast.error("화면을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + if (screenId) { + loadScreen(); + } + }, [screenId]); + + // 현재 디바이스 크기 + const currentDevice = DEVICE_SIZES[deviceType]; + + if (loading) { + return ( +
+
+ +

POP 화면 로딩 중...

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

화면을 찾을 수 없습니다

+

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

+ +
+
+ ); + } + + return ( + + + +
+ {/* 상단 툴바 (프리뷰 모드에서만) */} + {isPreviewMode && ( +
+
+
+ + {screen.screenName} +
+ + {/* 디바이스 전환 버튼 */} +
+ + +
+ + +
+
+ )} + + {/* POP 화면 컨텐츠 */} +
+
+ {/* 화면 컨텐츠 */} + {layout && layout.components && layout.components.length > 0 ? ( + +
+ {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 })); + }} + /> +
+ ))} +
+
+ ) : ( + // 빈 화면 +
+
+ +
+

+ 화면이 비어있습니다 +

+

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

+
+ )} +
+
+
+
+
+
+ ); +} + +// Provider 래퍼 +export default function PopScreenViewPageWrapper() { + return ( + + + + + + + + ); +} diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx new file mode 100644 index 00000000..3effc560 --- /dev/null +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import { useDrop } from "react-dnd"; +import GridLayout, { Layout } from "react-grid-layout"; +import { cn } from "@/lib/utils"; +import { + PopLayoutData, + PopSectionData, + PopComponentData, + PopComponentType, + GridPosition, +} from "./types/pop-layout"; +import { DND_ITEM_TYPES, DragItemSection, DragItemComponent } from "./panels/PopPanel"; +import { GripVertical, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { SectionGrid } from "./SectionGrid"; + +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; + +type DeviceType = "mobile" | "tablet"; + +// 디바이스별 캔버스 크기 (dp) +const DEVICE_SIZES = { + mobile: { + portrait: { width: 360, height: 640 }, + landscape: { width: 640, height: 360 }, + }, + tablet: { + portrait: { width: 768, height: 1024 }, + landscape: { width: 1024, height: 768 }, + }, +} as const; + +interface PopCanvasProps { + layout: PopLayoutData; + activeDevice: DeviceType; + showBothDevices: boolean; + isLandscape: boolean; + selectedSectionId: string | null; + selectedComponentId: string | null; + onSelectSection: (id: string | null) => void; + onSelectComponent: (id: string | null) => void; + onUpdateSection: (id: string, updates: Partial) => void; + onDeleteSection: (id: string) => void; + onLayoutChange: (sections: PopSectionData[]) => void; + onDropSection: (gridPosition: GridPosition) => void; + onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void; + onUpdateComponent: (sectionId: string, componentId: string, updates: Partial) => void; + onDeleteComponent: (sectionId: string, componentId: string) => void; +} + +export function PopCanvas({ + layout, + activeDevice, + showBothDevices, + isLandscape, + selectedSectionId, + selectedComponentId, + onSelectSection, + onSelectComponent, + onUpdateSection, + onDeleteSection, + onLayoutChange, + onDropSection, + onDropComponent, + onUpdateComponent, + onDeleteComponent, +}: PopCanvasProps) { + const { canvasGrid, sections } = layout; + + // GridLayout용 레이아웃 변환 + const gridLayoutItems: Layout[] = useMemo(() => { + return sections.map((section) => ({ + i: section.id, + x: section.grid.col - 1, + y: section.grid.row - 1, + w: section.grid.colSpan, + h: section.grid.rowSpan, + minW: 2, // 최소 너비 2칸 + minH: 1, // 최소 높이 1행 (20px) - 헤더만 보임 + })); + }, [sections]); + + // 드래그/리사이즈 완료 핸들러 (onDragStop, onResizeStop 사용) + const handleDragResizeStop = useCallback( + (layout: Layout[], oldItem: Layout, newItem: Layout) => { + const section = sections.find((s) => s.id === newItem.i); + if (!section) return; + + const newGrid: GridPosition = { + col: newItem.x + 1, + row: newItem.y + 1, + colSpan: newItem.w, + rowSpan: newItem.h, + }; + + // 변경된 경우에만 업데이트 + if ( + section.grid.col !== newGrid.col || + section.grid.row !== newGrid.row || + section.grid.colSpan !== newGrid.colSpan || + section.grid.rowSpan !== newGrid.rowSpan + ) { + const updatedSections = sections.map((s) => + s.id === newItem.i ? { ...s, grid: newGrid } : s + ); + onLayoutChange(updatedSections); + } + }, + [sections, onLayoutChange] + ); + + // 디바이스 프레임 렌더링 + const renderDeviceFrame = (device: DeviceType) => { + const orientation = isLandscape ? "landscape" : "portrait"; + const size = DEVICE_SIZES[device][orientation]; + const isActive = device === activeDevice; + + const cols = canvasGrid.columns; + const rowHeight = canvasGrid.rowHeight; + const margin: [number, number] = [canvasGrid.gap, canvasGrid.gap]; + + const sizeLabel = `${size.width}x${size.height}`; + const deviceLabel = + device === "mobile" ? `모바일 (${sizeLabel})` : `태블릿 (${sizeLabel})`; + + return ( +
+ {/* 디바이스 라벨 */} +
+ {deviceLabel} +
+ + {/* 드롭 영역 */} + +
+ ); + }; + + return ( +
+ {showBothDevices ? ( + <> + {renderDeviceFrame("tablet")} + {renderDeviceFrame("mobile")} + + ) : ( + renderDeviceFrame(activeDevice) + )} +
+ ); +} + +// 캔버스 드롭 영역 +interface CanvasDropZoneProps { + device: DeviceType; + isActive: boolean; + size: { width: number; height: number }; + cols: number; + rowHeight: number; + margin: [number, number]; + sections: PopSectionData[]; + gridLayoutItems: Layout[]; + selectedSectionId: string | null; + selectedComponentId: string | null; + onSelectSection: (id: string | null) => void; + onSelectComponent: (id: string | null) => void; + onDragResizeStop: (layout: Layout[], oldItem: Layout, newItem: Layout) => void; + onDropSection: (gridPosition: GridPosition) => void; + onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void; + onDeleteSection: (id: string) => void; + onUpdateComponent: (sectionId: string, componentId: string, updates: Partial) => void; + onDeleteComponent: (sectionId: string, componentId: string) => void; +} + +function CanvasDropZone({ + device, + isActive, + size, + cols, + rowHeight, + margin, + sections, + gridLayoutItems, + selectedSectionId, + selectedComponentId, + onSelectSection, + onSelectComponent, + onDragResizeStop, + onDropSection, + onDropComponent, + onDeleteSection, + onUpdateComponent, + onDeleteComponent, +}: CanvasDropZoneProps) { + const dropRef = useRef(null); + + // 섹션 드롭 핸들러 + const [{ isOver, canDrop }, drop] = useDrop(() => ({ + accept: DND_ITEM_TYPES.SECTION, + drop: (item: DragItemSection, monitor) => { + if (!isActive) return; + + // 드롭 위치 계산 + const clientOffset = monitor.getClientOffset(); + if (!clientOffset || !dropRef.current) return; + + const dropRect = dropRef.current.getBoundingClientRect(); + const x = clientOffset.x - dropRect.left; + const y = clientOffset.y - dropRect.top; + + // 그리드 위치 계산 + const colWidth = (size.width - 16) / cols; + const col = Math.max(1, Math.min(cols, Math.floor(x / colWidth) + 1)); + const row = Math.max(1, Math.floor(y / rowHeight) + 1); + + onDropSection({ + col, + row, + colSpan: 3, // 기본 너비 + rowSpan: 4, // 기본 높이 (20px * 4 = 80px) + }); + }, + canDrop: () => isActive, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }), [isActive, size, cols, rowHeight, onDropSection]); + + // ref 결합 + drop(dropRef); + + return ( +
{ + if (e.target === e.currentTarget) { + onSelectSection(null); + onSelectComponent(null); + } + }} + > + {sections.length > 0 ? ( + + {sections.map((section) => ( +
{ + e.stopPropagation(); + onSelectSection(section.id); + }} + > + {/* 섹션 헤더 - 고정 높이 */} +
+
+ + + {section.label || `섹션`} + +
+ {selectedSectionId === section.id && ( + + )} +
+ + {/* 섹션 내부 - 나머지 영역 전부 차지 */} +
+ +
+
+ ))} +
+ ) : ( +
+ {isOver && canDrop + ? "여기에 섹션을 놓으세요" + : "왼쪽 패널에서 섹션을 드래그하세요"} +
+ )} +
+ ); +} + diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx new file mode 100644 index 00000000..62499544 --- /dev/null +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -0,0 +1,352 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import { ArrowLeft, Save, Smartphone, Tablet, Columns2, RotateCcw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { toast } from "sonner"; + +import { PopCanvas } from "./PopCanvas"; +import { PopPanel } from "./panels/PopPanel"; +import { + PopLayoutData, + PopSectionData, + PopComponentData, + PopComponentType, + createEmptyPopLayout, + createPopSection, + createPopComponent, + GridPosition, +} from "./types/pop-layout"; +import { screenApi } from "@/lib/api/screen"; +import { ScreenDefinition } from "@/types/screen"; + +// 디바이스 타입 +type DeviceType = "mobile" | "tablet"; + +interface PopDesignerProps { + selectedScreen: ScreenDefinition; + onBackToList: () => void; + onScreenUpdate?: (updatedScreen: Partial) => void; +} + +export default function PopDesigner({ + selectedScreen, + onBackToList, + onScreenUpdate, +}: PopDesignerProps) { + // 레이아웃 상태 + const [layout, setLayout] = useState(createEmptyPopLayout()); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + + // 디바이스 프리뷰 상태 + const [activeDevice, setActiveDevice] = useState("tablet"); + const [showBothDevices, setShowBothDevices] = useState(false); + const [isLandscape, setIsLandscape] = useState(true); + + // 선택된 섹션/컴포넌트 + const [selectedSectionId, setSelectedSectionId] = useState(null); + const [selectedComponentId, setSelectedComponentId] = useState(null); + + // 선택된 섹션 객체 + const selectedSection = selectedSectionId + ? layout.sections.find((s) => s.id === selectedSectionId) || null + : null; + + // 레이아웃 로드 + useEffect(() => { + const loadLayout = async () => { + if (!selectedScreen?.screenId) return; + + setIsLoading(true); + try { + const response = await screenApi.getLayoutPop(selectedScreen.screenId); + + if (response && response.layout_data) { + const loadedLayout = response.layout_data as PopLayoutData; + + if (loadedLayout.version === "pop-1.0") { + setLayout(loadedLayout); + } else { + console.warn("레이아웃 버전 불일치, 새 레이아웃 생성"); + setLayout(createEmptyPopLayout()); + } + } else { + setLayout(createEmptyPopLayout()); + } + } catch (error) { + console.error("레이아웃 로드 실패:", error); + toast.error("레이아웃을 불러오는데 실패했습니다"); + setLayout(createEmptyPopLayout()); + } finally { + setIsLoading(false); + } + }; + + loadLayout(); + }, [selectedScreen?.screenId]); + + // 저장 + const handleSave = useCallback(async () => { + if (!selectedScreen?.screenId) return; + + setIsSaving(true); + try { + await screenApi.saveLayoutPop(selectedScreen.screenId, layout); + toast.success("저장되었습니다"); + setHasChanges(false); + } catch (error) { + console.error("저장 실패:", error); + toast.error("저장에 실패했습니다"); + } finally { + setIsSaving(false); + } + }, [selectedScreen?.screenId, layout]); + + // 섹션 드롭 (팔레트 → 캔버스) + const handleDropSection = useCallback((gridPosition: GridPosition) => { + const newId = `section-${Date.now()}`; + const newSection = createPopSection(newId, gridPosition); + + setLayout((prev) => ({ + ...prev, + sections: [...prev.sections, newSection], + })); + setSelectedSectionId(newId); + setHasChanges(true); + }, []); + + // 컴포넌트 드롭 (팔레트 → 섹션) + const handleDropComponent = useCallback( + (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => { + const newId = `${type}-${Date.now()}`; + const newComponent = createPopComponent(newId, type, gridPosition); + + setLayout((prev) => ({ + ...prev, + sections: prev.sections.map((s) => + s.id === sectionId + ? { ...s, components: [...s.components, newComponent] } + : s + ), + })); + setSelectedComponentId(newId); + setHasChanges(true); + }, + [] + ); + + // 섹션 업데이트 + const handleUpdateSection = useCallback( + (id: string, updates: Partial) => { + setLayout((prev) => ({ + ...prev, + sections: prev.sections.map((s) => + s.id === id ? { ...s, ...updates } : s + ), + })); + setHasChanges(true); + }, + [] + ); + + // 섹션 삭제 + const handleDeleteSection = useCallback((id: string) => { + setLayout((prev) => ({ + ...prev, + sections: prev.sections.filter((s) => s.id !== id), + })); + setSelectedSectionId(null); + setHasChanges(true); + }, []); + + // 레이아웃 변경 (드래그/리사이즈) + const handleLayoutChange = useCallback((sections: PopSectionData[]) => { + setLayout((prev) => ({ + ...prev, + sections, + })); + setHasChanges(true); + }, []); + + // 컴포넌트 업데이트 + const handleUpdateComponent = useCallback( + (sectionId: string, componentId: string, updates: Partial) => { + setLayout((prev) => ({ + ...prev, + sections: prev.sections.map((s) => + s.id === sectionId + ? { + ...s, + components: s.components.map((c) => + c.id === componentId ? { ...c, ...updates } : c + ), + } + : s + ), + })); + setHasChanges(true); + }, + [] + ); + + // 컴포넌트 삭제 + const handleDeleteComponent = useCallback( + (sectionId: string, componentId: string) => { + setLayout((prev) => ({ + ...prev, + sections: prev.sections.map((s) => + s.id === sectionId + ? { ...s, components: s.components.filter((c) => c.id !== componentId) } + : s + ), + })); + setSelectedComponentId(null); + setHasChanges(true); + }, + [] + ); + + // 뒤로가기 + const handleBack = useCallback(() => { + if (hasChanges) { + if (confirm("저장하지 않은 변경사항이 있습니다. 나가시겠습니까?")) { + onBackToList(); + } + } else { + onBackToList(); + } + }, [hasChanges, onBackToList]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( + +
+ {/* 툴바 */} +
+ {/* 왼쪽: 뒤로가기 + 화면명 */} +
+ + + {selectedScreen?.screenName || "POP 화면"} + + {hasChanges && ( + *변경됨 + )} +
+ + {/* 중앙: 디바이스 전환 */} +
+ setActiveDevice(v as DeviceType)} + > + + + + 태블릿 + + + + 모바일 + + + + + + + +
+ + {/* 오른쪽: 저장 */} +
+ +
+
+ + {/* 메인 영역: 리사이즈 가능한 패널 */} + + {/* 왼쪽: 패널 (컴포넌트/편집 탭) */} + + + + + + + {/* 오른쪽: 캔버스 */} + + + + +
+
+ ); +} diff --git a/frontend/components/pop/designer/SectionGrid.tsx b/frontend/components/pop/designer/SectionGrid.tsx new file mode 100644 index 00000000..5e14fd2a --- /dev/null +++ b/frontend/components/pop/designer/SectionGrid.tsx @@ -0,0 +1,352 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState, useEffect } from "react"; +import { useDrop } from "react-dnd"; +import GridLayout, { Layout } from "react-grid-layout"; +import { cn } from "@/lib/utils"; +import { + PopSectionData, + PopComponentData, + PopComponentType, + GridPosition, +} from "./types/pop-layout"; +import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel"; +import { Trash2, Move } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; + +interface SectionGridProps { + section: PopSectionData; + isActive: boolean; + selectedComponentId: string | null; + onSelectComponent: (id: string | null) => void; + onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void; + onUpdateComponent: (sectionId: string, componentId: string, updates: Partial) => void; + onDeleteComponent: (sectionId: string, componentId: string) => void; +} + +export function SectionGrid({ + section, + isActive, + selectedComponentId, + onSelectComponent, + onDropComponent, + onUpdateComponent, + onDeleteComponent, +}: SectionGridProps) { + const containerRef = useRef(null); + const { components } = section; + + // 컨테이너 크기 측정 + const [containerSize, setContainerSize] = useState({ width: 300, height: 200 }); + + useEffect(() => { + const updateSize = () => { + if (containerRef.current) { + setContainerSize({ + width: containerRef.current.offsetWidth, + height: containerRef.current.offsetHeight, + }); + } + }; + + updateSize(); + + const resizeObserver = new ResizeObserver(updateSize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => resizeObserver.disconnect(); + }, []); + + // 셀 크기 계산 - 고정 셀 크기 기반으로 자동 계산 + const padding = 8; // p-2 = 8px + const gap = 4; // 고정 간격 + const availableWidth = containerSize.width - padding * 2; + const availableHeight = containerSize.height - padding * 2; + + // 고정 셀 크기 (40px) 기반으로 열/행 수 자동 계산 + const CELL_SIZE = 40; + const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap))); + const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap))); + const cellHeight = CELL_SIZE; + + // GridLayout용 레이아웃 변환 (자동 계산된 cols/rows 사용) + const gridLayoutItems: Layout[] = useMemo(() => { + return components.map((comp) => { + // 컴포넌트 위치가 그리드 범위를 벗어나지 않도록 조정 + const x = Math.min(Math.max(0, comp.grid.col - 1), Math.max(0, cols - 1)); + const y = Math.min(Math.max(0, comp.grid.row - 1), Math.max(0, rows - 1)); + // colSpan/rowSpan도 범위 제한 + const w = Math.min(Math.max(1, comp.grid.colSpan), Math.max(1, cols - x)); + const h = Math.min(Math.max(1, comp.grid.rowSpan), Math.max(1, rows - y)); + + return { + i: comp.id, + x, + y, + w, + h, + minW: 1, + minH: 1, + }; + }); + }, [components, cols, rows]); + + // 드래그/리사이즈 완료 핸들러 (onDragStop, onResizeStop 사용) + const handleDragStop = useCallback( + (layout: Layout[], oldItem: Layout, newItem: Layout) => { + const comp = components.find((c) => c.id === newItem.i); + if (!comp) return; + + const newGrid: GridPosition = { + col: newItem.x + 1, + row: newItem.y + 1, + colSpan: newItem.w, + rowSpan: newItem.h, + }; + + if ( + comp.grid.col !== newGrid.col || + comp.grid.row !== newGrid.row + ) { + onUpdateComponent(section.id, comp.id, { grid: newGrid }); + } + }, + [components, section.id, onUpdateComponent] + ); + + const handleResizeStop = useCallback( + (layout: Layout[], oldItem: Layout, newItem: Layout) => { + const comp = components.find((c) => c.id === newItem.i); + if (!comp) return; + + const newGrid: GridPosition = { + col: newItem.x + 1, + row: newItem.y + 1, + colSpan: newItem.w, + rowSpan: newItem.h, + }; + + if ( + comp.grid.colSpan !== newGrid.colSpan || + comp.grid.rowSpan !== newGrid.rowSpan + ) { + onUpdateComponent(section.id, comp.id, { grid: newGrid }); + } + }, + [components, section.id, onUpdateComponent] + ); + + // 빈 셀 찾기 (드롭 위치용) - 자동 계산된 cols/rows 사용 + const findEmptyCell = useCallback((): GridPosition => { + const occupied = new Set(); + + components.forEach((comp) => { + for (let c = comp.grid.col; c < comp.grid.col + comp.grid.colSpan; c++) { + for (let r = comp.grid.row; r < comp.grid.row + comp.grid.rowSpan; r++) { + occupied.add(`${c}-${r}`); + } + } + }); + + // 빈 셀 찾기 + for (let r = 1; r <= rows; r++) { + for (let c = 1; c <= cols; c++) { + if (!occupied.has(`${c}-${r}`)) { + return { col: c, row: r, colSpan: 1, rowSpan: 1 }; + } + } + } + + // 빈 셀 없으면 첫 번째 위치에 + return { col: 1, row: 1, colSpan: 1, rowSpan: 1 }; + }, [components, cols, rows]); + + // 컴포넌트 드롭 핸들러 + const [{ isOver, canDrop }, drop] = useDrop(() => ({ + accept: DND_ITEM_TYPES.COMPONENT, + drop: (item: DragItemComponent) => { + if (!isActive) return; + const emptyCell = findEmptyCell(); + onDropComponent(section.id, item.componentType, emptyCell); + return { dropped: true }; + }, + canDrop: () => isActive, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }), [isActive, section.id, findEmptyCell, onDropComponent]); + + + return ( +
{ + containerRef.current = node; + drop(node); + }} + className={cn( + "relative h-full w-full p-2 transition-colors", + isOver && canDrop && "bg-blue-50" + )} + onClick={(e) => { + e.stopPropagation(); + onSelectComponent(null); + }} + onMouseDown={(e) => { + e.stopPropagation(); + }} + > + + {/* 빈 상태 안내 텍스트 */} + {components.length === 0 && ( +
+ + {isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"} + +
+ )} + + {/* 컴포넌트 GridLayout */} + {components.length > 0 && availableWidth > 0 && cols > 0 && ( + + {components.map((comp) => ( +
{ + e.stopPropagation(); + onSelectComponent(comp.id); + }} + onMouseDown={(e) => e.stopPropagation()} + > + {/* 드래그 핸들 바 */} +
+ +
+ + {/* 컴포넌트 내용 */} +
+ +
+ + {/* 삭제 버튼 */} + {selectedComponentId === comp.id && ( + + )} +
+ ))} +
+ )} +
+ ); +} + +// 컴포넌트 미리보기 +interface ComponentPreviewProps { + component: PopComponentData; +} + +function ComponentPreview({ component }: ComponentPreviewProps) { + const { type, label } = component; + + // 타입별 미리보기 렌더링 + const renderPreview = () => { + switch (type) { + case "pop-field": + return ( +
+ {label || "필드"} +
+
+ ); + case "pop-button": + return ( +
+ {label || "버튼"} +
+ ); + case "pop-list": + return ( +
+ {label || "리스트"} +
+
+
+
+ ); + case "pop-indicator": + return ( +
+ {label || "KPI"} + 0 +
+ ); + case "pop-scanner": + return ( +
+
+ QR +
+ {label || "스캐너"} +
+ ); + case "pop-numpad": + return ( +
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => ( +
+ {key} +
+ ))} +
+ ); + default: + return {label || type}; + } + }; + + return
{renderPreview()}
; +} diff --git a/frontend/components/pop/designer/index.ts b/frontend/components/pop/designer/index.ts new file mode 100644 index 00000000..80f2db22 --- /dev/null +++ b/frontend/components/pop/designer/index.ts @@ -0,0 +1,24 @@ +// POP 디자이너 컴포넌트 export + +// 타입 +export * from "./types"; + +// 메인 디자이너 +export { default as PopDesigner } from "./PopDesigner"; + +// 캔버스 +export { PopCanvas } from "./PopCanvas"; + +// 패널 +export { PopPanel } from "./panels/PopPanel"; + +// 타입 재export (편의) +export type { + PopLayoutData, + PopSectionData, + PopComponentData, + PopComponentType, + GridPosition, + PopCanvasGrid, + PopInnerGrid, +} from "./types/pop-layout"; diff --git a/frontend/components/pop/designer/panels/PopPanel.tsx b/frontend/components/pop/designer/panels/PopPanel.tsx new file mode 100644 index 00000000..226c1e2e --- /dev/null +++ b/frontend/components/pop/designer/panels/PopPanel.tsx @@ -0,0 +1,509 @@ +"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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Plus, + Settings, + LayoutGrid, + Type, + MousePointer, + List, + Activity, + ScanLine, + Calculator, + Trash2, + ChevronDown, + GripVertical, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + PopLayoutData, + PopSectionData, + PopComponentType, +} from "../types/pop-layout"; + +// 드래그 아이템 타입 +export const DND_ITEM_TYPES = { + SECTION: "section", + COMPONENT: "component", +} as const; + +// 드래그 아이템 데이터 +export interface DragItemSection { + type: typeof DND_ITEM_TYPES.SECTION; +} + +export interface DragItemComponent { + type: typeof DND_ITEM_TYPES.COMPONENT; + componentType: PopComponentType; +} + +interface PopPanelProps { + layout: PopLayoutData; + selectedSectionId: string | null; + selectedSection: PopSectionData | null; + onUpdateSection: (id: string, updates: Partial) => void; + onDeleteSection: (id: string) => void; + activeDevice: "mobile" | "tablet"; +} + +// 컴포넌트 팔레트 정의 +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: "숫자 입력 전용", + }, +]; + +export function PopPanel({ + layout, + selectedSectionId, + selectedSection, + onUpdateSection, + onDeleteSection, + activeDevice, +}: PopPanelProps) { + const [activeTab, setActiveTab] = useState("components"); + + return ( +
+ + + + + 컴포넌트 + + + + 편집 + + + + {/* 컴포넌트 탭 */} + +
+ {/* 섹션 드래그 아이템 */} +
+

+ 레이아웃 +

+ +

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

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

+ 컴포넌트 +

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

+ 섹션 안으로 드래그하여 배치 +

+
+
+
+ + {/* 편집 탭 */} + + {selectedSection ? ( + onUpdateSection(selectedSection.id, updates)} + onDelete={() => onDeleteSection(selectedSection.id)} + activeDevice={activeDevice} + /> + ) : ( +
+ 섹션을 선택하세요 +
+ )} +
+
+
+ ); +} + +// 드래그 가능한 섹션 아이템 +function DraggableSectionItem() { + const [{ isDragging }, drag] = useDrag(() => ({ + type: DND_ITEM_TYPES.SECTION, + item: { type: DND_ITEM_TYPES.SECTION } as DragItemSection, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + })); + + return ( +
+ + +
+

섹션

+

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

+
+
+ ); +} + +// 드래그 가능한 컴포넌트 아이템 +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}

+
+
+ ); +} + +// 섹션 편집기 +interface SectionEditorProps { + section: PopSectionData; + onUpdate: (updates: Partial) => void; + onDelete: () => void; + activeDevice: "mobile" | "tablet"; +} + +function SectionEditor({ + section, + onUpdate, + onDelete, + activeDevice, +}: SectionEditorProps) { + const [isGridOpen, setIsGridOpen] = useState(true); + const [isMobileOpen, setIsMobileOpen] = useState(false); + + return ( +
+ {/* 섹션 기본 정보 */} +
+ 섹션 + +
+ + {/* 라벨 */} +
+ + onUpdate({ label: e.target.value })} + placeholder="섹션 이름" + className="h-8 text-xs" + /> +
+ + {/* 그리드 위치/크기 */} + + + 그리드 위치 + + + +
+
+ + + onUpdate({ + grid: { ...section.grid, col: parseInt(e.target.value) || 1 }, + }) + } + className="h-8 text-xs" + /> +
+
+ + + onUpdate({ + grid: { ...section.grid, row: parseInt(e.target.value) || 1 }, + }) + } + className="h-8 text-xs" + /> +
+
+ + + onUpdate({ + grid: { + ...section.grid, + colSpan: parseInt(e.target.value) || 1, + }, + }) + } + className="h-8 text-xs" + /> +
+
+ + + onUpdate({ + grid: { + ...section.grid, + rowSpan: parseInt(e.target.value) || 1, + }, + }) + } + className="h-8 text-xs" + /> +
+
+

+ 캔버스는 24열 그리드입니다 +

+
+
+ + {/* 내부 그리드 설정 */} +
+
+
+ + +
+
+ + +
+
+

+ 섹션 내부에서 컴포넌트를 배치할 그리드 (점으로 표시) +

+
+ + {/* 모바일 전용 설정 */} + + + 모바일 전용 설정 + + + +
+
+ + + onUpdate({ + mobileGrid: { + col: section.mobileGrid?.col || 1, + row: section.mobileGrid?.row || section.grid.row, + colSpan: parseInt(e.target.value) || 4, + rowSpan: + section.mobileGrid?.rowSpan || section.grid.rowSpan, + }, + }) + } + className="h-8 text-xs" + /> +
+
+ + + onUpdate({ + mobileGrid: { + col: section.mobileGrid?.col || 1, + row: section.mobileGrid?.row || section.grid.row, + colSpan: section.mobileGrid?.colSpan || 4, + rowSpan: parseInt(e.target.value) || 1, + }, + }) + } + className="h-8 text-xs" + /> +
+
+

+ 모바일에서는 4열 그리드로 자동 변환됩니다 +

+
+
+
+ ); +} diff --git a/frontend/components/pop/designer/panels/index.ts b/frontend/components/pop/designer/panels/index.ts new file mode 100644 index 00000000..eaeb0e27 --- /dev/null +++ b/frontend/components/pop/designer/panels/index.ts @@ -0,0 +1,2 @@ +// POP 디자이너 패널 export +export { PopPanel } from "./PopPanel"; diff --git a/frontend/components/pop/designer/types/index.ts b/frontend/components/pop/designer/types/index.ts new file mode 100644 index 00000000..011f6dc1 --- /dev/null +++ b/frontend/components/pop/designer/types/index.ts @@ -0,0 +1,2 @@ +// POP 디자이너 타입 export +export * from "./pop-layout"; diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts new file mode 100644 index 00000000..eec57e9d --- /dev/null +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -0,0 +1,363 @@ +// POP 디자이너 레이아웃 타입 정의 +// 그리드 기반 반응형 레이아웃 (픽셀 좌표 없음, 그리드 셀 기반) + +/** + * POP 레이아웃 전체 데이터 + * - 캔버스는 12열 그리드 + * - 섹션은 그리드 셀 위치/크기로 배치 + * - 컴포넌트는 섹션 내부 그리드에 배치 + */ +export interface PopLayoutData { + version: "pop-1.0"; + layoutMode: "grid"; // 그리드 기반 (반응형) + deviceTarget: PopDeviceTarget; + canvasGrid: PopCanvasGrid; // 캔버스 그리드 설정 + sections: PopSectionData[]; // 섹션 목록 + metadata?: PopLayoutMetadata; +} + +/** + * 캔버스 그리드 설정 + */ +export interface PopCanvasGrid { + columns: number; // 기본 12열 + rowHeight: number; // 행 높이 (px) - 태블릿 기준 + gap: number; // 그리드 간격 (px) +} + +/** + * 대상 디바이스 + */ +export type PopDeviceTarget = "mobile" | "tablet" | "both"; + +/** + * 레이아웃 메타데이터 + */ +export interface PopLayoutMetadata { + lastModified?: string; + modifiedBy?: string; + description?: string; +} + +// ===== 섹션 타입 ===== + +/** + * 그리드 위치/크기 + */ +export interface GridPosition { + col: number; // 시작 열 (1-based) + row: number; // 시작 행 (1-based) + colSpan: number; // 열 개수 + rowSpan: number; // 행 개수 +} + +/** + * 섹션 데이터 + * - 캔버스 그리드 위에 배치 + * - 내부에 컴포넌트들을 가짐 + */ +export interface PopSectionData { + id: string; + label?: string; + // 그리드 위치 (12열 캔버스 기준) + grid: GridPosition; + // 모바일용 그리드 위치 (선택, 없으면 자동 조정) + mobileGrid?: GridPosition; + // 내부 그리드 설정 + innerGrid: PopInnerGrid; + // 섹션 내 컴포넌트들 + components: PopComponentData[]; + // 스타일 + style?: PopSectionStyle; +} + +/** + * 섹션 내부 그리드 설정 + */ +export interface PopInnerGrid { + columns: number; // 내부 열 수 (2, 3, 4, 6 등) + rows: number; // 내부 행 수 + gap: number; // 간격 (px) +} + +/** + * 섹션 스타일 + */ +export interface PopSectionStyle { + showBorder?: boolean; + backgroundColor?: string; + padding?: PopPaddingPreset; +} + +// ===== 컴포넌트 타입 ===== + +/** + * POP 컴포넌트 타입 + * - 6개 핵심 컴포넌트 (섹션 제외, 섹션은 별도 타입) + */ +export type PopComponentType = + | "pop-field" // 데이터 입력/표시 + | "pop-button" // 액션 실행 + | "pop-list" // 데이터 목록 + | "pop-indicator" // 상태/수치 표시 + | "pop-scanner" // 바코드/QR 입력 + | "pop-numpad"; // 숫자 입력 특화 + +/** + * POP 컴포넌트 데이터 + * - 섹션 내부 그리드에 배치 + * - grid: 섹션 내부 그리드 위치 + */ +export interface PopComponentData { + id: string; + type: PopComponentType; + + // 섹션 내부 그리드 위치 + grid: GridPosition; + + // 모바일용 그리드 위치 (선택, 없으면 자동) + mobileGrid?: GridPosition; + + // 라벨 + label?: string; + + // 데이터 바인딩 + dataBinding?: PopDataBinding; + + // 스타일 프리셋 + style?: PopStylePreset; + + // 컴포넌트별 설정 (타입별로 다름) + config?: PopComponentConfig; +} + +// ===== 프리셋 ===== + +export type PopGapPreset = "none" | "small" | "medium" | "large"; + +/** + * 스타일 프리셋 + */ +export interface PopStylePreset { + variant: PopVariant; + padding: PopPaddingPreset; +} + +export type PopVariant = "default" | "primary" | "success" | "warning" | "danger"; +export type PopPaddingPreset = "none" | "small" | "medium" | "large"; + +/** + * 패딩 프리셋 → 실제 값 매핑 + */ +export const POP_PADDING_MAP: Record = { + none: "0", + small: "8px", + medium: "16px", + large: "24px", +}; + +// ===== 데이터 바인딩 ===== + +/** + * 데이터 바인딩 설정 + * - 기존 데스크톱 시스템과 호환 + */ +export interface PopDataBinding { + tableName: string; + columnName: string; + displayField?: string; + filter?: Record; +} + + +// ===== 컴포넌트별 설정 ===== + +/** + * 컴포넌트별 설정 (config 필드) + */ +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; // API URL 또는 화면 ID + params?: Record; +} + +/** + * 리스트 설정 + */ +export interface PopListConfig { + listType: PopListType; + itemsPerPage?: number; + selectable?: boolean; + multiSelect?: boolean; + displayColumns?: string[]; +} + +export type PopListType = "card" | "simple" | "table"; + +/** + * 인디케이터 설정 + */ +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; // 스캔 결과를 입력할 필드 ID + autoSubmit?: boolean; + soundEnabled?: boolean; +} + +export type PopScannerType = "camera" | "external" | "both"; + +/** + * 숫자패드 설정 + */ +export interface PopNumpadConfig { + targetField?: string; + showDecimal?: boolean; + maxDigits?: number; + autoSubmit?: boolean; +} + +// ===== 기본값 및 유틸리티 ===== + +/** + * 기본 캔버스 그리드 설정 + * - columns: 24칸 (더 세밀한 가로 조절) + * - rowHeight: 20px (더 세밀한 세로 조절) + */ +export const DEFAULT_CANVAS_GRID: PopCanvasGrid = { + columns: 24, + rowHeight: 20, // 20px per row - 더 세밀한 높이 조절 + gap: 4, +}; + +/** + * 기본 섹션 내부 그리드 설정 + */ +export const DEFAULT_INNER_GRID: PopInnerGrid = { + columns: 3, + rows: 3, + gap: 4, +}; + +/** + * 빈 레이아웃 생성 + */ +export const createEmptyPopLayout = (): PopLayoutData => ({ + version: "pop-1.0", + layoutMode: "grid", + deviceTarget: "both", + canvasGrid: { ...DEFAULT_CANVAS_GRID }, + sections: [], +}); + +/** + * 새 섹션 생성 + */ +export const createPopSection = ( + id: string, + grid: GridPosition = { col: 1, row: 1, colSpan: 3, rowSpan: 4 } +): PopSectionData => ({ + id, + grid, + innerGrid: { ...DEFAULT_INNER_GRID }, + components: [], + style: { + showBorder: true, + padding: "small", + }, +}); + +/** + * 새 컴포넌트 생성 + */ +export const createPopComponent = ( + id: string, + type: PopComponentType, + grid: GridPosition = { col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + label?: string +): PopComponentData => ({ + id, + type, + grid, + label, +}); + +// ===== 타입 가드 ===== + +export const isPopField = (comp: PopComponentData): boolean => + comp.type === "pop-field"; + +export const isPopButton = (comp: PopComponentData): boolean => + comp.type === "pop-button"; diff --git a/frontend/components/screen/CreateScreenModal.tsx b/frontend/components/screen/CreateScreenModal.tsx index 05f3eab9..a8bdcdf6 100644 --- a/frontend/components/screen/CreateScreenModal.tsx +++ b/frontend/components/screen/CreateScreenModal.tsx @@ -26,9 +26,10 @@ interface CreateScreenModalProps { open: boolean; onOpenChange: (open: boolean) => void; onCreated?: (screen: ScreenDefinition) => void; + isPop?: boolean; // POP 화면 생성 모드 } -export default function CreateScreenModal({ open, onOpenChange, onCreated }: CreateScreenModalProps) { +export default function CreateScreenModal({ open, onOpenChange, onCreated, isPop = false }: CreateScreenModalProps) { const { user } = useAuth(); const [screenName, setScreenName] = useState(""); @@ -246,6 +247,19 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre const created = await screenApi.createScreen(createData); + // POP 모드일 경우 빈 POP 레이아웃 자동 생성 + if (isPop && created.screenId) { + try { + await screenApi.saveLayoutPop(created.screenId, { + version: "2.0", + components: [], + }); + } catch (popError) { + console.error("POP 레이아웃 생성 실패:", popError); + // POP 레이아웃 생성 실패해도 화면 생성은 성공으로 처리 + } + } + // 날짜 필드 보정 const mapped: ScreenDefinition = { ...created, @@ -278,7 +292,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre - 새 화면 생성 + {isPop ? "새 POP 화면 생성" : "새 화면 생성"}
diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index b05f03b6..743b1cf9 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -117,6 +117,9 @@ interface ScreenDesignerProps { selectedScreen: ScreenDefinition | null; onBackToList: () => void; onScreenUpdate?: (updatedScreen: Partial) => void; + // POP 모드 지원 + isPop?: boolean; + defaultDevicePreview?: "mobile" | "tablet"; } // 패널 설정 (통합 패널 1개) @@ -132,7 +135,15 @@ const panelConfigs: PanelConfig[] = [ }, ]; -export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) { +export default function ScreenDesigner({ + selectedScreen, + onBackToList, + onScreenUpdate, + isPop = false, + defaultDevicePreview = "tablet" +}: ScreenDesignerProps) { + // POP 모드 여부에 따른 API 분기 + const USE_POP_API = isPop; // 패널 상태 관리 const { panelStates, togglePanel, openPanel, closePanel } = usePanelState(panelConfigs); @@ -1253,9 +1264,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU console.warn("⚠️ 화면에 할당된 메뉴가 없습니다"); } - // V2 API 사용 여부에 따라 분기 + // V2/POP API 사용 여부에 따라 분기 let response: any; - if (USE_V2_API) { + if (USE_POP_API) { + // POP 모드: screen_layouts_pop 테이블 사용 + const popResponse = await screenApi.getLayoutPop(selectedScreen.screenId); + response = popResponse ? convertV2ToLegacy(popResponse) : null; + console.log("📱 POP 레이아웃 로드:", popResponse?.components?.length || 0, "개 컴포넌트"); + } else if (USE_V2_API) { + // 데스크톱 V2 모드: screen_layouts_v2 테이블 사용 const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId); response = v2Response ? convertV2ToLegacy(v2Response) : null; console.log("📦 V2 레이아웃 로드:", v2Response?.components?.length || 0, "개 컴포넌트"); @@ -1698,9 +1715,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU })), }); - // V2 API 사용 여부에 따라 분기 - if (USE_V2_API) { - const v2Layout = convertLegacyToV2(layoutWithResolution); + // V2/POP API 사용 여부에 따라 분기 + const v2Layout = convertLegacyToV2(layoutWithResolution); + if (USE_POP_API) { + // POP 모드: screen_layouts_pop 테이블에 저장 + await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); + console.log("📱 POP 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트"); + } else if (USE_V2_API) { + // 데스크톱 V2 모드: screen_layouts_v2 테이블에 저장 await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트"); } else { @@ -1725,6 +1747,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU } }, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]); + // POP 미리보기 핸들러 (새 창에서 열기) + const handlePopPreview = useCallback(() => { + if (!selectedScreen?.screenId) { + toast.error("화면 정보가 없습니다."); + return; + } + + const deviceType = defaultDevicePreview || "tablet"; + const previewUrl = `/pop/screens/${selectedScreen.screenId}?preview=true&device=${deviceType}`; + window.open(previewUrl, "_blank", "width=800,height=900"); + }, [selectedScreen, defaultDevicePreview]); + // 다국어 자동 생성 핸들러 const handleGenerateMultilang = useCallback(async () => { if (!selectedScreen?.screenId) { @@ -1803,8 +1837,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU // 자동 저장 (매핑 정보가 손실되지 않도록) try { - if (USE_V2_API) { - const v2Layout = convertLegacyToV2(updatedLayout); + const v2Layout = convertLegacyToV2(updatedLayout); + if (USE_POP_API) { + await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); + } else if (USE_V2_API) { await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); } else { await screenApi.saveLayout(selectedScreen.screenId, updatedLayout); @@ -4801,9 +4837,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU gridSettings: layoutWithResolution.gridSettings, screenResolution: layoutWithResolution.screenResolution, }); - // V2 API 사용 여부에 따라 분기 - if (USE_V2_API) { - const v2Layout = convertLegacyToV2(layoutWithResolution); + // V2/POP API 사용 여부에 따라 분기 + const v2Layout = convertLegacyToV2(layoutWithResolution); + if (USE_POP_API) { + await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); + } else if (USE_V2_API) { await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); } else { await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); @@ -4919,14 +4957,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU onBack={onBackToList} onSave={handleSave} isSaving={isSaving} + onPreview={isPop ? handlePopPreview : undefined} onResolutionChange={setScreenResolution} gridSettings={layout.gridSettings} onGridSettingsChange={updateGridSettings} onGenerateMultilang={handleGenerateMultilang} isGeneratingMultilang={isGeneratingMultilang} onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)} - isPanelOpen={panelStates.v2?.isOpen || false} - onTogglePanel={() => togglePanel("v2")} + isPanelOpen={panelStates.v2?.isOpen || false} + onTogglePanel={() => togglePanel("v2")} /> {/* 메인 컨테이너 (패널들 + 캔버스) */}
diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index d7f814d3..7171e214 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -61,6 +61,7 @@ interface ScreenRelationFlowProps { screen: ScreenDefinition | null; selectedGroup?: { id: number; name: string; company_code?: string } | null; initialFocusedScreenId?: number | null; + isPop?: boolean; } // 노드 타입 (Record 확장) @@ -69,7 +70,7 @@ type TableNodeType = Node>; type AllNodeType = ScreenNodeType | TableNodeType; // 내부 컴포넌트 (useReactFlow 사용 가능) -function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }: ScreenRelationFlowProps) { +function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId, isPop = false }: ScreenRelationFlowProps) { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [loading, setLoading] = useState(false); @@ -2295,6 +2296,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId fieldMappings={settingModalNode.existingConfig?.fieldMappings} componentCount={0} onSaveSuccess={handleRefreshVisualization} + isPop={isPop} /> )} diff --git a/frontend/components/screen/ScreenSettingModal.tsx b/frontend/components/screen/ScreenSettingModal.tsx index b2d59539..54da7776 100644 --- a/frontend/components/screen/ScreenSettingModal.tsx +++ b/frontend/components/screen/ScreenSettingModal.tsx @@ -134,6 +134,7 @@ interface ScreenSettingModalProps { fieldMappings?: FieldMappingInfo[]; componentCount?: number; onSaveSuccess?: () => void; + isPop?: boolean; // POP 화면 여부 } // 검색 가능한 Select 컴포넌트 @@ -239,6 +240,7 @@ export function ScreenSettingModal({ fieldMappings = [], componentCount = 0, onSaveSuccess, + isPop = false, }: ScreenSettingModalProps) { const [activeTab, setActiveTab] = useState("overview"); const [loading, setLoading] = useState(false); @@ -519,6 +521,7 @@ export function ScreenSettingModal({ iframeKey={iframeKey} canvasWidth={canvasSize.width} canvasHeight={canvasSize.height} + isPop={isPop} />
@@ -4630,9 +4633,10 @@ interface PreviewTabProps { iframeKey?: number; // iframe 새로고침용 키 canvasWidth?: number; // 화면 캔버스 너비 canvasHeight?: number; // 화면 캔버스 높이 + isPop?: boolean; // POP 화면 여부 } -function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight }: PreviewTabProps) { +function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight, isPop = false }: PreviewTabProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const containerRef = useRef(null); @@ -4686,12 +4690,18 @@ function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWi if (companyCode) { params.set("company_code", companyCode); } + // POP 화면일 경우 디바이스 타입 추가 + if (isPop) { + params.set("device", "tablet"); + } + // POP 화면과 데스크톱 화면 경로 분기 + const screenPath = isPop ? `/pop/screens/${screenId}` : `/screens/${screenId}`; if (typeof window !== "undefined") { const baseUrl = window.location.origin; - return `${baseUrl}/screens/${screenId}?${params.toString()}`; + return `${baseUrl}${screenPath}?${params.toString()}`; } - return `/screens/${screenId}?${params.toString()}`; - }, [screenId, companyCode]); + return `${screenPath}?${params.toString()}`; + }, [screenId, companyCode, isPop]); const handleIframeLoad = () => { setLoading(false); diff --git a/frontend/components/screen/toolbar/SlimToolbar.tsx b/frontend/components/screen/toolbar/SlimToolbar.tsx index d71ed93a..ff5396c6 100644 --- a/frontend/components/screen/toolbar/SlimToolbar.tsx +++ b/frontend/components/screen/toolbar/SlimToolbar.tsx @@ -329,8 +329,8 @@ export const SlimToolbar: React.FC = ({
{onPreview && ( )} {onGenerateMultilang && ( diff --git a/frontend/components/ui/resizable.tsx b/frontend/components/ui/resizable.tsx new file mode 100644 index 00000000..c0929f42 --- /dev/null +++ b/frontend/components/ui/resizable.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { GripVertical } from "lucide-react"; +import * as ResizablePrimitive from "react-resizable-panels"; + +import { cn } from "@/lib/utils"; + +const ResizablePanelGroup = ({ + className, + ...props +}: React.ComponentProps) => ( + +); + +const ResizablePanel = ResizablePrimitive.Panel; + +const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps & { + withHandle?: boolean; +}) => ( + div]:rotate-90", + className + )} + {...props} + > + {withHandle && ( +
+ +
+ )} +
+); + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index 74894dc0..c610da30 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -213,6 +213,32 @@ export const screenApi = { await apiClient.post(`/screen-management/screens/${screenId}/layout-v2`, layoutData); }, + // ======================================== + // POP 레이아웃 관리 (모바일/태블릿) + // ======================================== + + // POP 레이아웃 조회 + getLayoutPop: async (screenId: number): Promise => { + const response = await apiClient.get(`/screen-management/screens/${screenId}/layout-pop`); + return response.data.data; + }, + + // POP 레이아웃 저장 + saveLayoutPop: async (screenId: number, layoutData: any): Promise => { + await apiClient.post(`/screen-management/screens/${screenId}/layout-pop`, layoutData); + }, + + // POP 레이아웃 삭제 + deleteLayoutPop: async (screenId: number): Promise => { + await apiClient.delete(`/screen-management/screens/${screenId}/layout-pop`); + }, + + // POP 레이아웃이 존재하는 화면 ID 목록 조회 + getScreenIdsWithPopLayout: async (): Promise => { + const response = await apiClient.get(`/screen-management/pop-layout-screen-ids`); + return response.data.data || []; + }, + // 연결된 모달 화면 감지 detectLinkedModals: async ( screenId: number, diff --git a/frontend/lib/registry/PopComponentRegistry.ts b/frontend/lib/registry/PopComponentRegistry.ts new file mode 100644 index 00000000..f9620415 --- /dev/null +++ b/frontend/lib/registry/PopComponentRegistry.ts @@ -0,0 +1,267 @@ +"use client"; + +import React from "react"; + +/** + * POP 컴포넌트 정의 인터페이스 + */ +export interface PopComponentDefinition { + id: string; + name: string; + description: string; + category: PopComponentCategory; + icon?: string; + component: React.ComponentType; + configPanel?: React.ComponentType; + defaultProps?: Record; + // POP 전용 속성 + touchOptimized?: boolean; + minTouchArea?: number; + supportedDevices?: ("mobile" | "tablet")[]; + createdAt?: Date; + updatedAt?: Date; +} + +/** + * POP 컴포넌트 카테고리 + */ +export type PopComponentCategory = + | "display" // 데이터 표시 (카드, 리스트, 배지) + | "input" // 입력 (스캐너, 터치 입력) + | "action" // 액션 (버튼, 스와이프) + | "layout" // 레이아웃 (컨테이너, 그리드) + | "feedback"; // 피드백 (토스트, 로딩) + +/** + * POP 컴포넌트 레지스트리 이벤트 + */ +export interface PopComponentRegistryEvent { + type: "component_registered" | "component_unregistered"; + data: PopComponentDefinition; + timestamp: Date; +} + +/** + * POP 컴포넌트 레지스트리 클래스 + * 모바일/태블릿 전용 컴포넌트를 등록, 관리, 조회할 수 있는 중앙 레지스트리 + */ +export class PopComponentRegistry { + private static components = new Map(); + private static eventListeners: Array<(event: PopComponentRegistryEvent) => void> = []; + + /** + * 컴포넌트 등록 + */ + static registerComponent(definition: PopComponentDefinition): void { + // 유효성 검사 + if (!definition.id || !definition.name || !definition.component) { + throw new Error( + `POP 컴포넌트 등록 실패 (${definition.id || "unknown"}): 필수 필드 누락` + ); + } + + // 중복 등록 체크 + if (this.components.has(definition.id)) { + console.warn(`[POP Registry] 컴포넌트 중복 등록: ${definition.id} - 기존 정의를 덮어씁니다.`); + } + + // 타임스탬프 추가 + const enhancedDefinition: PopComponentDefinition = { + ...definition, + touchOptimized: definition.touchOptimized ?? true, + minTouchArea: definition.minTouchArea ?? 44, + supportedDevices: definition.supportedDevices ?? ["mobile", "tablet"], + createdAt: definition.createdAt || new Date(), + updatedAt: new Date(), + }; + + this.components.set(definition.id, enhancedDefinition); + + // 이벤트 발생 + this.emitEvent({ + type: "component_registered", + data: enhancedDefinition, + timestamp: new Date(), + }); + + // 개발 모드에서만 로깅 + if (process.env.NODE_ENV === "development") { + console.log(`[POP Registry] 컴포넌트 등록: ${definition.id}`); + } + } + + /** + * 컴포넌트 등록 해제 + */ + static unregisterComponent(id: string): void { + const definition = this.components.get(id); + if (!definition) { + console.warn(`[POP Registry] 등록되지 않은 컴포넌트 해제 시도: ${id}`); + return; + } + + this.components.delete(id); + + // 이벤트 발생 + this.emitEvent({ + type: "component_unregistered", + data: definition, + timestamp: new Date(), + }); + + console.log(`[POP Registry] 컴포넌트 해제: ${id}`); + } + + /** + * 특정 컴포넌트 조회 + */ + static getComponent(id: string): PopComponentDefinition | undefined { + return this.components.get(id); + } + + /** + * URL로 컴포넌트 조회 + */ + static getComponentByUrl(url: string): PopComponentDefinition | undefined { + // "@/lib/registry/pop-components/pop-card-list" → "pop-card-list" + const parts = url.split("/"); + const componentId = parts[parts.length - 1]; + return this.getComponent(componentId); + } + + /** + * 모든 컴포넌트 조회 + */ + static getAllComponents(): PopComponentDefinition[] { + return Array.from(this.components.values()).sort((a, b) => { + // 카테고리별 정렬, 그 다음 이름순 + if (a.category !== b.category) { + return a.category.localeCompare(b.category); + } + return a.name.localeCompare(b.name); + }); + } + + /** + * 카테고리별 컴포넌트 조회 + */ + static getComponentsByCategory(category: PopComponentCategory): PopComponentDefinition[] { + return Array.from(this.components.values()) + .filter((def) => def.category === category) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * 디바이스별 컴포넌트 조회 + */ + static getComponentsByDevice(device: "mobile" | "tablet"): PopComponentDefinition[] { + return Array.from(this.components.values()) + .filter((def) => def.supportedDevices?.includes(device)) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * 컴포넌트 검색 + */ + static searchComponents(query: string): PopComponentDefinition[] { + const lowerQuery = query.toLowerCase(); + return Array.from(this.components.values()).filter( + (def) => + def.id.toLowerCase().includes(lowerQuery) || + def.name.toLowerCase().includes(lowerQuery) || + def.description?.toLowerCase().includes(lowerQuery) + ); + } + + /** + * 등록된 컴포넌트 개수 + */ + static getComponentCount(): number { + return this.components.size; + } + + /** + * 카테고리별 통계 + */ + static getStatsByCategory(): Record { + const stats: Record = { + display: 0, + input: 0, + action: 0, + layout: 0, + feedback: 0, + }; + + for (const def of this.components.values()) { + stats[def.category]++; + } + + return stats; + } + + /** + * 이벤트 리스너 등록 + */ + static addEventListener(callback: (event: PopComponentRegistryEvent) => void): void { + this.eventListeners.push(callback); + } + + /** + * 이벤트 리스너 해제 + */ + static removeEventListener(callback: (event: PopComponentRegistryEvent) => void): void { + const index = this.eventListeners.indexOf(callback); + if (index > -1) { + this.eventListeners.splice(index, 1); + } + } + + /** + * 이벤트 발생 + */ + private static emitEvent(event: PopComponentRegistryEvent): void { + for (const listener of this.eventListeners) { + try { + listener(event); + } catch (error) { + console.error("[POP Registry] 이벤트 리스너 오류:", error); + } + } + } + + /** + * 레지스트리 초기화 (테스트용) + */ + static clear(): void { + this.components.clear(); + console.log("[POP Registry] 레지스트리 초기화됨"); + } + + /** + * 컴포넌트 존재 여부 확인 + */ + static hasComponent(id: string): boolean { + return this.components.has(id); + } + + /** + * 디버그 정보 출력 + */ + static debug(): void { + console.group("[POP Registry] 등록된 컴포넌트"); + console.log(`총 ${this.components.size}개 컴포넌트`); + console.table( + Array.from(this.components.values()).map((c) => ({ + id: c.id, + name: c.name, + category: c.category, + touchOptimized: c.touchOptimized, + devices: c.supportedDevices?.join(", "), + })) + ); + console.groupEnd(); + } +} + +// 기본 export +export default PopComponentRegistry; diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts new file mode 100644 index 00000000..fd98edd8 --- /dev/null +++ b/frontend/lib/registry/pop-components/index.ts @@ -0,0 +1,24 @@ +/** + * POP 컴포넌트 인덱스 + * + * POP(모바일/태블릿) 전용 컴포넌트를 export합니다. + * 새로운 POP 컴포넌트 추가 시 여기에 export를 추가하세요. + */ + +// ============================================ +// POP 컴포넌트 목록 +// ============================================ +// 4단계에서 추가될 컴포넌트들: +// - pop-card-list: 카드형 리스트 +// - pop-touch-button: 터치 버튼 +// - pop-scanner-input: 스캐너 입력 +// - pop-status-badge: 상태 배지 + +// 예시: 컴포넌트가 추가되면 다음과 같이 export +// export * from "./pop-card-list"; +// export * from "./pop-touch-button"; +// export * from "./pop-scanner-input"; +// export * from "./pop-status-badge"; + +// 현재는 빈 export (컴포넌트 개발 전) +export { }; diff --git a/frontend/lib/schemas/popComponentConfig.ts b/frontend/lib/schemas/popComponentConfig.ts new file mode 100644 index 00000000..b9f2fbb6 --- /dev/null +++ b/frontend/lib/schemas/popComponentConfig.ts @@ -0,0 +1,231 @@ +/** + * POP 컴포넌트 설정 스키마 및 유틸리티 + * + * POP(모바일/태블릿) 컴포넌트의 overrides 스키마 및 기본값을 관리 + * - 공통 요소는 componentConfig.ts에서 import하여 재사용 + * - POP 전용 컴포넌트의 overrides 스키마만 새로 정의 + */ +import { z } from "zod"; + +// ============================================ +// 공통 요소 재사용 (componentConfig.ts에서 import) +// ============================================ +export { + // 공통 스키마 + customConfigSchema, + componentV2Schema, + layoutV2Schema, + // 공통 유틸리티 함수 + deepMerge, + mergeComponentConfig, + extractCustomConfig, + isDeepEqual, + getComponentTypeFromUrl, +} from "./componentConfig"; + +// ============================================ +// POP 전용 URL 생성 함수 +// ============================================ +export function getPopComponentUrl(componentType: string): string { + return `@/lib/registry/pop-components/${componentType}`; +} + +// ============================================ +// POP 전용 컴포넌트 기본값 +// ============================================ + +// POP 카드 리스트 기본값 +export const popCardListDefaults = { + displayMode: "card" as const, + cardStyle: "compact" as const, + showHeader: true, + showFooter: false, + pageSize: 10, + enablePullToRefresh: true, + enableInfiniteScroll: false, + cardColumns: 1, + gap: 8, + padding: 16, + // 터치 최적화 + touchFeedback: true, + swipeActions: false, +}; + +// POP 터치 버튼 기본값 +export const popTouchButtonDefaults = { + variant: "primary" as const, + size: "lg" as const, + text: "확인", + icon: null, + iconPosition: "left" as const, + fullWidth: true, + // 터치 최적화 + minHeight: 48, // 최소 터치 영역 48px + hapticFeedback: true, + pressDelay: 0, +}; + +// POP 스캐너 입력 기본값 +export const popScannerInputDefaults = { + placeholder: "바코드를 스캔하세요", + showKeyboard: false, + autoFocus: true, + autoSubmit: true, + submitDelay: 300, + // 스캐너 설정 + scannerMode: "auto" as const, + beepOnScan: true, + vibrationOnScan: true, + clearOnSubmit: true, +}; + +// POP 상태 배지 기본값 +export const popStatusBadgeDefaults = { + variant: "default" as const, + size: "md" as const, + text: "", + icon: null, + // 스타일 + rounded: true, + pulse: false, +}; + +// ============================================ +// POP 전용 overrides 스키마 +// ============================================ + +// POP 카드 리스트 overrides 스키마 +export const popCardListOverridesSchema = z + .object({ + displayMode: z.enum(["card", "list", "grid"]).default("card"), + cardStyle: z.enum(["compact", "default", "expanded"]).default("compact"), + showHeader: z.boolean().default(true), + showFooter: z.boolean().default(false), + pageSize: z.number().default(10), + enablePullToRefresh: z.boolean().default(true), + enableInfiniteScroll: z.boolean().default(false), + cardColumns: z.number().default(1), + gap: z.number().default(8), + padding: z.number().default(16), + touchFeedback: z.boolean().default(true), + swipeActions: z.boolean().default(false), + // 데이터 바인딩 + tableName: z.string().optional(), + columns: z.array(z.string()).optional(), + titleField: z.string().optional(), + subtitleField: z.string().optional(), + statusField: z.string().optional(), + }) + .passthrough(); + +// POP 터치 버튼 overrides 스키마 +export const popTouchButtonOverridesSchema = z + .object({ + variant: z.enum(["primary", "secondary", "success", "warning", "danger", "ghost"]).default("primary"), + size: z.enum(["sm", "md", "lg", "xl"]).default("lg"), + text: z.string().default("확인"), + icon: z.string().nullable().default(null), + iconPosition: z.enum(["left", "right", "top", "bottom"]).default("left"), + fullWidth: z.boolean().default(true), + minHeight: z.number().default(48), + hapticFeedback: z.boolean().default(true), + pressDelay: z.number().default(0), + // 액션 + actionType: z.string().optional(), + actionParams: z.record(z.string(), z.any()).optional(), + }) + .passthrough(); + +// POP 스캐너 입력 overrides 스키마 +export const popScannerInputOverridesSchema = z + .object({ + placeholder: z.string().default("바코드를 스캔하세요"), + showKeyboard: z.boolean().default(false), + autoFocus: z.boolean().default(true), + autoSubmit: z.boolean().default(true), + submitDelay: z.number().default(300), + scannerMode: z.enum(["auto", "camera", "external"]).default("auto"), + beepOnScan: z.boolean().default(true), + vibrationOnScan: z.boolean().default(true), + clearOnSubmit: z.boolean().default(true), + // 데이터 바인딩 + tableName: z.string().optional(), + columnName: z.string().optional(), + }) + .passthrough(); + +// POP 상태 배지 overrides 스키마 +export const popStatusBadgeOverridesSchema = z + .object({ + variant: z.enum(["default", "success", "warning", "danger", "info"]).default("default"), + size: z.enum(["sm", "md", "lg"]).default("md"), + text: z.string().default(""), + icon: z.string().nullable().default(null), + rounded: z.boolean().default(true), + pulse: z.boolean().default(false), + // 조건부 스타일 + conditionField: z.string().optional(), + conditionMapping: z.record(z.string(), z.string()).optional(), + }) + .passthrough(); + +// ============================================ +// POP 컴포넌트 overrides 스키마 레지스트리 +// ============================================ +export const popComponentOverridesSchemaRegistry: Record = { + "pop-card-list": popCardListOverridesSchema, + "pop-touch-button": popTouchButtonOverridesSchema, + "pop-scanner-input": popScannerInputOverridesSchema, + "pop-status-badge": popStatusBadgeOverridesSchema, +}; + +// ============================================ +// POP 컴포넌트 기본값 레지스트리 +// ============================================ +export const popComponentDefaultsRegistry: Record> = { + "pop-card-list": popCardListDefaults, + "pop-touch-button": popTouchButtonDefaults, + "pop-scanner-input": popScannerInputDefaults, + "pop-status-badge": popStatusBadgeDefaults, +}; + +// ============================================ +// POP 기본값 조회 함수 +// ============================================ +export function getPopComponentDefaults(componentType: string): Record { + return popComponentDefaultsRegistry[componentType] || {}; +} + +// ============================================ +// POP URL로 기본값 조회 +// ============================================ +export function getPopDefaultsByUrl(componentUrl: string): Record { + // "@/lib/registry/pop-components/pop-card-list" → "pop-card-list" + const parts = componentUrl.split("/"); + const componentType = parts[parts.length - 1]; + return getPopComponentDefaults(componentType); +} + +// ============================================ +// POP overrides 파싱 및 검증 +// ============================================ +export function parsePopOverridesByUrl( + componentUrl: string, + overrides: Record, +): Record { + const parts = componentUrl.split("/"); + const componentType = parts[parts.length - 1]; + const schema = popComponentOverridesSchemaRegistry[componentType]; + + if (!schema) { + // 스키마 없으면 그대로 반환 + return overrides || {}; + } + + try { + return schema.parse(overrides || {}); + } catch { + // 파싱 실패 시 기본값 반환 + return getPopComponentDefaults(componentType); + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1558865e..4d35b97c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -48,6 +48,7 @@ "@types/d3": "^7.4.3", "@types/leaflet": "^1.9.21", "@types/qrcode": "^1.5.6", + "@types/react-grid-layout": "^1.3.6", "@types/react-window": "^1.8.8", "@types/three": "^0.180.0", "@xyflow/react": "^12.8.4", @@ -76,6 +77,7 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "19.1.0", + "react-grid-layout": "^2.2.2", "react-hook-form": "^7.62.0", "react-hot-toast": "^2.6.0", "react-is": "^18.3.1", @@ -259,7 +261,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -301,7 +302,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -335,7 +335,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2666,7 +2665,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.32.0", @@ -3320,7 +3318,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.6" }, @@ -3388,7 +3385,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3702,7 +3698,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -6203,7 +6198,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6214,11 +6208,19 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.6.tgz", + "integrity": "sha512-Cw7+sb3yyjtmxwwJiXtEXcu5h4cgs+sCGkHwHXsFmPyV30bf14LeD/fa2LwQovuD2HWxCcjIdNhDlcYGj95qGA==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-reconciler": { "version": "0.32.2", "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.2.tgz", @@ -6248,7 +6250,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "license": "MIT", - "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -6331,7 +6332,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -6964,7 +6964,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8115,8 +8114,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3": { "version": "7.9.0", @@ -8438,7 +8436,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -9198,7 +9195,6 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9287,7 +9283,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9389,7 +9384,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9793,6 +9787,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -10540,7 +10540,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11121,7 +11120,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -11322,8 +11320,7 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/levn": { "version": "0.4.1", @@ -11730,7 +11727,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -12161,7 +12157,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12622,7 +12617,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12791,7 +12785,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -12803,7 +12796,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/prosemirror-changeset": { @@ -12918,7 +12910,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -12948,7 +12939,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -12997,7 +12987,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -13124,7 +13113,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13194,7 +13182,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -13208,12 +13195,43 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, + "node_modules/react-draggable": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", + "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.2.tgz", + "integrity": "sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-hook-form": { "version": "7.66.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -13324,6 +13342,20 @@ } } }, + "node_modules/react-resizable": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz", + "integrity": "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/react-resizable-panels": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz", @@ -13540,7 +13572,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -13563,8 +13594,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/recharts/node_modules/redux-thunk": { "version": "3.1.0", @@ -13665,6 +13695,12 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -14588,8 +14624,7 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -14677,7 +14712,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15026,7 +15060,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/package.json b/frontend/package.json index 9a43e4bc..6f4101f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,6 +57,7 @@ "@types/d3": "^7.4.3", "@types/leaflet": "^1.9.21", "@types/qrcode": "^1.5.6", + "@types/react-grid-layout": "^1.3.6", "@types/react-window": "^1.8.8", "@types/three": "^0.180.0", "@xyflow/react": "^12.8.4", @@ -85,6 +86,7 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "19.1.0", + "react-grid-layout": "^2.2.2", "react-hook-form": "^7.62.0", "react-hot-toast": "^2.6.0", "react-is": "^18.3.1", From d9b7ef9ad4d8116bf9023b4a611428fb09da1e8a Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 2 Feb 2026 18:01:05 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat(pop):=20POP=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5/=EB=A1=9C=EB=93=9C=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=ED=99=94=20POP=20=EC=A0=84=EC=9A=A9=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=ED=8A=B8=EB=A6=AC=20UI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(=EA=B3=84=EC=B8=B5=EC=A0=81=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0)=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=20CRUD=20API=20=EC=B6=94=EA=B0=80=20(hierarchy=5Fpath=20LIKE?= =?UTF-8?q?=20'POP/%'=20=ED=95=84=ED=84=B0)=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EA=B8=B0=EB=8A=A5=20(=EA=B8=B0=EC=A1=B4?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=20=EC=82=AD=EC=A0=9C=20=ED=9B=84=20?= =?UTF-8?q?=EC=83=88=20=EC=97=B0=EA=B2=B0=20=EC=B6=94=EA=B0=80=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D)=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC/=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20(display=5Forder=20=EA=B5=90=ED=99=98)=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20UI=EB=A5=BC=20=EC=84=9C=EB=B8=8C=EB=A9=94=EB=89=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B2=80=EC=83=89=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=9C=20=EB=AA=A8=EB=8B=AC=EB=A1=9C=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?POP=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC=20(pop-1.0)=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20DB=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=ED=98=B8=ED=99=98=EC=84=B1=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(writer=20=EC=BB=AC=EB=9F=BC,=20is=5Factiv?= =?UTF-8?q?e=20VARCHAR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- POPUPDATE.md | 283 ++++- .../src/controllers/screenGroupController.ts | 278 +++++ backend-node/src/routes/screenGroupRoutes.ts | 15 + .../src/services/screenManagementService.ts | 4 +- .../admin/screenMng/popScreenMngList/page.tsx | 362 +++--- .../app/(pop)/pop/screens/[screenId]/page.tsx | 71 +- .../components/pop/designer/PopDesigner.tsx | 27 +- .../pop/management/PopCategoryTree.tsx | 1095 +++++++++++++++++ .../pop/management/PopScreenFlowView.tsx | 347 ++++++ .../pop/management/PopScreenPreview.tsx | 199 +++ .../pop/management/PopScreenSettingModal.tsx | 442 +++++++ frontend/components/pop/management/index.ts | 8 + frontend/lib/api/popScreenGroup.ts | 182 +++ 13 files changed, 3104 insertions(+), 209 deletions(-) create mode 100644 frontend/components/pop/management/PopCategoryTree.tsx create mode 100644 frontend/components/pop/management/PopScreenFlowView.tsx create mode 100644 frontend/components/pop/management/PopScreenPreview.tsx create mode 100644 frontend/components/pop/management/PopScreenSettingModal.tsx create mode 100644 frontend/components/pop/management/index.ts create mode 100644 frontend/lib/api/popScreenGroup.ts diff --git a/POPUPDATE.md b/POPUPDATE.md index 139cd8f9..49331154 100644 --- a/POPUPDATE.md +++ b/POPUPDATE.md @@ -627,4 +627,285 @@ const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap)) --- -*최종 업데이트: 2026-02-02* +## 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 화면 │ +│ 📁 홈 관리 │ +│ 📁 출고관리 │ +│ 📁 수주관리 │ +│ 📁 생산 관리 (현재) │ +├─────────────────────────────┤ +│ [ 취소 ] │ +└─────────────────────────────┘ +``` + +--- + +## 트러블슈팅 + +### Export default doesn't exist in target module + +**문제:** `import apiClient from "@/lib/api/client"` 에러 + +**원인:** `apiClient`가 named export로 정의됨 + +**해결:** `import { apiClient } from "@/lib/api/client"` 사용 + +### 관련 파일 + +| 파일 | 역할 | +|------|------| +| `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 라우트 | + +--- + +*최종 업데이트: 2026-01-29* diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 69a63491..f8fb1c09 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -2424,3 +2424,281 @@ export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res } }; +// ============================================================ +// POP 전용 화면 그룹 API +// hierarchy_path LIKE 'POP/%' 필터로 POP 카테고리만 조회 +// ============================================================ + +// POP 화면 그룹 목록 조회 (카테고리 트리용) +export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { searchTerm } = req.query; + + let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'"; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터링 (멀티테넌시) + if (companyCode !== "*") { + whereClause += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + // 검색어 필터링 + if (searchTerm) { + whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`; + params.push(`%${searchTerm}%`); + paramIndex++; + } + + // POP 그룹 조회 (계층 구조를 위해 전체 조회) + const dataQuery = ` + SELECT + sg.*, + (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, + (SELECT json_agg( + json_build_object( + 'id', sgs.id, + 'screen_id', sgs.screen_id, + 'screen_name', sd.screen_name, + 'screen_role', sgs.screen_role, + 'display_order', sgs.display_order, + 'is_default', sgs.is_default, + 'table_name', sd.table_name + ) ORDER BY sgs.display_order + ) FROM screen_group_screens sgs + LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id + WHERE sgs.group_id = sg.id + ) as screens + FROM screen_groups sg + ${whereClause} + ORDER BY sg.display_order ASC, sg.hierarchy_path ASC + `; + + const result = await pool.query(dataQuery, params); + + logger.info("POP 화면 그룹 목록 조회", { companyCode, count: result.rows.length }); + + res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("POP 화면 그룹 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 목록 조회에 실패했습니다.", error: error.message }); + } +}; + +// POP 화면 그룹 생성 (hierarchy_path 자동 설정) +export const createPopScreenGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const userCompanyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || ""; + const { group_name, group_code, description, icon, display_order, parent_group_id, target_company_code } = req.body; + + if (!group_name || !group_code) { + return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." }); + } + + // 회사 코드 결정 + const effectiveCompanyCode = target_company_code || userCompanyCode; + if (userCompanyCode !== "*" && effectiveCompanyCode !== userCompanyCode) { + return res.status(403).json({ success: false, message: "다른 회사의 그룹을 생성할 권한이 없습니다." }); + } + + // hierarchy_path 계산 - POP 하위로 설정 + let hierarchyPath = "POP"; + if (parent_group_id) { + // 부모 그룹의 hierarchy_path 조회 + const parentResult = await pool.query( + `SELECT hierarchy_path FROM screen_groups WHERE id = $1`, + [parent_group_id] + ); + if (parentResult.rows.length > 0) { + hierarchyPath = `${parentResult.rows[0].hierarchy_path}/${group_code}`; + } + } else { + // 최상위 POP 카테고리 + hierarchyPath = `POP/${group_code}`; + } + + // 중복 체크 + const duplicateCheck = await pool.query( + `SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`, + [group_code, effectiveCompanyCode] + ); + if (duplicateCheck.rows.length > 0) { + return res.status(400).json({ success: false, message: "동일한 그룹코드가 이미 존재합니다." }); + } + + // 그룹 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤) + const insertQuery = ` + INSERT INTO screen_groups ( + group_name, group_code, description, icon, display_order, + parent_group_id, hierarchy_path, company_code, writer, is_active + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y') + RETURNING * + `; + const insertParams = [ + group_name, + group_code, + description || null, + icon || null, + display_order || 0, + parent_group_id || null, + hierarchyPath, + effectiveCompanyCode, + userId, + ]; + + const result = await pool.query(insertQuery, insertParams); + + logger.info("POP 화면 그룹 생성", { groupId: result.rows[0].id, groupCode: group_code, companyCode: effectiveCompanyCode }); + + res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 생성되었습니다." }); + } catch (error: any) { + logger.error("POP 화면 그룹 생성 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 생성에 실패했습니다.", error: error.message }); + } +}; + +// POP 화면 그룹 수정 +export const updatePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { group_name, description, icon, display_order, is_active } = req.body; + + // 기존 그룹 확인 + let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`; + const checkParams: any[] = [id]; + if (companyCode !== "*") { + checkQuery += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await pool.query(checkQuery, checkParams); + if (existing.rows.length === 0) { + return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); + } + + // POP 그룹인지 확인 + if (!existing.rows[0].hierarchy_path?.startsWith("POP")) { + return res.status(400).json({ success: false, message: "POP 그룹만 수정할 수 있습니다." }); + } + + // 업데이트 + const updateQuery = ` + UPDATE screen_groups + SET group_name = COALESCE($1, group_name), + description = COALESCE($2, description), + icon = COALESCE($3, icon), + display_order = COALESCE($4, display_order), + is_active = COALESCE($5, is_active), + updated_date = NOW() + WHERE id = $6 + RETURNING * + `; + const updateParams = [group_name, description, icon, display_order, is_active, id]; + const result = await pool.query(updateQuery, updateParams); + + logger.info("POP 화면 그룹 수정", { groupId: id, companyCode }); + + res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 수정되었습니다." }); + } catch (error: any) { + logger.error("POP 화면 그룹 수정 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 수정에 실패했습니다.", error: error.message }); + } +}; + +// POP 화면 그룹 삭제 +export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 기존 그룹 확인 + let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`; + const checkParams: any[] = [id]; + if (companyCode !== "*") { + checkQuery += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await pool.query(checkQuery, checkParams); + if (existing.rows.length === 0) { + return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); + } + + // POP 그룹인지 확인 + if (!existing.rows[0].hierarchy_path?.startsWith("POP")) { + return res.status(400).json({ success: false, message: "POP 그룹만 삭제할 수 있습니다." }); + } + + // 하위 그룹 확인 + const childCheck = await pool.query( + `SELECT COUNT(*) as count FROM screen_groups WHERE parent_group_id = $1`, + [id] + ); + if (parseInt(childCheck.rows[0].count) > 0) { + return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." }); + } + + // 연결된 화면 확인 + const screenCheck = await pool.query( + `SELECT COUNT(*) as count FROM screen_group_screens WHERE group_id = $1`, + [id] + ); + if (parseInt(screenCheck.rows[0].count) > 0) { + return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." }); + } + + // 삭제 + await pool.query(`DELETE FROM screen_groups WHERE id = $1`, [id]); + + logger.info("POP 화면 그룹 삭제", { groupId: id, companyCode }); + + res.json({ success: true, message: "POP 화면 그룹이 삭제되었습니다." }); + } catch (error: any) { + logger.error("POP 화면 그룹 삭제 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 삭제에 실패했습니다.", error: error.message }); + } +}; + +// POP 루트 그룹 확보 (없으면 자동 생성) +export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + + // POP 루트 그룹 확인 + const checkQuery = ` + SELECT * FROM screen_groups + WHERE hierarchy_path = 'POP' AND company_code = $1 + `; + const existing = await pool.query(checkQuery, [companyCode]); + + if (existing.rows.length > 0) { + return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." }); + } + + // 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤) + const insertQuery = ` + INSERT INTO screen_groups ( + group_name, group_code, hierarchy_path, company_code, + description, display_order, is_active, writer + ) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2) + RETURNING * + `; + const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]); + + logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode }); + + res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." }); + } catch (error: any) { + logger.error("POP 루트 그룹 확보 실패:", error); + res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message }); + } +}; + diff --git a/backend-node/src/routes/screenGroupRoutes.ts b/backend-node/src/routes/screenGroupRoutes.ts index 614e6d61..86b97b31 100644 --- a/backend-node/src/routes/screenGroupRoutes.ts +++ b/backend-node/src/routes/screenGroupRoutes.ts @@ -36,6 +36,12 @@ import { syncMenuToScreenGroupsController, getSyncStatusController, syncAllCompaniesController, + // POP 전용 화면 그룹 + getPopScreenGroups, + createPopScreenGroup, + updatePopScreenGroup, + deletePopScreenGroup, + ensurePopRootGroup, } from "../controllers/screenGroupController"; const router = Router(); @@ -106,6 +112,15 @@ router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController); // 전체 회사 동기화 (최고 관리자만) router.post("/sync/all", syncAllCompaniesController); +// ============================================================ +// POP 전용 화면 그룹 (hierarchy_path LIKE 'POP/%') +// ============================================================ +router.get("/pop/groups", getPopScreenGroups); +router.post("/pop/groups", createPopScreenGroup); +router.put("/pop/groups/:id", updatePopScreenGroup); +router.delete("/pop/groups/:id", deletePopScreenGroup); +router.post("/pop/ensure-root", ensurePopRootGroup); + export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 96ee11d2..7dfab16d 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -4829,9 +4829,9 @@ export class ScreenManagementService { throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다."); } - // 버전 정보 추가 + // 버전 정보 추가 (프론트엔드 pop-1.0과 통일) const dataToSave = { - version: "2.0", + version: "pop-1.0", ...layoutData }; diff --git a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx index 6477800a..d9e289ca 100644 --- a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx @@ -4,39 +4,71 @@ import { useState, useEffect, useCallback } from "react"; import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Plus, RefreshCw, Search, LayoutGrid, LayoutList, Smartphone, Tablet, Eye } from "lucide-react"; +import { + Plus, + RefreshCw, + Search, + Smartphone, + Eye, + Settings, + LayoutGrid, + GitBranch, +} from "lucide-react"; import { PopDesigner } from "@/components/pop/designer"; -import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScreenDefinition } from "@/types/screen"; import { screenApi } from "@/lib/api/screen"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import CreateScreenModal from "@/components/screen/CreateScreenModal"; import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { + PopCategoryTree, + PopScreenPreview, + PopScreenFlowView, + PopScreenSettingModal, +} from "@/components/pop/management"; +import { PopScreenGroup } from "@/lib/api/popScreenGroup"; + +// ============================================================ +// 타입 정의 +// ============================================================ -// 단계별 진행을 위한 타입 정의 type Step = "list" | "design"; -type ViewMode = "tree" | "table"; type DevicePreview = "mobile" | "tablet"; +type RightPanelView = "preview" | "flow"; + +// ============================================================ +// 메인 컴포넌트 +// ============================================================ export default function PopScreenManagementPage() { const searchParams = useSearchParams(); + + // 단계 및 화면 상태 const [currentStep, setCurrentStep] = useState("list"); const [selectedScreen, setSelectedScreen] = useState(null); - const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null); - const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState(null); + const [selectedGroup, setSelectedGroup] = useState(null); const [stepHistory, setStepHistory] = useState(["list"]); - const [viewMode, setViewMode] = useState("tree"); + + // 화면 데이터 const [screens, setScreens] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [devicePreview, setDevicePreview] = useState("tablet"); - + // POP 레이아웃 존재 화면 ID const [popLayoutScreenIds, setPopLayoutScreenIds] = useState>(new Set()); - // 화면 목록 및 POP 레이아웃 존재 여부 로드 + // UI 상태 + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); + const [devicePreview, setDevicePreview] = useState("tablet"); + const [rightPanelView, setRightPanelView] = useState("preview"); + + // ============================================================ + // 데이터 로드 + // ============================================================ + const loadScreens = useCallback(async () => { try { setLoading(true); @@ -44,7 +76,7 @@ export default function PopScreenManagementPage() { screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" }), screenApi.getScreenIdsWithPopLayout(), ]); - + if (result.data && result.data.length > 0) { setScreens(result.data); } @@ -63,7 +95,7 @@ export default function PopScreenManagementPage() { // 화면 목록 새로고침 이벤트 리스너 useEffect(() => { const handleScreenListRefresh = () => { - console.log("🔄 POP 화면 목록 새로고침 이벤트 수신"); + console.log("POP 화면 목록 새로고침 이벤트 수신"); loadScreens(); }; @@ -87,16 +119,15 @@ export default function PopScreenManagementPage() { } }, [searchParams, screens]); - // 화면 설계 모드일 때는 전체 화면 사용 - const isDesignMode = currentStep === "design"; + // ============================================================ + // 핸들러 + // ============================================================ - // 다음 단계로 이동 const goToNextStep = (nextStep: Step) => { setStepHistory((prev) => [...prev, nextStep]); setCurrentStep(nextStep); }; - // 특정 단계로 이동 const goToStep = (step: Step) => { setCurrentStep(step); const stepIndex = stepHistory.findIndex((s) => s === step); @@ -105,13 +136,19 @@ export default function PopScreenManagementPage() { } }; - // 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제) + // 화면 선택 const handleScreenSelect = (screen: ScreenDefinition) => { setSelectedScreen(screen); setSelectedGroup(null); }; - // 화면 디자인 핸들러 + // 그룹 선택 + const handleGroupSelect = (group: PopScreenGroup | null) => { + setSelectedGroup(group); + // 그룹 선택 시 화면 선택 해제하지 않음 (미리보기 유지) + }; + + // 화면 디자인 모드 진입 const handleDesignScreen = (screen: ScreenDefinition) => { setSelectedScreen(screen); goToNextStep("design"); @@ -123,32 +160,43 @@ export default function PopScreenManagementPage() { window.open(previewUrl, "_blank", "width=800,height=900"); }; - // POP 화면만 필터링 (POP 레이아웃이 있는 화면만) - const popScreens = screens.filter((screen) => popLayoutScreenIds.has(screen.screenId)); - - // 검색어 필터링 - const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean); - const filteredScreens = popScreens.filter((screen) => { - if (searchKeywords.length > 1) { - return true; // 폴더 계층 검색 시 화면 필터링 없음 + // 화면 설정 모달 열기 + const handleOpenSettings = () => { + if (selectedScreen) { + setIsSettingModalOpen(true); } + }; + + // ============================================================ + // 필터링된 데이터 + // ============================================================ + + // POP 레이아웃이 있는 화면만 필터링 + const popScreens = screens.filter((screen) => popLayoutScreenIds.has(screen.screenId)); + + // 검색어 필터링 + const filteredScreens = popScreens.filter((screen) => { if (!searchTerm) return true; return ( screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) ); }); - - // POP 화면 수 + const popScreenCount = popLayoutScreenIds.size; - // 화면 설계 모드일 때는 POP 전용 디자이너 사용 + // ============================================================ + // 디자인 모드 + // ============================================================ + + const isDesignMode = currentStep === "design"; + if (isDesignMode && selectedScreen) { return (
- goToStep("list")} + goToStep("list")} onScreenUpdate={(updatedFields) => { setSelectedScreen({ ...selectedScreen, @@ -160,6 +208,10 @@ export default function PopScreenManagementPage() { ); } + // ============================================================ + // 목록 모드 렌더링 + // ============================================================ + return (
{/* 페이지 헤더 */} @@ -173,37 +225,13 @@ export default function PopScreenManagementPage() { 모바일/태블릿
-

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

+

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

+
- {/* 디바이스 미리보기 선택 */} - setDevicePreview(v as DevicePreview)}> - - - - 모바일 - - - - 태블릿 - - - -
- {/* 뷰 모드 전환 */} - setViewMode(v as ViewMode)}> - - - - 트리 - - - - 테이블 - - - @@ -224,7 +252,8 @@ export default function PopScreenManagementPage() {

POP 화면이 없습니다

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

- ) : viewMode === "tree" ? ( + ) : (
- {/* 왼쪽: POP 화면 목록 */} -
+ {/* 왼쪽: 카테고리 트리 + 화면 목록 */} +
{/* 검색 */}
@@ -247,7 +276,6 @@ export default function PopScreenManagementPage() { className="pl-9 h-9" />
- {/* POP 화면 수 표시 */}
POP 화면 @@ -255,132 +283,75 @@ export default function PopScreenManagementPage() {
- {/* POP 화면 리스트 */} -
- {filteredScreens.length === 0 ? ( -
- 검색 결과가 없습니다 -
- ) : ( -
- {filteredScreens.map((screen) => ( -
handleScreenSelect(screen)} - onDoubleClick={() => handleDesignScreen(screen)} - > -
-
{screen.screenName}
-
- {screen.screenCode} {screen.tableName && `| ${screen.tableName}`} -
-
-
- - -
-
- ))} + + {/* 카테고리 트리 */} + +
+ + {/* 오른쪽: 미리보기 / 화면 흐름 */} +
+ {/* 오른쪽 패널 헤더 */} +
+ setRightPanelView(v as RightPanelView)}> + + + + 미리보기 + + + + 화면 흐름 + + + + + {selectedScreen && ( +
+ + +
)}
-
- {/* 오른쪽: 관계 시각화 (React Flow) */} -
- -
-
- ) : ( - // 테이블 뷰 - POP 화면만 표시 -
-
- - - - - - - - - - - - {filteredScreens.map((screen) => ( - handleScreenSelect(screen)} - > - - - - - - - ))} - -
화면명화면코드테이블명생성일작업
{screen.screenName}{screen.screenCode}{screen.tableName || "-"} - {screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR") : "-"} - -
- - -
-
+ {/* 오른쪽 패널 콘텐츠 */} +
+ {rightPanelView === "preview" ? ( + + ) : ( + + )} +
)} @@ -399,6 +370,19 @@ export default function PopScreenManagementPage() { isPop={true} /> + {/* 화면 설정 모달 */} + { + if (selectedScreen) { + setSelectedScreen({ ...selectedScreen, ...updatedFields }); + } + loadScreens(); + }} + /> + {/* Scroll to Top 버튼 */}
diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index 31de64bd..daa2350d 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -78,18 +78,24 @@ function PopScreenViewPage() { setScreen(screenData); // POP 레이아웃 로드 (screen_layouts_pop 테이블에서) + // POP 레이아웃은 sections[] 구조 사용 (데스크톱의 components[]와 다름) try { const popLayout = await screenApi.getLayoutPop(screenId); - if (popLayout && popLayout.components && popLayout.components.length > 0) { - // POP 레이아웃이 있으면 사용 - console.log("POP 레이아웃 로드:", popLayout.components?.length || 0, "개 컴포넌트"); + if (popLayout && popLayout.sections && popLayout.sections.length > 0) { + // POP 레이아웃 (sections 구조) - 그대로 저장 + console.log("POP 레이아웃 로드:", popLayout.sections?.length || 0, "개 섹션"); + setLayout(popLayout as any); // sections 구조 그대로 사용 + } else if (popLayout && popLayout.components && popLayout.components.length > 0) { + // 이전 형식 (components 구조) - 호환성 유지 + console.log("POP 레이아웃 로드 (이전 형식):", popLayout.components?.length || 0, "개 컴포넌트"); setLayout(popLayout as LayoutData); } else { // POP 레이아웃이 비어있으면 빈 레이아웃 console.log("POP 레이아웃 없음, 빈 화면 표시"); setLayout({ screenId, + sections: [], components: [], gridSettings: { columns: 12, @@ -101,12 +107,13 @@ function PopScreenViewPage() { opacity: 0.5, snapToGrid: true, }, - }); + } as any); } } catch (layoutError) { console.warn("POP 레이아웃 로드 실패:", layoutError); setLayout({ screenId, + sections: [], components: [], gridSettings: { columns: 12, @@ -118,7 +125,7 @@ function PopScreenViewPage() { opacity: 0.5, snapToGrid: true, }, - }); + } as any); } } catch (error) { console.error("POP 화면 로드 실패:", error); @@ -222,8 +229,58 @@ function PopScreenViewPage() { maxWidth: isPreviewMode ? currentDevice.width : "100%", }} > - {/* 화면 컨텐츠 */} - {layout && layout.components && layout.components.length > 0 ? ( + {/* POP 레이아웃: sections 구조 렌더링 */} + {layout && (layout as any).sections && (layout as any).sections.length > 0 ? ( +
+ {/* 그리드 레이아웃으로 섹션 배치 */} +
+ {(layout as any).sections.map((section: any) => ( +
+ {/* 섹션 라벨 */} + {section.label && ( +
+ {section.label} +
+ )} + {/* 섹션 내 컴포넌트들 */} + {section.components && section.components.length > 0 ? ( +
+ {section.components.map((comp: any) => ( +
+ {/* TODO: POP 전용 컴포넌트 렌더러 구현 필요 */} + + {comp.label || comp.type || comp.id} + +
+ ))} +
+ ) : ( +
+ 빈 섹션 +
+ )} +
+ ))} +
+
+ ) : layout && layout.components && layout.components.length > 0 ? ( + // 이전 형식 (components 구조) - 호환성 유지
{layout.components diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 62499544..2d6df485 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -63,24 +63,31 @@ export default function PopDesigner({ : null; // 레이아웃 로드 + // API는 이미 언래핑된 layout_data를 반환하므로 response 자체가 레이아웃 데이터 useEffect(() => { const loadLayout = async () => { if (!selectedScreen?.screenId) return; setIsLoading(true); try { - const response = await screenApi.getLayoutPop(selectedScreen.screenId); + // API가 layout_data 내용을 직접 반환함 (언래핑된 상태) + const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); - if (response && response.layout_data) { - const loadedLayout = response.layout_data as PopLayoutData; - - if (loadedLayout.version === "pop-1.0") { - setLayout(loadedLayout); - } else { - console.warn("레이아웃 버전 불일치, 새 레이아웃 생성"); - setLayout(createEmptyPopLayout()); - } + if (loadedLayout && loadedLayout.version === "pop-1.0") { + // 유효한 POP 레이아웃 + setLayout(loadedLayout as PopLayoutData); + console.log("POP 레이아웃 로드 성공:", loadedLayout.sections?.length || 0, "개 섹션"); + } else if (loadedLayout && loadedLayout.sections) { + // 버전 태그 없지만 sections 구조가 있으면 사용 + console.warn("버전 태그 없음, sections 구조 감지하여 사용"); + setLayout({ + ...createEmptyPopLayout(), + ...loadedLayout, + version: "pop-1.0", + } as PopLayoutData); } else { + // 레이아웃 없음 - 빈 레이아웃 생성 + console.log("POP 레이아웃 없음, 빈 레이아웃 생성"); setLayout(createEmptyPopLayout()); } } catch (error) { diff --git a/frontend/components/pop/management/PopCategoryTree.tsx b/frontend/components/pop/management/PopCategoryTree.tsx new file mode 100644 index 00000000..d7562fd0 --- /dev/null +++ b/frontend/components/pop/management/PopCategoryTree.tsx @@ -0,0 +1,1095 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { + ChevronRight, + ChevronDown, + ChevronUp, + Folder, + FolderOpen, + Monitor, + Plus, + MoreVertical, + Edit, + Trash2, + Loader2, + RefreshCw, + FolderPlus, + MoveRight, + ArrowUp, + ArrowDown, + Search, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { ScreenDefinition } from "@/types/screen"; +import { apiClient } from "@/lib/api/client"; +import { + PopScreenGroup, + getPopScreenGroups, + createPopScreenGroup, + updatePopScreenGroup, + deletePopScreenGroup, + ensurePopRootGroup, + buildPopGroupTree, +} from "@/lib/api/popScreenGroup"; + +// ============================================================ +// 타입 정의 +// ============================================================ + +interface PopCategoryTreeProps { + screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록 + selectedScreen: ScreenDefinition | null; + onScreenSelect: (screen: ScreenDefinition) => void; + onScreenDesign: (screen: ScreenDefinition) => void; + onGroupSelect?: (group: PopScreenGroup | null) => void; + searchTerm?: string; +} + +interface TreeNodeProps { + group: PopScreenGroup; + level: number; + expandedGroups: Set; + onToggle: (groupId: number) => void; + selectedGroupId: number | null; + selectedScreenId: number | null; + onGroupSelect: (group: PopScreenGroup) => void; + onScreenSelect: (screen: ScreenDefinition) => void; + onScreenDesign: (screen: ScreenDefinition) => void; + onEditGroup: (group: PopScreenGroup) => void; + onDeleteGroup: (group: PopScreenGroup) => void; + onAddSubGroup: (parentGroup: PopScreenGroup) => void; + screensMap: Map; + // 화면 이동/삭제 관련 + onOpenMoveModal: (screen: ScreenDefinition, fromGroupId: number | null) => void; + onRemoveScreenFromGroup: (screen: ScreenDefinition, groupId: number) => void; + // 순서 변경 관련 + siblingGroups: PopScreenGroup[]; // 같은 레벨의 그룹들 + onMoveGroupUp: (group: PopScreenGroup) => void; + onMoveGroupDown: (group: PopScreenGroup) => void; + onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void; + onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void; +} + +// ============================================================ +// 트리 노드 컴포넌트 +// ============================================================ + +function TreeNode({ + group, + level, + onOpenMoveModal, + onRemoveScreenFromGroup, + siblingGroups, + onMoveGroupUp, + onMoveGroupDown, + onMoveScreenUp, + onMoveScreenDown, + expandedGroups, + onToggle, + selectedGroupId, + selectedScreenId, + onGroupSelect, + onScreenSelect, + onScreenDesign, + onEditGroup, + onDeleteGroup, + onAddSubGroup, + screensMap, +}: TreeNodeProps) { + const isExpanded = expandedGroups.has(group.id); + const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0); + const isSelected = selectedGroupId === group.id; + + // 그룹에 연결된 화면 목록 + const groupScreens = useMemo(() => { + if (!group.screens) return []; + return group.screens + .map((gs) => screensMap.get(gs.screen_id)) + .filter((s): s is ScreenDefinition => s !== undefined); + }, [group.screens, screensMap]); + + // 루트 레벨(POP 화면)인지 확인 + const isRootLevel = level === 0; + + // 그룹 순서 변경 가능 여부 계산 + const groupIndex = siblingGroups.findIndex((g) => g.id === group.id); + const canMoveGroupUp = groupIndex > 0; + const canMoveGroupDown = groupIndex < siblingGroups.length - 1; + + return ( +
+ {/* 그룹 노드 */} +
onGroupSelect(group)} + > + {/* 트리 연결 표시 (하위 레벨만) */} + {level > 0 && ( + + )} + + {/* 확장/축소 버튼 */} + + + {/* 폴더 아이콘 - 루트는 다른 색상 */} + {isExpanded && hasChildren ? ( + + ) : ( + + )} + + {/* 그룹명 - 루트는 볼드체 */} + {group.group_name} + + {/* 화면 수 배지 */} + {group.screen_count && group.screen_count > 0 && ( + + {group.screen_count} + + )} + + {/* 더보기 메뉴 */} + + + + + + onAddSubGroup(group)}> + + 하위 그룹 추가 + + onEditGroup(group)}> + + 수정 + + + onMoveGroupUp(group)} + disabled={!canMoveGroupUp} + > + + 위로 이동 + + onMoveGroupDown(group)} + disabled={!canMoveGroupDown} + > + + 아래로 이동 + + + onDeleteGroup(group)} + > + + 삭제 + + + +
+ + {/* 확장된 경우 하위 요소 렌더링 */} + {isExpanded && ( + <> + {/* 하위 그룹 */} + {group.children?.map((child) => ( + + ))} + + {/* 그룹에 연결된 화면 */} + {groupScreens.map((screen, screenIndex) => { + const canMoveScreenUp = screenIndex > 0; + const canMoveScreenDown = screenIndex < groupScreens.length - 1; + + return ( +
onScreenSelect(screen)} + onDoubleClick={() => onScreenDesign(screen)} + > + {/* 트리 연결 표시 */} + + + {screen.screenName} + + {/* 더보기 메뉴 (폴더와 동일한 스타일) */} + + + + + + onScreenDesign(screen)}> + + 설계 + + + onMoveScreenUp(screen, group.id)} + disabled={!canMoveScreenUp} + > + + 위로 이동 + + onMoveScreenDown(screen, group.id)} + disabled={!canMoveScreenDown} + > + + 아래로 이동 + + + onOpenMoveModal(screen, group.id)}> + + 다른 카테고리로 이동 + + + onRemoveScreenFromGroup(screen, group.id)} + > + + 그룹에서 제거 + + + +
+ ); + })} + + )} +
+ ); +} + +// ============================================================ +// 메인 컴포넌트 +// ============================================================ + +export function PopCategoryTree({ + screens, + selectedScreen, + onScreenSelect, + onScreenDesign, + onGroupSelect, + searchTerm = "", +}: PopCategoryTreeProps) { + // 상태 관리 + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [selectedGroupId, setSelectedGroupId] = useState(null); + + // 그룹 모달 상태 + const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); + const [editingGroup, setEditingGroup] = useState(null); + const [parentGroupId, setParentGroupId] = useState(null); + const [groupFormData, setGroupFormData] = useState({ + group_name: "", + group_code: "", + description: "", + icon: "", + }); + + // 삭제 다이얼로그 상태 + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [deletingGroup, setDeletingGroup] = useState(null); + + // 이동 모달 상태 + const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); + const [movingScreen, setMovingScreen] = useState(null); + const [movingFromGroupId, setMovingFromGroupId] = useState(null); + const [moveSearchTerm, setMoveSearchTerm] = useState(""); + + // 화면 맵 생성 (screen_id로 빠르게 조회) + const screensMap = useMemo(() => { + const map = new Map(); + screens.forEach((s) => map.set(s.screenId, s)); + return map; + }, [screens]); + + // 그룹 데이터 로드 + const loadGroups = useCallback(async () => { + try { + setLoading(true); + + // 먼저 POP 루트 그룹 확보 + await ensurePopRootGroup(); + + // 그룹 목록 조회 + const data = await getPopScreenGroups(searchTerm); + setGroups(data); + + // 첫 로드 시 루트 그룹들 자동 확장 + if (expandedGroups.size === 0 && data.length > 0) { + const rootIds = data + .filter((g) => g.hierarchy_path === "POP" || g.hierarchy_path?.split("/").length === 2) + .map((g) => g.id); + setExpandedGroups(new Set(rootIds)); + } + } catch (error) { + console.error("POP 그룹 로드 실패:", error); + toast.error("그룹 목록 로드에 실패했습니다."); + } finally { + setLoading(false); + } + }, [searchTerm]); + + useEffect(() => { + loadGroups(); + }, [loadGroups]); + + // 트리 구조로 변환 + const treeData = useMemo(() => buildPopGroupTree(groups), [groups]); + + // 그룹 토글 + const handleToggle = (groupId: number) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }; + + // 그룹 선택 + const handleGroupSelect = (group: PopScreenGroup) => { + setSelectedGroupId(group.id); + onGroupSelect?.(group); + }; + + // 그룹 생성/수정 모달 열기 + const openGroupModal = (parentGroup?: PopScreenGroup, editGroup?: PopScreenGroup) => { + if (editGroup) { + setEditingGroup(editGroup); + setParentGroupId(editGroup.parent_group_id || null); + setGroupFormData({ + group_name: editGroup.group_name, + group_code: editGroup.group_code, + description: editGroup.description || "", + icon: editGroup.icon || "", + }); + } else { + setEditingGroup(null); + setParentGroupId(parentGroup?.id || null); + setGroupFormData({ + group_name: "", + group_code: "", + description: "", + icon: "", + }); + } + setIsGroupModalOpen(true); + }; + + // 그룹 저장 + const handleSaveGroup = async () => { + if (!groupFormData.group_name || !groupFormData.group_code) { + toast.error("그룹명과 그룹코드는 필수입니다."); + return; + } + + try { + if (editingGroup) { + // 수정 + const result = await updatePopScreenGroup(editingGroup.id, { + group_name: groupFormData.group_name, + description: groupFormData.description, + icon: groupFormData.icon, + }); + if (result.success) { + toast.success("그룹이 수정되었습니다."); + loadGroups(); + } else { + toast.error(result.message || "수정에 실패했습니다."); + } + } else { + // 생성 + const result = await createPopScreenGroup({ + group_name: groupFormData.group_name, + group_code: groupFormData.group_code, + description: groupFormData.description, + icon: groupFormData.icon, + parent_group_id: parentGroupId, + }); + if (result.success) { + toast.success("그룹이 생성되었습니다."); + loadGroups(); + } else { + toast.error(result.message || "생성에 실패했습니다."); + } + } + setIsGroupModalOpen(false); + } catch (error) { + console.error("그룹 저장 실패:", error); + toast.error("그룹 저장에 실패했습니다."); + } + }; + + // 그룹 삭제 + const handleDeleteGroup = async () => { + if (!deletingGroup) return; + + try { + const result = await deletePopScreenGroup(deletingGroup.id); + if (result.success) { + toast.success("그룹이 삭제되었습니다."); + loadGroups(); + if (selectedGroupId === deletingGroup.id) { + setSelectedGroupId(null); + onGroupSelect?.(null); + } + } else { + toast.error(result.message || "삭제에 실패했습니다."); + } + } catch (error) { + console.error("그룹 삭제 실패:", error); + toast.error("그룹 삭제에 실패했습니다."); + } finally { + setIsDeleteDialogOpen(false); + setDeletingGroup(null); + } + }; + + // 화면을 그룹으로 이동 (기존 연결 삭제 후 새 연결 추가) + const handleMoveScreenToGroup = async (screen: ScreenDefinition, targetGroup: PopScreenGroup) => { + try { + // 1. 기존 연결 정보 찾기 (모든 그룹에서 해당 화면의 연결 찾기) + let existingLinkId: number | null = null; + for (const g of groups) { + const screenLink = g.screens?.find((s) => s.screen_id === screen.screenId); + if (screenLink) { + existingLinkId = screenLink.id; + break; + } + } + + // 2. 기존 연결이 있으면 삭제 + if (existingLinkId) { + await apiClient.delete(`/screen-groups/group-screens/${existingLinkId}`); + } + + // 3. 새 그룹에 연결 추가 + const response = await apiClient.post("/screen-groups/group-screens", { + group_id: targetGroup.id, + screen_id: screen.screenId, + screen_role: "main", + display_order: 0, + is_default: false, + }); + + if (response.data.success) { + toast.success(`"${screen.screenName}"을(를) "${(targetGroup as any)._displayName || targetGroup.group_name}"으로 이동했습니다.`); + loadGroups(); // 그룹 목록 새로고침 + } else { + throw new Error(response.data.message || "이동 실패"); + } + } catch (error: any) { + console.error("화면 이동 실패:", error); + toast.error(error.response?.data?.message || error.message || "화면 이동에 실패했습니다."); + } + }; + + // 그룹에서 화면 제거 + const handleRemoveScreenFromGroup = async (screen: ScreenDefinition, groupId: number) => { + try { + // 해당 그룹에서 화면 연결 정보 찾기 + const targetGroup = groups.find((g) => g.id === groupId); + const screenLink = targetGroup?.screens?.find((s) => s.screen_id === screen.screenId); + + if (!screenLink) { + toast.error("연결 정보를 찾을 수 없습니다."); + return; + } + + await apiClient.delete(`/screen-groups/group-screens/${screenLink.id}`); + toast.success(`"${screen.screenName}"을(를) 그룹에서 제거했습니다.`); + loadGroups(); + } catch (error: any) { + console.error("화면 제거 실패:", error); + toast.error(error.response?.data?.message || error.message || "화면 제거에 실패했습니다."); + } + }; + + // 그룹 순서 위로 이동 + const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => { + try { + // 같은 부모의 형제 그룹들 찾기 + const parentId = targetGroup.parent_id; + const siblingGroups = groups + .filter((g) => g.parent_id === parentId) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + + const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); + if (currentIndex <= 0) return; + + const prevGroup = siblingGroups[currentIndex - 1]; + + // 두 그룹의 display_order 교환 + await Promise.all([ + apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { + display_order: prevGroup.display_order || currentIndex - 1 + }), + apiClient.put(`/screen-groups/groups/${prevGroup.id}`, { + display_order: targetGroup.display_order || currentIndex + }), + ]); + + loadGroups(); + } catch (error: any) { + console.error("그룹 순서 변경 실패:", error); + toast.error("순서 변경에 실패했습니다."); + } + }; + + // 그룹 순서 아래로 이동 + const handleMoveGroupDown = async (targetGroup: PopScreenGroup) => { + try { + // 같은 부모의 형제 그룹들 찾기 + const parentId = targetGroup.parent_id; + const siblingGroups = groups + .filter((g) => g.parent_id === parentId) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + + const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); + if (currentIndex >= siblingGroups.length - 1) return; + + const nextGroup = siblingGroups[currentIndex + 1]; + + // 두 그룹의 display_order 교환 + await Promise.all([ + apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { + display_order: nextGroup.display_order || currentIndex + 1 + }), + apiClient.put(`/screen-groups/groups/${nextGroup.id}`, { + display_order: targetGroup.display_order || currentIndex + }), + ]); + + loadGroups(); + } catch (error: any) { + console.error("그룹 순서 변경 실패:", error); + toast.error("순서 변경에 실패했습니다."); + } + }; + + // 화면 순서 위로 이동 + const handleMoveScreenUp = async (screen: ScreenDefinition, groupId: number) => { + try { + const targetGroup = groups.find((g) => g.id === groupId); + if (!targetGroup?.screens) return; + + const sortedScreens = [...targetGroup.screens].sort( + (a, b) => (a.display_order || 0) - (b.display_order || 0) + ); + const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); + if (currentIndex <= 0) return; + + const currentLink = sortedScreens[currentIndex]; + const prevLink = sortedScreens[currentIndex - 1]; + + // 두 화면의 display_order 교환 + await Promise.all([ + apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { + display_order: prevLink.display_order || currentIndex - 1 + }), + apiClient.put(`/screen-groups/group-screens/${prevLink.id}`, { + display_order: currentLink.display_order || currentIndex + }), + ]); + + loadGroups(); + } catch (error: any) { + console.error("화면 순서 변경 실패:", error); + toast.error("순서 변경에 실패했습니다."); + } + }; + + // 화면 순서 아래로 이동 + const handleMoveScreenDown = async (screen: ScreenDefinition, groupId: number) => { + try { + const targetGroup = groups.find((g) => g.id === groupId); + if (!targetGroup?.screens) return; + + const sortedScreens = [...targetGroup.screens].sort( + (a, b) => (a.display_order || 0) - (b.display_order || 0) + ); + const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); + if (currentIndex >= sortedScreens.length - 1) return; + + const currentLink = sortedScreens[currentIndex]; + const nextLink = sortedScreens[currentIndex + 1]; + + // 두 화면의 display_order 교환 + await Promise.all([ + apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { + display_order: nextLink.display_order || currentIndex + 1 + }), + apiClient.put(`/screen-groups/group-screens/${nextLink.id}`, { + display_order: currentLink.display_order || currentIndex + }), + ]); + + loadGroups(); + } catch (error: any) { + console.error("화면 순서 변경 실패:", error); + toast.error("순서 변경에 실패했습니다."); + } + }; + + // 미분류 화면 (그룹에 연결되지 않은 화면) + const ungroupedScreens = useMemo(() => { + const groupedScreenIds = new Set(); + groups.forEach((g) => { + g.screens?.forEach((gs) => groupedScreenIds.add(gs.screen_id)); + }); + return screens.filter((s) => !groupedScreenIds.has(s.screenId)); + }, [groups, screens]); + + // 전체 그룹 평탄화 (이동 드롭다운용) + const flattenedGroups = useMemo(() => { + const result: PopScreenGroup[] = []; + const flatten = (groups: PopScreenGroup[], parentName?: string) => { + groups.forEach((g) => { + // 표시 이름에 부모 경로 추가 + const displayGroup = { + ...g, + _displayName: parentName ? `${parentName} > ${g.group_name}` : g.group_name + }; + result.push(displayGroup); + if (g.children && g.children.length > 0) { + flatten(g.children, displayGroup._displayName); + } + }); + }; + flatten(treeData); + return result; + }, [treeData]); + + // 이동 모달 열기 + const openMoveModal = (screen: ScreenDefinition, fromGroupId: number | null) => { + setMovingScreen(screen); + setMovingFromGroupId(fromGroupId); + setMoveSearchTerm(""); + setIsMoveModalOpen(true); + }; + + // 이동 모달에서 그룹 선택 처리 + const handleMoveToSelectedGroup = async (targetGroup: PopScreenGroup) => { + if (!movingScreen) return; + + await handleMoveScreenToGroup(movingScreen, targetGroup); + setIsMoveModalOpen(false); + setMovingScreen(null); + setMovingFromGroupId(null); + }; + + // 이동 모달용 필터링된 그룹 목록 + const filteredMoveGroups = useMemo(() => { + if (!moveSearchTerm) return flattenedGroups; + const searchLower = moveSearchTerm.toLowerCase(); + return flattenedGroups.filter((g: any) => + (g._displayName || g.group_name).toLowerCase().includes(searchLower) + ); + }, [flattenedGroups, moveSearchTerm]); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 헤더 */} +
+

POP 카테고리

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

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

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

화면 흐름

+
+
+
+ +

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

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

화면 흐름

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

화면 흐름

+
+
+
+ +

POP 레이아웃이 없습니다.

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

화면 흐름

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

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

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

미리보기

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

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

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

레이아웃 확인 중...

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

POP 레이아웃이 없습니다.

+

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

+
+ ) : ( + // 디바이스 프레임 + iframe +
+ {/* 디바이스 노치 (모바일) */} + {deviceType === "mobile" && ( +
+ )} + + {/* 디바이스 홈 버튼 (태블릿) */} + {deviceType === "tablet" && ( +
+ )} + + {/* iframe 컨테이너 */} +
+