Compare commits
3 Commits
3fca677f3d
...
368d641ae8
| Author | SHA1 | Date |
|---|---|---|
|
|
368d641ae8 | |
|
|
d9b7ef9ad4 | |
|
|
8c045acab3 |
|
|
@ -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 화면 시스템
|
||||||
|
|
@ -0,0 +1,911 @@
|
||||||
|
# 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<string, any>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데스크톱 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
|
||||||
|
// 변경 전
|
||||||
|
<GridLayout onLayoutChange={handleLayoutChange} ... />
|
||||||
|
|
||||||
|
// 변경 후
|
||||||
|
<GridLayout onDragStop={handleDragResizeStop} onResizeStop={handleDragResizeStop} ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
상태 업데이트는 드래그/리사이즈 완료 후에만 실행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 && (
|
||||||
|
<span className="text-muted-foreground/50 text-xs mr-1">ㄴ</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
// 루트와 하위 폴더 시각적 구분
|
||||||
|
<Folder className={cn("h-4 w-4 shrink-0", isRootLevel ? "text-orange-500" : "text-amber-500")} />
|
||||||
|
<span className={cn("flex-1 text-sm truncate", isRootLevel && "font-semibold")}>{group.group_name}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 미분류 화면 이동 기능 추가
|
||||||
|
|
||||||
|
**기능:** 미분류 화면을 특정 카테고리로 이동하는 드롭다운 메뉴
|
||||||
|
|
||||||
|
**구현:**
|
||||||
|
```tsx
|
||||||
|
// 이동 드롭다운 메뉴
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<MoveRight className="h-3 w-3 mr-1" />
|
||||||
|
이동
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
{treeData.map((g) => (
|
||||||
|
<DropdownMenuItem onClick={() => handleMoveScreenToGroup(screen, g)}>
|
||||||
|
<Folder className="h-4 w-4 mr-2" />
|
||||||
|
{g.group_name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
// 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<ScreenDefinition | null>(null);
|
||||||
|
const [movingFromGroupId, setMovingFromGroupId] = useState<number | null>(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*
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
export const generateScreenCode = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@ import {
|
||||||
syncMenuToScreenGroupsController,
|
syncMenuToScreenGroupsController,
|
||||||
getSyncStatusController,
|
getSyncStatusController,
|
||||||
syncAllCompaniesController,
|
syncAllCompaniesController,
|
||||||
|
// POP 전용 화면 그룹
|
||||||
|
getPopScreenGroups,
|
||||||
|
createPopScreenGroup,
|
||||||
|
updatePopScreenGroup,
|
||||||
|
deletePopScreenGroup,
|
||||||
|
ensurePopRootGroup,
|
||||||
} from "../controllers/screenGroupController";
|
} from "../controllers/screenGroupController";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -106,6 +112,15 @@ router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController);
|
||||||
// 전체 회사 동기화 (최고 관리자만)
|
// 전체 회사 동기화 (최고 관리자만)
|
||||||
router.post("/sync/all", syncAllCompaniesController);
|
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;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ import {
|
||||||
getLayoutV1,
|
getLayoutV1,
|
||||||
getLayoutV2,
|
getLayoutV2,
|
||||||
saveLayoutV2,
|
saveLayoutV2,
|
||||||
|
getLayoutPop,
|
||||||
|
saveLayoutPop,
|
||||||
|
deleteLayoutPop,
|
||||||
|
getScreenIdsWithPopLayout,
|
||||||
generateScreenCode,
|
generateScreenCode,
|
||||||
generateMultipleScreenCodes,
|
generateMultipleScreenCodes,
|
||||||
assignScreenToMenu,
|
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.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides)
|
||||||
router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장
|
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.post("/screens/:screenId/assign-menu", assignScreenToMenu);
|
||||||
router.get("/menus/:menuObjid/screens", getScreensByMenu);
|
router.get("/menus/:menuObjid/screens", getScreensByMenu);
|
||||||
|
|
|
||||||
|
|
@ -4707,6 +4707,325 @@ export class ScreenManagementService {
|
||||||
|
|
||||||
console.log(`V2 레이아웃 저장 완료`);
|
console.log(`V2 레이아웃 저장 완료`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// POP 레이아웃 관리 (모바일/태블릿)
|
||||||
|
// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP v1 → v2 마이그레이션 (백엔드)
|
||||||
|
* - 단일 sections 배열 → 4모드별 layouts + 공유 sections/components
|
||||||
|
*/
|
||||||
|
private migratePopV1ToV2(v1Data: any): any {
|
||||||
|
console.log("POP v1 → v2 마이그레이션 시작");
|
||||||
|
|
||||||
|
// 기본 v2 구조
|
||||||
|
const v2Data: any = {
|
||||||
|
version: "pop-2.0",
|
||||||
|
layouts: {
|
||||||
|
tablet_landscape: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
tablet_portrait: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
mobile_landscape: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
mobile_portrait: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
},
|
||||||
|
sections: {},
|
||||||
|
components: {},
|
||||||
|
dataFlow: {
|
||||||
|
sectionConnections: [],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
touchTargetMin: 48,
|
||||||
|
mode: "normal",
|
||||||
|
canvasGrid: v1Data.canvasGrid || { columns: 24, rowHeight: 20, gap: 4 },
|
||||||
|
},
|
||||||
|
metadata: v1Data.metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
// v1 섹션 배열 처리
|
||||||
|
const sections = v1Data.sections || [];
|
||||||
|
const modeKeys = ["tablet_landscape", "tablet_portrait", "mobile_landscape", "mobile_portrait"];
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
// 섹션 정의 생성
|
||||||
|
v2Data.sections[section.id] = {
|
||||||
|
id: section.id,
|
||||||
|
label: section.label,
|
||||||
|
componentIds: (section.components || []).map((c: any) => c.id),
|
||||||
|
innerGrid: section.innerGrid || { columns: 3, rows: 3, gap: 4 },
|
||||||
|
style: section.style,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 섹션 위치 복사 (4모드 모두 동일)
|
||||||
|
const sectionPos = section.grid || { col: 1, row: 1, colSpan: 3, rowSpan: 4 };
|
||||||
|
for (const mode of modeKeys) {
|
||||||
|
v2Data.layouts[mode].sectionPositions[section.id] = { ...sectionPos };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트별 처리
|
||||||
|
for (const comp of section.components || []) {
|
||||||
|
// 컴포넌트 정의 생성
|
||||||
|
v2Data.components[comp.id] = {
|
||||||
|
id: comp.id,
|
||||||
|
type: comp.type,
|
||||||
|
label: comp.label,
|
||||||
|
dataBinding: comp.dataBinding,
|
||||||
|
style: comp.style,
|
||||||
|
config: comp.config,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트 위치 복사 (4모드 모두 동일)
|
||||||
|
const compPos = comp.grid || { col: 1, row: 1, colSpan: 1, rowSpan: 1 };
|
||||||
|
for (const mode of modeKeys) {
|
||||||
|
v2Data.layouts[mode].componentPositions[comp.id] = { ...compPos };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionCount = Object.keys(v2Data.sections).length;
|
||||||
|
const componentCount = Object.keys(v2Data.components).length;
|
||||||
|
console.log(`POP v1 → v2 마이그레이션 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
||||||
|
|
||||||
|
return v2Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 레이아웃 조회
|
||||||
|
* - screen_layouts_pop 테이블에서 화면당 1개 레코드 조회
|
||||||
|
* - v1 데이터는 자동으로 v2로 마이그레이션하여 반환
|
||||||
|
*/
|
||||||
|
async getLayoutPop(
|
||||||
|
screenId: number,
|
||||||
|
companyCode: string,
|
||||||
|
userType?: string,
|
||||||
|
): Promise<any | null> {
|
||||||
|
console.log(`=== POP 레이아웃 로드 시작 ===`);
|
||||||
|
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`);
|
||||||
|
|
||||||
|
// SUPER_ADMIN 여부 확인
|
||||||
|
const isSuperAdmin = userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
// 권한 확인
|
||||||
|
const screens = await query<{
|
||||||
|
company_code: string | null;
|
||||||
|
table_name: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||||
|
[screenId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (screens.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingScreen = screens[0];
|
||||||
|
|
||||||
|
// SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음
|
||||||
|
if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) {
|
||||||
|
throw new Error("이 화면의 POP 레이아웃을 조회할 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout: { layout_data: any } | null = null;
|
||||||
|
|
||||||
|
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
|
||||||
|
if (isSuperAdmin) {
|
||||||
|
// 1. 화면 정의의 회사 코드로 레이아웃 조회
|
||||||
|
layout = await queryOne<{ layout_data: any }>(
|
||||||
|
`SELECT layout_data FROM screen_layouts_pop
|
||||||
|
WHERE screen_id = $1 AND company_code = $2`,
|
||||||
|
[screenId, existingScreen.company_code],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회
|
||||||
|
if (!layout) {
|
||||||
|
layout = await queryOne<{ layout_data: any }>(
|
||||||
|
`SELECT layout_data FROM screen_layouts_pop
|
||||||
|
WHERE screen_id = $1
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[screenId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 일반 사용자: 회사별 우선, 없으면 공통(*) 조회
|
||||||
|
layout = await queryOne<{ layout_data: any }>(
|
||||||
|
`SELECT layout_data FROM screen_layouts_pop
|
||||||
|
WHERE screen_id = $1 AND company_code = $2`,
|
||||||
|
[screenId, companyCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
|
||||||
|
if (!layout && companyCode !== "*") {
|
||||||
|
layout = await queryOne<{ layout_data: any }>(
|
||||||
|
`SELECT layout_data FROM screen_layouts_pop
|
||||||
|
WHERE screen_id = $1 AND company_code = '*'`,
|
||||||
|
[screenId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layout) {
|
||||||
|
console.log(`POP 레이아웃 없음: screen_id=${screenId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutData = layout.layout_data;
|
||||||
|
|
||||||
|
// v1 → v2 자동 마이그레이션
|
||||||
|
if (layoutData && layoutData.version === "pop-1.0") {
|
||||||
|
console.log("POP v1 레이아웃 감지, v2로 마이그레이션");
|
||||||
|
return this.migratePopV1ToV2(layoutData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2 또는 버전 태그 없는 경우 (버전 태그 없으면 sections 구조 확인)
|
||||||
|
if (layoutData && !layoutData.version && layoutData.sections && Array.isArray(layoutData.sections)) {
|
||||||
|
console.log("버전 태그 없는 v1 레이아웃 감지, v2로 마이그레이션");
|
||||||
|
return this.migratePopV1ToV2({ ...layoutData, version: "pop-1.0" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2 레이아웃 그대로 반환
|
||||||
|
const sectionCount = layoutData?.sections ? Object.keys(layoutData.sections).length : 0;
|
||||||
|
const componentCount = layoutData?.components ? Object.keys(layoutData.components).length : 0;
|
||||||
|
console.log(`POP v2 레이아웃 로드 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
||||||
|
|
||||||
|
return layoutData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 레이아웃 저장
|
||||||
|
* - screen_layouts_pop 테이블에 화면당 1개 레코드 저장
|
||||||
|
* - v2 형식으로 저장 (version: "pop-2.0")
|
||||||
|
*/
|
||||||
|
async saveLayoutPop(
|
||||||
|
screenId: number,
|
||||||
|
layoutData: any,
|
||||||
|
companyCode: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
console.log(`=== POP 레이아웃 저장 시작 ===`);
|
||||||
|
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
||||||
|
|
||||||
|
// v2 구조 확인
|
||||||
|
const isV2 = layoutData.version === "pop-2.0" ||
|
||||||
|
(layoutData.layouts && layoutData.sections && layoutData.components);
|
||||||
|
|
||||||
|
if (isV2) {
|
||||||
|
const sectionCount = Object.keys(layoutData.sections || {}).length;
|
||||||
|
const componentCount = Object.keys(layoutData.components || {}).length;
|
||||||
|
console.log(`v2 레이아웃: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
||||||
|
} else {
|
||||||
|
console.log(`v1 레이아웃 (섹션 수: ${layoutData.sections?.length || 0})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 확인
|
||||||
|
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 레이아웃을 저장할 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 버전 정보 보장 (v2 우선, v1은 프론트엔드에서 마이그레이션 후 저장 권장)
|
||||||
|
let dataToSave: any;
|
||||||
|
if (isV2) {
|
||||||
|
dataToSave = {
|
||||||
|
...layoutData,
|
||||||
|
version: "pop-2.0",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// v1 형식으로 저장 (하위 호환)
|
||||||
|
dataToSave = {
|
||||||
|
version: "pop-1.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 레이아웃 저장 완료 (version: ${dataToSave.version})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 레이아웃이 존재하는 화면 ID 목록 조회
|
||||||
|
* - 옵션 B: POP 레이아웃 존재 여부로 화면 구분
|
||||||
|
*/
|
||||||
|
async getScreenIdsWithPopLayout(
|
||||||
|
companyCode: string,
|
||||||
|
): Promise<number[]> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
// 서비스 인스턴스 export
|
||||||
|
|
|
||||||
|
|
@ -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*
|
||||||
|
|
@ -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*
|
||||||
|
|
@ -0,0 +1,390 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Smartphone,
|
||||||
|
Eye,
|
||||||
|
Settings,
|
||||||
|
LayoutGrid,
|
||||||
|
GitBranch,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { PopDesigner } from "@/components/pop/designer";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import CreateScreenModal from "@/components/screen/CreateScreenModal";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
PopCategoryTree,
|
||||||
|
PopScreenPreview,
|
||||||
|
PopScreenFlowView,
|
||||||
|
PopScreenSettingModal,
|
||||||
|
} from "@/components/pop/management";
|
||||||
|
import { PopScreenGroup } from "@/lib/api/popScreenGroup";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 타입 정의
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type Step = "list" | "design";
|
||||||
|
type DevicePreview = "mobile" | "tablet";
|
||||||
|
type RightPanelView = "preview" | "flow";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export default function PopScreenManagementPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// 단계 및 화면 상태
|
||||||
|
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||||
|
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<PopScreenGroup | null>(null);
|
||||||
|
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||||
|
|
||||||
|
// 화면 데이터
|
||||||
|
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// POP 레이아웃 존재 화면 ID
|
||||||
|
const [popLayoutScreenIds, setPopLayoutScreenIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// UI 상태
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
|
||||||
|
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
|
||||||
|
const [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview");
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 데이터 로드
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const loadScreens = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [result, popScreenIds] = await Promise.all([
|
||||||
|
screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" }),
|
||||||
|
screenApi.getScreenIdsWithPopLayout(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.data && result.data.length > 0) {
|
||||||
|
setScreens(result.data);
|
||||||
|
}
|
||||||
|
setPopLayoutScreenIds(new Set(popScreenIds));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("POP 화면 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadScreens();
|
||||||
|
}, [loadScreens]);
|
||||||
|
|
||||||
|
// 화면 목록 새로고침 이벤트 리스너
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScreenListRefresh = () => {
|
||||||
|
console.log("POP 화면 목록 새로고침 이벤트 수신");
|
||||||
|
loadScreens();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("screen-list-refresh", handleScreenListRefresh);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("screen-list-refresh", handleScreenListRefresh);
|
||||||
|
};
|
||||||
|
}, [loadScreens]);
|
||||||
|
|
||||||
|
// URL 쿼리 파라미터로 화면 디자이너 자동 열기
|
||||||
|
useEffect(() => {
|
||||||
|
const openDesignerId = searchParams.get("openDesigner");
|
||||||
|
if (openDesignerId && screens.length > 0) {
|
||||||
|
const screenId = parseInt(openDesignerId, 10);
|
||||||
|
const targetScreen = screens.find((s) => s.screenId === screenId);
|
||||||
|
if (targetScreen) {
|
||||||
|
setSelectedScreen(targetScreen);
|
||||||
|
setCurrentStep("design");
|
||||||
|
setStepHistory(["list", "design"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [searchParams, screens]);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 핸들러
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const goToNextStep = (nextStep: Step) => {
|
||||||
|
setStepHistory((prev) => [...prev, nextStep]);
|
||||||
|
setCurrentStep(nextStep);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToStep = (step: Step) => {
|
||||||
|
setCurrentStep(step);
|
||||||
|
const stepIndex = stepHistory.findIndex((s) => s === step);
|
||||||
|
if (stepIndex !== -1) {
|
||||||
|
setStepHistory(stepHistory.slice(0, stepIndex + 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 선택
|
||||||
|
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||||||
|
setSelectedScreen(screen);
|
||||||
|
setSelectedGroup(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹 선택
|
||||||
|
const handleGroupSelect = (group: PopScreenGroup | null) => {
|
||||||
|
setSelectedGroup(group);
|
||||||
|
// 그룹 선택 시 화면 선택 해제하지 않음 (미리보기 유지)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 디자인 모드 진입
|
||||||
|
const handleDesignScreen = (screen: ScreenDefinition) => {
|
||||||
|
setSelectedScreen(screen);
|
||||||
|
goToNextStep("design");
|
||||||
|
};
|
||||||
|
|
||||||
|
// POP 화면 미리보기 (새 탭에서 열기)
|
||||||
|
const handlePreviewScreen = (screen: ScreenDefinition) => {
|
||||||
|
const previewUrl = `/pop/screens/${screen.screenId}?preview=true&device=${devicePreview}`;
|
||||||
|
window.open(previewUrl, "_blank", "width=800,height=900");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 설정 모달 열기
|
||||||
|
const handleOpenSettings = () => {
|
||||||
|
if (selectedScreen) {
|
||||||
|
setIsSettingModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 필터링된 데이터
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// POP 레이아웃이 있는 화면만 필터링
|
||||||
|
const popScreens = screens.filter((screen) => popLayoutScreenIds.has(screen.screenId));
|
||||||
|
|
||||||
|
// 검색어 필터링
|
||||||
|
const filteredScreens = popScreens.filter((screen) => {
|
||||||
|
if (!searchTerm) return true;
|
||||||
|
return (
|
||||||
|
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const popScreenCount = popLayoutScreenIds.size;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 디자인 모드
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const isDesignMode = currentStep === "design";
|
||||||
|
|
||||||
|
if (isDesignMode && selectedScreen) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-background">
|
||||||
|
<PopDesigner
|
||||||
|
selectedScreen={selectedScreen}
|
||||||
|
onBackToList={() => goToStep("list")}
|
||||||
|
onScreenUpdate={(updatedFields) => {
|
||||||
|
setSelectedScreen({
|
||||||
|
...selectedScreen,
|
||||||
|
...updatedFields,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 목록 모드 렌더링
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-background overflow-hidden">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="shrink-0 border-b bg-background px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">POP 화면 관리</h1>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
모바일/태블릿
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
POP 화면을 카테고리별로 관리하고 모바일/태블릿에 최적화된 화면을 설계합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="icon" onClick={loadScreens}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
새 POP 화면
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
{popScreenCount === 0 ? (
|
||||||
|
// POP 화면이 없을 때 빈 상태 표시
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-center p-8">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||||
|
<Smartphone className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">POP 화면이 없습니다</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||||
|
아직 생성된 POP 화면이 없습니다.
|
||||||
|
<br />
|
||||||
|
"새 POP 화면" 버튼을 클릭하여 모바일/태블릿용 화면을 만들어보세요.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
새 POP 화면 만들기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-hidden flex">
|
||||||
|
{/* 왼쪽: 카테고리 트리 + 화면 목록 */}
|
||||||
|
<div className="w-[320px] min-w-[280px] max-w-[400px] flex flex-col border-r bg-background">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="shrink-0 p-3 border-b">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="POP 화면 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span className="text-xs text-muted-foreground">POP 화면</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{popScreenCount}개
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 트리 */}
|
||||||
|
<PopCategoryTree
|
||||||
|
screens={filteredScreens}
|
||||||
|
selectedScreen={selectedScreen}
|
||||||
|
onScreenSelect={handleScreenSelect}
|
||||||
|
onScreenDesign={handleDesignScreen}
|
||||||
|
onGroupSelect={handleGroupSelect}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 미리보기 / 화면 흐름 */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* 오른쪽 패널 헤더 */}
|
||||||
|
<div className="shrink-0 px-4 py-2 border-b bg-background flex items-center justify-between">
|
||||||
|
<Tabs value={rightPanelView} onValueChange={(v) => setRightPanelView(v as RightPanelView)}>
|
||||||
|
<TabsList className="h-8">
|
||||||
|
<TabsTrigger value="preview" className="h-7 px-3 text-xs gap-1.5">
|
||||||
|
<LayoutGrid className="h-3.5 w-3.5" />
|
||||||
|
미리보기
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="flow" className="h-7 px-3 text-xs gap-1.5">
|
||||||
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
|
화면 흐름
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{selectedScreen && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => handlePreviewScreen(selectedScreen)}
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5 mr-1" />
|
||||||
|
새 탭
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={handleOpenSettings}
|
||||||
|
>
|
||||||
|
<Settings className="h-3.5 w-3.5 mr-1" />
|
||||||
|
설정
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-3 text-xs"
|
||||||
|
onClick={() => handleDesignScreen(selectedScreen)}
|
||||||
|
>
|
||||||
|
설계
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽 패널 콘텐츠 */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{rightPanelView === "preview" ? (
|
||||||
|
<PopScreenPreview screen={selectedScreen} className="h-full" />
|
||||||
|
) : (
|
||||||
|
<PopScreenFlowView screen={selectedScreen} className="h-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 화면 생성 모달 */}
|
||||||
|
<CreateScreenModal
|
||||||
|
open={isCreateOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsCreateOpen(open);
|
||||||
|
if (!open) loadScreens();
|
||||||
|
}}
|
||||||
|
onCreated={() => {
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
loadScreens();
|
||||||
|
}}
|
||||||
|
isPop={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 화면 설정 모달 */}
|
||||||
|
<PopScreenSettingModal
|
||||||
|
open={isSettingModalOpen}
|
||||||
|
onOpenChange={setIsSettingModalOpen}
|
||||||
|
screen={selectedScreen}
|
||||||
|
onSave={(updatedFields) => {
|
||||||
|
if (selectedScreen) {
|
||||||
|
setSelectedScreen({ ...selectedScreen, ...updatedFields });
|
||||||
|
}
|
||||||
|
loadScreens();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 */}
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,362 @@
|
||||||
|
"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<DeviceType>(deviceParam || "tablet");
|
||||||
|
|
||||||
|
// 프리뷰 모드 (디자이너에서 열렸을 때)
|
||||||
|
const isPreviewMode = searchParams.get("preview") === "true";
|
||||||
|
|
||||||
|
// 사용자 정보
|
||||||
|
const { user, userName, companyCode } = useAuth();
|
||||||
|
|
||||||
|
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
||||||
|
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||||
|
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
||||||
|
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 테이블에서)
|
||||||
|
// POP 레이아웃은 sections[] 구조 사용 (데스크톱의 components[]와 다름)
|
||||||
|
try {
|
||||||
|
const popLayout = await screenApi.getLayoutPop(screenId);
|
||||||
|
|
||||||
|
if (popLayout && popLayout.sections && popLayout.sections.length > 0) {
|
||||||
|
// POP 레이아웃 (sections 구조) - 그대로 저장
|
||||||
|
console.log("POP 레이아웃 로드:", popLayout.sections?.length || 0, "개 섹션");
|
||||||
|
setLayout(popLayout as any); // sections 구조 그대로 사용
|
||||||
|
} else if (popLayout && popLayout.components && popLayout.components.length > 0) {
|
||||||
|
// 이전 형식 (components 구조) - 호환성 유지
|
||||||
|
console.log("POP 레이아웃 로드 (이전 형식):", popLayout.components?.length || 0, "개 컴포넌트");
|
||||||
|
setLayout(popLayout as LayoutData);
|
||||||
|
} else {
|
||||||
|
// POP 레이아웃이 비어있으면 빈 레이아웃
|
||||||
|
console.log("POP 레이아웃 없음, 빈 화면 표시");
|
||||||
|
setLayout({
|
||||||
|
screenId,
|
||||||
|
sections: [],
|
||||||
|
components: [],
|
||||||
|
gridSettings: {
|
||||||
|
columns: 12,
|
||||||
|
gap: 8,
|
||||||
|
padding: 16,
|
||||||
|
enabled: true,
|
||||||
|
size: 8,
|
||||||
|
color: "#e0e0e0",
|
||||||
|
opacity: 0.5,
|
||||||
|
snapToGrid: true,
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
} catch (layoutError) {
|
||||||
|
console.warn("POP 레이아웃 로드 실패:", layoutError);
|
||||||
|
setLayout({
|
||||||
|
screenId,
|
||||||
|
sections: [],
|
||||||
|
components: [],
|
||||||
|
gridSettings: {
|
||||||
|
columns: 12,
|
||||||
|
gap: 8,
|
||||||
|
padding: 16,
|
||||||
|
enabled: true,
|
||||||
|
size: 8,
|
||||||
|
color: "#e0e0e0",
|
||||||
|
opacity: 0.5,
|
||||||
|
snapToGrid: true,
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("POP 화면 로드 실패:", error);
|
||||||
|
setError("화면을 불러오는데 실패했습니다.");
|
||||||
|
toast.error("화면을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (screenId) {
|
||||||
|
loadScreen();
|
||||||
|
}
|
||||||
|
}, [screenId]);
|
||||||
|
|
||||||
|
// 현재 디바이스 크기
|
||||||
|
const currentDevice = DEVICE_SIZES[deviceType];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full items-center justify-center bg-gray-100">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-500" />
|
||||||
|
<p className="mt-4 text-gray-600">POP 화면 로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !screen) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full items-center justify-center bg-gray-100">
|
||||||
|
<div className="text-center max-w-md p-6">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||||
|
<span className="text-2xl">!</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-2 text-xl font-bold text-gray-800">화면을 찾을 수 없습니다</h2>
|
||||||
|
<p className="mb-4 text-gray-600">{error || "요청하신 POP 화면이 존재하지 않습니다."}</p>
|
||||||
|
<Button onClick={() => router.back()} variant="outline">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
돌아가기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenPreviewProvider isPreviewMode={isPreviewMode}>
|
||||||
|
<ActiveTabProvider>
|
||||||
|
<TableOptionsProvider>
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
{/* 상단 툴바 (프리뷰 모드에서만) */}
|
||||||
|
{isPreviewMode && (
|
||||||
|
<div className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => window.close()}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium">{screen.screenName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 디바이스 전환 버튼 */}
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
variant={deviceType === "mobile" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeviceType("mobile")}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Smartphone className="h-4 w-4" />
|
||||||
|
모바일
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={deviceType === "tablet" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeviceType("tablet")}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Tablet className="h-4 w-4" />
|
||||||
|
태블릿
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => window.location.reload()}>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* POP 화면 컨텐츠 */}
|
||||||
|
<div className={`flex justify-center ${isPreviewMode ? "py-4" : "py-0"}`}>
|
||||||
|
<div
|
||||||
|
className={`bg-white ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-hidden border-8 border-gray-800" : ""}`}
|
||||||
|
style={{
|
||||||
|
width: isPreviewMode ? currentDevice.width : "100%",
|
||||||
|
minHeight: isPreviewMode ? currentDevice.height : "100vh",
|
||||||
|
maxWidth: isPreviewMode ? currentDevice.width : "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* POP 레이아웃: sections 구조 렌더링 */}
|
||||||
|
{layout && (layout as any).sections && (layout as any).sections.length > 0 ? (
|
||||||
|
<div className="w-full min-h-full p-2">
|
||||||
|
{/* 그리드 레이아웃으로 섹션 배치 */}
|
||||||
|
<div
|
||||||
|
className="grid gap-1"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${(layout as any).canvasGrid?.columns || 24}, 1fr)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(layout as any).sections.map((section: any) => (
|
||||||
|
<div
|
||||||
|
key={section.id}
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-lg p-2"
|
||||||
|
style={{
|
||||||
|
gridColumn: `${section.grid?.col || 1} / span ${section.grid?.colSpan || 6}`,
|
||||||
|
gridRow: `${section.grid?.row || 1} / span ${section.grid?.rowSpan || 4}`,
|
||||||
|
minHeight: `${(section.grid?.rowSpan || 4) * 20}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 섹션 라벨 */}
|
||||||
|
{section.label && (
|
||||||
|
<div className="text-xs font-medium text-gray-500 mb-1">
|
||||||
|
{section.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 섹션 내 컴포넌트들 */}
|
||||||
|
{section.components && section.components.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{section.components.map((comp: any) => (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className="bg-white border border-gray-100 rounded p-2 text-sm"
|
||||||
|
>
|
||||||
|
{/* TODO: POP 전용 컴포넌트 렌더러 구현 필요 */}
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{comp.label || comp.type || comp.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-400 text-center py-2">
|
||||||
|
빈 섹션
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : layout && layout.components && layout.components.length > 0 ? (
|
||||||
|
// 이전 형식 (components 구조) - 호환성 유지
|
||||||
|
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
|
||||||
|
<div className="relative w-full min-h-full p-4">
|
||||||
|
{layout.components
|
||||||
|
.filter((component) => !component.parentId)
|
||||||
|
.map((component) => (
|
||||||
|
<div
|
||||||
|
key={component.id}
|
||||||
|
style={{
|
||||||
|
position: component.position ? "absolute" : "relative",
|
||||||
|
left: component.position?.x || 0,
|
||||||
|
top: component.position?.y || 0,
|
||||||
|
width: component.size?.width || "100%",
|
||||||
|
height: component.size?.height || "auto",
|
||||||
|
zIndex: component.position?.z || 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DynamicComponentRenderer
|
||||||
|
component={component}
|
||||||
|
isDesignMode={false}
|
||||||
|
isInteractive={true}
|
||||||
|
formData={formData}
|
||||||
|
onDataflowComplete={() => { }}
|
||||||
|
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 }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScreenMultiLangProvider>
|
||||||
|
) : (
|
||||||
|
// 빈 화면
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||||
|
<Smartphone className="h-8 w-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-2">
|
||||||
|
화면이 비어있습니다
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 max-w-xs">
|
||||||
|
POP 화면 디자이너에서 컴포넌트를 추가하여 화면을 구성하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableOptionsProvider>
|
||||||
|
</ActiveTabProvider>
|
||||||
|
</ScreenPreviewProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider 래퍼
|
||||||
|
export default function PopScreenViewPageWrapper() {
|
||||||
|
return (
|
||||||
|
<TableSearchWidgetHeightProvider>
|
||||||
|
<ScreenContextProvider>
|
||||||
|
<SplitPanelProvider>
|
||||||
|
<PopScreenViewPage />
|
||||||
|
</SplitPanelProvider>
|
||||||
|
</ScreenContextProvider>
|
||||||
|
</TableSearchWidgetHeightProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,588 @@
|
||||||
|
"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 {
|
||||||
|
PopLayoutDataV2,
|
||||||
|
PopLayoutModeKey,
|
||||||
|
PopComponentType,
|
||||||
|
GridPosition,
|
||||||
|
MODE_RESOLUTIONS,
|
||||||
|
} from "./types/pop-layout";
|
||||||
|
import { DND_ITEM_TYPES, DragItemSection } from "./panels/PopPanel";
|
||||||
|
import { GripVertical, Trash2, ZoomIn, ZoomOut, Maximize2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { SectionGridV2 } from "./SectionGridV2";
|
||||||
|
|
||||||
|
import "react-grid-layout/css/styles.css";
|
||||||
|
import "react-resizable/css/styles.css";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 타입 정의
|
||||||
|
// ========================================
|
||||||
|
type DeviceType = "mobile" | "tablet";
|
||||||
|
|
||||||
|
// 모드별 라벨
|
||||||
|
const MODE_LABELS: Record<PopLayoutModeKey, string> = {
|
||||||
|
tablet_landscape: "태블릿 가로",
|
||||||
|
tablet_portrait: "태블릿 세로",
|
||||||
|
mobile_landscape: "모바일 가로",
|
||||||
|
mobile_portrait: "모바일 세로",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props
|
||||||
|
// ========================================
|
||||||
|
interface PopCanvasProps {
|
||||||
|
layout: PopLayoutDataV2;
|
||||||
|
activeDevice: DeviceType;
|
||||||
|
activeModeKey: PopLayoutModeKey;
|
||||||
|
onModeKeyChange: (modeKey: PopLayoutModeKey) => void;
|
||||||
|
selectedSectionId: string | null;
|
||||||
|
selectedComponentId: string | null;
|
||||||
|
onSelectSection: (id: string | null) => void;
|
||||||
|
onSelectComponent: (id: string | null) => void;
|
||||||
|
onUpdateSectionPosition: (sectionId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void;
|
||||||
|
onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void;
|
||||||
|
onDeleteSection: (id: string) => void;
|
||||||
|
onDropSection: (gridPosition: GridPosition) => void;
|
||||||
|
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
|
||||||
|
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ========================================
|
||||||
|
export function PopCanvas({
|
||||||
|
layout,
|
||||||
|
activeDevice,
|
||||||
|
activeModeKey,
|
||||||
|
onModeKeyChange,
|
||||||
|
selectedSectionId,
|
||||||
|
selectedComponentId,
|
||||||
|
onSelectSection,
|
||||||
|
onSelectComponent,
|
||||||
|
onUpdateSectionPosition,
|
||||||
|
onUpdateComponentPosition,
|
||||||
|
onDeleteSection,
|
||||||
|
onDropSection,
|
||||||
|
onDropComponent,
|
||||||
|
onDeleteComponent,
|
||||||
|
}: PopCanvasProps) {
|
||||||
|
const { settings, sections, components, layouts } = layout;
|
||||||
|
const canvasGrid = settings.canvasGrid;
|
||||||
|
|
||||||
|
// 줌 상태 (0.3 ~ 1.0 범위)
|
||||||
|
const [canvasScale, setCanvasScale] = useState(0.6);
|
||||||
|
|
||||||
|
// 패닝 상태
|
||||||
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||||
|
const [isSpacePressed, setIsSpacePressed] = useState(false); // Space 키 눌림 상태
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 줌 인 (최대 1.5로 증가)
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 줌 아웃 (최소 0.3)
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 맞춤 (1.0)
|
||||||
|
const handleZoomFit = () => {
|
||||||
|
setCanvasScale(1.0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 패닝 시작 (중앙 마우스 버튼 또는 배경 영역 드래그)
|
||||||
|
const handlePanStart = (e: React.MouseEvent) => {
|
||||||
|
// 중앙 마우스 버튼(휠 버튼, button === 1) 또는 Space 키 누른 상태
|
||||||
|
// 또는 내부 컨테이너(스크롤 영역) 직접 클릭 시
|
||||||
|
const isMiddleButton = e.button === 1;
|
||||||
|
const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area");
|
||||||
|
|
||||||
|
if (isMiddleButton || isSpacePressed || isScrollAreaClick) {
|
||||||
|
setIsPanning(true);
|
||||||
|
setPanStart({ x: e.clientX, y: e.clientY });
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 패닝 중
|
||||||
|
const handlePanMove = (e: React.MouseEvent) => {
|
||||||
|
if (!isPanning || !containerRef.current) return;
|
||||||
|
|
||||||
|
const deltaX = e.clientX - panStart.x;
|
||||||
|
const deltaY = e.clientY - panStart.y;
|
||||||
|
|
||||||
|
containerRef.current.scrollLeft -= deltaX;
|
||||||
|
containerRef.current.scrollTop -= deltaY;
|
||||||
|
|
||||||
|
setPanStart({ x: e.clientX, y: e.clientY });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 패닝 종료
|
||||||
|
const handlePanEnd = () => {
|
||||||
|
setIsPanning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 마우스 휠 줌 (0.3 ~ 1.5 범위)
|
||||||
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
|
e.preventDefault(); // 브라우저 스크롤 방지
|
||||||
|
|
||||||
|
const delta = e.deltaY > 0 ? -0.1 : 0.1; // 위로 스크롤: 줌인, 아래로 스크롤: 줌아웃
|
||||||
|
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Space 키 감지
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === "Space" && !isSpacePressed) {
|
||||||
|
setIsSpacePressed(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === "Space") {
|
||||||
|
setIsSpacePressed(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener("keyup", handleKeyUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
|
};
|
||||||
|
}, [isSpacePressed]);
|
||||||
|
|
||||||
|
// 초기 로드 시 캔버스를 중앙으로 스크롤
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const container = containerRef.current;
|
||||||
|
// 약간의 딜레이 후 중앙으로 스크롤 (DOM이 완전히 렌더링된 후)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const scrollX = (container.scrollWidth - container.clientWidth) / 2;
|
||||||
|
const scrollY = (container.scrollHeight - container.clientHeight) / 2;
|
||||||
|
container.scrollTo(scrollX, scrollY);
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [activeDevice]); // 디바이스 변경 시 재중앙화
|
||||||
|
|
||||||
|
// 현재 디바이스의 가로/세로 모드 키
|
||||||
|
const landscapeModeKey: PopLayoutModeKey = activeDevice === "tablet"
|
||||||
|
? "tablet_landscape"
|
||||||
|
: "mobile_landscape";
|
||||||
|
const portraitModeKey: PopLayoutModeKey = activeDevice === "tablet"
|
||||||
|
? "tablet_portrait"
|
||||||
|
: "mobile_portrait";
|
||||||
|
|
||||||
|
// 단일 캔버스 프레임 렌더링
|
||||||
|
const renderDeviceFrame = (modeKey: PopLayoutModeKey) => {
|
||||||
|
const resolution = MODE_RESOLUTIONS[modeKey];
|
||||||
|
const isActive = modeKey === activeModeKey;
|
||||||
|
const modeLayout = layouts[modeKey];
|
||||||
|
|
||||||
|
// 이 모드의 섹션 위치 목록
|
||||||
|
const sectionPositions = modeLayout.sectionPositions;
|
||||||
|
const sectionIds = Object.keys(sectionPositions);
|
||||||
|
|
||||||
|
// GridLayout용 레이아웃 아이템 생성
|
||||||
|
const gridLayoutItems: Layout[] = sectionIds.map((sectionId) => {
|
||||||
|
const pos = sectionPositions[sectionId];
|
||||||
|
return {
|
||||||
|
i: sectionId,
|
||||||
|
x: pos.col - 1,
|
||||||
|
y: pos.row - 1,
|
||||||
|
w: pos.colSpan,
|
||||||
|
h: pos.rowSpan,
|
||||||
|
minW: 2,
|
||||||
|
minH: 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const cols = canvasGrid.columns;
|
||||||
|
const rowHeight = canvasGrid.rowHeight;
|
||||||
|
const margin: [number, number] = [canvasGrid.gap, canvasGrid.gap];
|
||||||
|
|
||||||
|
const sizeLabel = `${resolution.width}x${resolution.height}`;
|
||||||
|
const modeLabel = `${MODE_LABELS[modeKey]} (${sizeLabel})`;
|
||||||
|
|
||||||
|
// 드래그/리사이즈 완료 핸들러
|
||||||
|
const handleDragResizeStop = (
|
||||||
|
layoutItems: Layout[],
|
||||||
|
oldItem: Layout,
|
||||||
|
newItem: Layout
|
||||||
|
) => {
|
||||||
|
const newPos: GridPosition = {
|
||||||
|
col: newItem.x + 1,
|
||||||
|
row: newItem.y + 1,
|
||||||
|
colSpan: newItem.w,
|
||||||
|
rowSpan: newItem.h,
|
||||||
|
};
|
||||||
|
onUpdateSectionPosition(newItem.i, newPos, modeKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={modeKey}
|
||||||
|
className={cn(
|
||||||
|
"relative shrink-0 cursor-pointer rounded-lg border-4 bg-white shadow-xl transition-all",
|
||||||
|
isActive
|
||||||
|
? "border-primary ring-2 ring-primary/30"
|
||||||
|
: "border-gray-300 hover:border-gray-400"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: resolution.width * canvasScale,
|
||||||
|
height: resolution.height * canvasScale,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isActive) {
|
||||||
|
onModeKeyChange(modeKey);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 모드 라벨 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium",
|
||||||
|
isActive ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{modeLabel}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 활성 표시 배지 */}
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute right-2 top-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-bold text-white shadow-lg">
|
||||||
|
편집 중
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 드롭 영역 */}
|
||||||
|
<CanvasDropZone
|
||||||
|
modeKey={modeKey}
|
||||||
|
isActive={isActive}
|
||||||
|
resolution={resolution}
|
||||||
|
scale={canvasScale}
|
||||||
|
cols={cols}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
margin={margin}
|
||||||
|
sections={sections}
|
||||||
|
components={components}
|
||||||
|
sectionPositions={sectionPositions}
|
||||||
|
componentPositions={modeLayout.componentPositions}
|
||||||
|
gridLayoutItems={gridLayoutItems}
|
||||||
|
selectedSectionId={selectedSectionId}
|
||||||
|
selectedComponentId={selectedComponentId}
|
||||||
|
onSelectSection={onSelectSection}
|
||||||
|
onSelectComponent={onSelectComponent}
|
||||||
|
onDragResizeStop={handleDragResizeStop}
|
||||||
|
onDropSection={onDropSection}
|
||||||
|
onDropComponent={onDropComponent}
|
||||||
|
onUpdateComponentPosition={(compId, pos) => onUpdateComponentPosition(compId, pos, modeKey)}
|
||||||
|
onDeleteSection={onDeleteSection}
|
||||||
|
onDeleteComponent={onDeleteComponent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full flex-col bg-gray-50">
|
||||||
|
{/* 줌 컨트롤 바 */}
|
||||||
|
<div className="flex shrink-0 items-center justify-end gap-2 border-b bg-white px-4 py-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
줌: {Math.round(canvasScale * 100)}%
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
title="줌 아웃"
|
||||||
|
>
|
||||||
|
<ZoomOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
title="줌 인"
|
||||||
|
>
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleZoomFit}
|
||||||
|
title="맞춤 (100%)"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 캔버스 영역 (패닝 가능) */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
"relative flex-1 overflow-auto",
|
||||||
|
isPanning && "cursor-grabbing",
|
||||||
|
isSpacePressed && "cursor-grab"
|
||||||
|
)}
|
||||||
|
onMouseDown={handlePanStart}
|
||||||
|
onMouseMove={handlePanMove}
|
||||||
|
onMouseUp={handlePanEnd}
|
||||||
|
onMouseLeave={handlePanEnd}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
>
|
||||||
|
{/* 스크롤 가능한 큰 영역 - 빈 공간 클릭 시 패닝 가능 */}
|
||||||
|
<div
|
||||||
|
className="canvas-scroll-area flex items-center justify-center gap-16"
|
||||||
|
style={{
|
||||||
|
// 캔버스 주변에 충분한 여백 확보 (상하좌우 500px씩)
|
||||||
|
padding: "500px",
|
||||||
|
minWidth: "fit-content",
|
||||||
|
minHeight: "fit-content",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 가로 모드 캔버스 */}
|
||||||
|
{renderDeviceFrame(landscapeModeKey)}
|
||||||
|
|
||||||
|
{/* 세로 모드 캔버스 */}
|
||||||
|
{renderDeviceFrame(portraitModeKey)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 캔버스 드롭 영역 컴포넌트
|
||||||
|
// ========================================
|
||||||
|
interface CanvasDropZoneProps {
|
||||||
|
modeKey: PopLayoutModeKey;
|
||||||
|
isActive: boolean;
|
||||||
|
resolution: { width: number; height: number };
|
||||||
|
scale: number;
|
||||||
|
cols: number;
|
||||||
|
rowHeight: number;
|
||||||
|
margin: [number, number];
|
||||||
|
sections: PopLayoutDataV2["sections"];
|
||||||
|
components: PopLayoutDataV2["components"];
|
||||||
|
sectionPositions: Record<string, GridPosition>;
|
||||||
|
componentPositions: Record<string, GridPosition>;
|
||||||
|
gridLayoutItems: Layout[];
|
||||||
|
selectedSectionId: string | null;
|
||||||
|
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;
|
||||||
|
onUpdateComponentPosition: (componentId: string, position: GridPosition) => void;
|
||||||
|
onDeleteSection: (id: string) => void;
|
||||||
|
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CanvasDropZone({
|
||||||
|
modeKey,
|
||||||
|
isActive,
|
||||||
|
resolution,
|
||||||
|
scale,
|
||||||
|
cols,
|
||||||
|
rowHeight,
|
||||||
|
margin,
|
||||||
|
sections,
|
||||||
|
components,
|
||||||
|
sectionPositions,
|
||||||
|
componentPositions,
|
||||||
|
gridLayoutItems,
|
||||||
|
selectedSectionId,
|
||||||
|
selectedComponentId,
|
||||||
|
onSelectSection,
|
||||||
|
onSelectComponent,
|
||||||
|
onDragResizeStop,
|
||||||
|
onDropSection,
|
||||||
|
onDropComponent,
|
||||||
|
onUpdateComponentPosition,
|
||||||
|
onDeleteSection,
|
||||||
|
onDeleteComponent,
|
||||||
|
}: CanvasDropZoneProps) {
|
||||||
|
const dropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 스케일 적용된 크기
|
||||||
|
const scaledWidth = resolution.width * scale;
|
||||||
|
const scaledHeight = resolution.height * scale;
|
||||||
|
|
||||||
|
// 섹션 드롭 핸들러
|
||||||
|
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) / scale;
|
||||||
|
const y = (clientOffset.y - dropRect.top) / scale;
|
||||||
|
|
||||||
|
// 그리드 위치 계산
|
||||||
|
const colWidth = (resolution.width - 16) / cols;
|
||||||
|
const col = Math.max(1, Math.min(cols, Math.floor(x / colWidth) + 1));
|
||||||
|
const row = Math.max(1, Math.floor(y / (rowHeight * scale)) + 1);
|
||||||
|
|
||||||
|
onDropSection({
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
colSpan: 3,
|
||||||
|
rowSpan: 4,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
canDrop: () => isActive,
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOver: monitor.isOver(),
|
||||||
|
canDrop: monitor.canDrop(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[isActive, resolution, scale, cols, rowHeight, onDropSection]
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(dropRef);
|
||||||
|
|
||||||
|
const sectionIds = Object.keys(sectionPositions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dropRef}
|
||||||
|
className={cn(
|
||||||
|
"h-full w-full overflow-hidden rounded-md bg-gray-100 p-1 transition-colors",
|
||||||
|
isOver && canDrop && "bg-primary/10 ring-2 ring-primary ring-inset"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
// 내부 컨텐츠를 스케일 조정
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: "top left",
|
||||||
|
width: resolution.width,
|
||||||
|
height: resolution.height,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onSelectSection(null);
|
||||||
|
onSelectComponent(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sectionIds.length > 0 ? (
|
||||||
|
<GridLayout
|
||||||
|
className="layout"
|
||||||
|
layout={gridLayoutItems}
|
||||||
|
cols={cols}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
width={resolution.width - 8}
|
||||||
|
margin={margin}
|
||||||
|
containerPadding={[0, 0]}
|
||||||
|
onDragStop={onDragResizeStop}
|
||||||
|
onResizeStop={onDragResizeStop}
|
||||||
|
isDraggable={isActive}
|
||||||
|
isResizable={isActive}
|
||||||
|
compactType={null}
|
||||||
|
preventCollision={false}
|
||||||
|
useCSSTransforms={true}
|
||||||
|
draggableHandle=".section-drag-handle"
|
||||||
|
>
|
||||||
|
{sectionIds.map((sectionId) => {
|
||||||
|
const sectionDef = sections[sectionId];
|
||||||
|
if (!sectionDef) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sectionId}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex flex-col overflow-hidden rounded-lg border-2 bg-white transition-all",
|
||||||
|
selectedSectionId === sectionId
|
||||||
|
? "border-primary ring-2 ring-primary/30"
|
||||||
|
: "border-gray-200 hover:border-gray-400"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectSection(sectionId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 섹션 헤더 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"section-drag-handle flex h-7 shrink-0 cursor-move items-center justify-between border-b px-2",
|
||||||
|
selectedSectionId === sectionId ? "bg-primary/10" : "bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<GripVertical className="h-3 w-3 text-gray-400" />
|
||||||
|
<span className="text-xs font-medium text-gray-600">
|
||||||
|
{sectionDef.label || "섹션"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{selectedSectionId === sectionId && isActive && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteSection(sectionId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 섹션 내부 - 컴포넌트들 */}
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<SectionGridV2
|
||||||
|
sectionId={sectionId}
|
||||||
|
sectionDef={sectionDef}
|
||||||
|
components={components}
|
||||||
|
componentPositions={componentPositions}
|
||||||
|
isActive={isActive}
|
||||||
|
selectedComponentId={selectedComponentId}
|
||||||
|
onSelectComponent={onSelectComponent}
|
||||||
|
onDropComponent={onDropComponent}
|
||||||
|
onUpdateComponentPosition={onUpdateComponentPosition}
|
||||||
|
onDeleteComponent={onDeleteComponent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</GridLayout>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-full items-center justify-center rounded-lg border-2 border-dashed text-sm",
|
||||||
|
isOver && canDrop
|
||||||
|
? "border-primary bg-primary/5 text-primary"
|
||||||
|
: "border-gray-300 text-gray-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOver && canDrop
|
||||||
|
? "여기에 섹션을 놓으세요"
|
||||||
|
: isActive
|
||||||
|
? "왼쪽 패널에서 섹션을 드래그하세요"
|
||||||
|
: "클릭하여 편집 모드로 전환"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,454 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { DndProvider } from "react-dnd";
|
||||||
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||||
|
import { ArrowLeft, Save, Smartphone, Tablet } 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 {
|
||||||
|
PopLayoutDataV2,
|
||||||
|
PopLayoutModeKey,
|
||||||
|
PopComponentType,
|
||||||
|
GridPosition,
|
||||||
|
PopSectionDefinition,
|
||||||
|
createEmptyPopLayoutV2,
|
||||||
|
createSectionDefinition,
|
||||||
|
createComponentDefinition,
|
||||||
|
ensureV2Layout,
|
||||||
|
addSectionToV2Layout,
|
||||||
|
addComponentToV2Layout,
|
||||||
|
removeSectionFromV2Layout,
|
||||||
|
removeComponentFromV2Layout,
|
||||||
|
updateSectionPositionInMode,
|
||||||
|
updateComponentPositionInMode,
|
||||||
|
isV2Layout,
|
||||||
|
} from "./types/pop-layout";
|
||||||
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 디바이스 타입
|
||||||
|
// ========================================
|
||||||
|
type DeviceType = "mobile" | "tablet";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디바이스 + 방향 → 모드 키 변환
|
||||||
|
*/
|
||||||
|
const getModeKey = (device: DeviceType, isLandscape: boolean): PopLayoutModeKey => {
|
||||||
|
if (device === "tablet") {
|
||||||
|
return isLandscape ? "tablet_landscape" : "tablet_portrait";
|
||||||
|
}
|
||||||
|
return isLandscape ? "mobile_landscape" : "mobile_portrait";
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props
|
||||||
|
// ========================================
|
||||||
|
interface PopDesignerProps {
|
||||||
|
selectedScreen: ScreenDefinition;
|
||||||
|
onBackToList: () => void;
|
||||||
|
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ========================================
|
||||||
|
export default function PopDesigner({
|
||||||
|
selectedScreen,
|
||||||
|
onBackToList,
|
||||||
|
onScreenUpdate,
|
||||||
|
}: PopDesignerProps) {
|
||||||
|
// ========================================
|
||||||
|
// 레이아웃 상태 (v2)
|
||||||
|
// ========================================
|
||||||
|
const [layout, setLayout] = useState<PopLayoutDataV2>(createEmptyPopLayoutV2());
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 디바이스/모드 상태
|
||||||
|
// ========================================
|
||||||
|
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
|
||||||
|
|
||||||
|
// 활성 모드 키 (가로/세로 중 현재 포커스된 캔버스)
|
||||||
|
// 기본값: 태블릿 가로
|
||||||
|
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 선택 상태
|
||||||
|
// ========================================
|
||||||
|
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
|
||||||
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 파생 상태
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// 선택된 섹션 정의
|
||||||
|
const selectedSection: PopSectionDefinition | null = useMemo(() => {
|
||||||
|
if (!selectedSectionId) return null;
|
||||||
|
return layout.sections[selectedSectionId] || null;
|
||||||
|
}, [layout.sections, selectedSectionId]);
|
||||||
|
|
||||||
|
// 현재 활성 모드의 섹션 ID 목록
|
||||||
|
const activeSectionIds = useMemo(() => {
|
||||||
|
return Object.keys(layout.layouts[activeModeKey].sectionPositions);
|
||||||
|
}, [layout.layouts, activeModeKey]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 레이아웃 로드
|
||||||
|
// ========================================
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLayout = async () => {
|
||||||
|
if (!selectedScreen?.screenId) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// API가 layout_data 내용을 직접 반환 (언래핑 상태)
|
||||||
|
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||||
|
|
||||||
|
if (loadedLayout) {
|
||||||
|
// v1 또는 v2 → v2로 변환
|
||||||
|
const v2Layout = ensureV2Layout(loadedLayout);
|
||||||
|
setLayout(v2Layout);
|
||||||
|
|
||||||
|
const sectionCount = Object.keys(v2Layout.sections).length;
|
||||||
|
const componentCount = Object.keys(v2Layout.components).length;
|
||||||
|
console.log(`POP v2 레이아웃 로드 성공: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
||||||
|
|
||||||
|
// v1에서 마이그레이션된 경우 알림
|
||||||
|
if (!isV2Layout(loadedLayout)) {
|
||||||
|
console.log("v1 → v2 자동 마이그레이션 완료");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 레이아웃 없음 - 빈 v2 레이아웃 생성
|
||||||
|
console.log("POP 레이아웃 없음, 빈 v2 레이아웃 생성");
|
||||||
|
setLayout(createEmptyPopLayoutV2());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 로드 실패:", error);
|
||||||
|
toast.error("레이아웃을 불러오는데 실패했습니다");
|
||||||
|
setLayout(createEmptyPopLayoutV2());
|
||||||
|
} 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]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 섹션 추가 (4모드 동기화)
|
||||||
|
// ========================================
|
||||||
|
const handleDropSection = useCallback((gridPosition: GridPosition) => {
|
||||||
|
const newId = `section-${Date.now()}`;
|
||||||
|
|
||||||
|
setLayout((prev) => addSectionToV2Layout(prev, newId, gridPosition));
|
||||||
|
setSelectedSectionId(newId);
|
||||||
|
setHasChanges(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 추가 (4모드 동기화)
|
||||||
|
// ========================================
|
||||||
|
const handleDropComponent = useCallback(
|
||||||
|
(sectionId: string, type: PopComponentType, gridPosition: GridPosition) => {
|
||||||
|
const newId = `${type}-${Date.now()}`;
|
||||||
|
|
||||||
|
setLayout((prev) => addComponentToV2Layout(prev, sectionId, newId, type, gridPosition));
|
||||||
|
setSelectedComponentId(newId);
|
||||||
|
setHasChanges(true);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 섹션 정의 업데이트 (공유)
|
||||||
|
// ========================================
|
||||||
|
const handleUpdateSectionDefinition = useCallback(
|
||||||
|
(sectionId: string, updates: Partial<PopSectionDefinition>) => {
|
||||||
|
setLayout((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sections: {
|
||||||
|
...prev.sections,
|
||||||
|
[sectionId]: {
|
||||||
|
...prev.sections[sectionId],
|
||||||
|
...updates,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setHasChanges(true);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 섹션 위치 업데이트 (현재 모드만)
|
||||||
|
// ========================================
|
||||||
|
const handleUpdateSectionPosition = useCallback(
|
||||||
|
(sectionId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
|
||||||
|
const targetMode = modeKey || activeModeKey;
|
||||||
|
setLayout((prev) => updateSectionPositionInMode(prev, targetMode, sectionId, position));
|
||||||
|
setHasChanges(true);
|
||||||
|
},
|
||||||
|
[activeModeKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 위치 업데이트 (현재 모드만)
|
||||||
|
// ========================================
|
||||||
|
const handleUpdateComponentPosition = useCallback(
|
||||||
|
(componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
|
||||||
|
const targetMode = modeKey || activeModeKey;
|
||||||
|
setLayout((prev) => updateComponentPositionInMode(prev, targetMode, componentId, position));
|
||||||
|
setHasChanges(true);
|
||||||
|
},
|
||||||
|
[activeModeKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 섹션 삭제 (4모드 동기화)
|
||||||
|
// ========================================
|
||||||
|
const handleDeleteSection = useCallback((sectionId: string) => {
|
||||||
|
setLayout((prev) => removeSectionFromV2Layout(prev, sectionId));
|
||||||
|
setSelectedSectionId(null);
|
||||||
|
setSelectedComponentId(null);
|
||||||
|
setHasChanges(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 삭제 (4모드 동기화)
|
||||||
|
// ========================================
|
||||||
|
const handleDeleteComponent = useCallback(
|
||||||
|
(sectionId: string, componentId: string) => {
|
||||||
|
setLayout((prev) => removeComponentFromV2Layout(prev, sectionId, componentId));
|
||||||
|
setSelectedComponentId(null);
|
||||||
|
setHasChanges(true);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 디바이스 전환
|
||||||
|
// ========================================
|
||||||
|
const handleDeviceChange = useCallback((device: DeviceType) => {
|
||||||
|
setActiveDevice(device);
|
||||||
|
// 기본 모드 키 설정 (가로)
|
||||||
|
setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 모드 키 전환 (캔버스 포커스)
|
||||||
|
// ========================================
|
||||||
|
const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => {
|
||||||
|
setActiveModeKey(modeKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 뒤로가기
|
||||||
|
// ========================================
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (hasChanges) {
|
||||||
|
if (confirm("저장하지 않은 변경사항이 있습니다. 나가시겠습니까?")) {
|
||||||
|
onBackToList();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onBackToList();
|
||||||
|
}
|
||||||
|
}, [hasChanges, onBackToList]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Delete 키 삭제 기능
|
||||||
|
// ========================================
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// input/textarea 포커스 시 제외
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.isContentEditable
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 또는 Backspace 키
|
||||||
|
if (e.key === "Delete" || e.key === "Backspace") {
|
||||||
|
e.preventDefault(); // 브라우저 뒤로가기 방지
|
||||||
|
|
||||||
|
// 컴포넌트가 선택되어 있으면 컴포넌트 삭제
|
||||||
|
if (selectedComponentId) {
|
||||||
|
// v2 구조: 컴포넌트가 속한 섹션을 sections의 componentIds에서 찾기
|
||||||
|
// (PopComponentDefinition에는 sectionId가 없으므로 섹션을 순회하여 찾음)
|
||||||
|
let foundSectionId: string | null = null;
|
||||||
|
for (const [sectionId, sectionDef] of Object.entries(layout.sections)) {
|
||||||
|
if (sectionDef.componentIds.includes(selectedComponentId)) {
|
||||||
|
foundSectionId = sectionId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundSectionId) {
|
||||||
|
handleDeleteComponent(foundSectionId, selectedComponentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 컴포넌트가 선택되지 않았고 섹션이 선택되어 있으면 섹션 삭제
|
||||||
|
else if (selectedSectionId) {
|
||||||
|
handleDeleteSection(selectedSectionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
selectedComponentId,
|
||||||
|
selectedSectionId,
|
||||||
|
layout.sections,
|
||||||
|
handleDeleteComponent,
|
||||||
|
handleDeleteSection,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 로딩 상태
|
||||||
|
// ========================================
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 렌더링
|
||||||
|
// ========================================
|
||||||
|
return (
|
||||||
|
<DndProvider backend={HTML5Backend}>
|
||||||
|
<div className="flex h-screen flex-col bg-background">
|
||||||
|
{/* 툴바 */}
|
||||||
|
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||||
|
{/* 왼쪽: 뒤로가기 + 화면명 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleBack}>
|
||||||
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||||
|
목록
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedScreen?.screenName || "POP 화면"}
|
||||||
|
</span>
|
||||||
|
{hasChanges && (
|
||||||
|
<span className="text-xs text-orange-500">*변경됨</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중앙: 디바이스 전환 (가로/세로 전환 버튼 제거 - 캔버스 2개 동시 표시) */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tabs
|
||||||
|
value={activeDevice}
|
||||||
|
onValueChange={(v) => handleDeviceChange(v as DeviceType)}
|
||||||
|
>
|
||||||
|
<TabsList className="h-8">
|
||||||
|
<TabsTrigger value="tablet" className="h-7 px-3 text-xs">
|
||||||
|
<Tablet className="mr-1 h-3.5 w-3.5" />
|
||||||
|
태블릿
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="mobile" className="h-7 px-3 text-xs">
|
||||||
|
<Smartphone className="mr-1 h-3.5 w-3.5" />
|
||||||
|
모바일
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 저장 */}
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !hasChanges}
|
||||||
|
>
|
||||||
|
<Save className="mr-1 h-4 w-4" />
|
||||||
|
{isSaving ? "저장 중..." : "저장"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 영역: 리사이즈 가능한 패널 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
||||||
|
{/* 왼쪽: 패널 (컴포넌트/편집 탭) */}
|
||||||
|
<ResizablePanel
|
||||||
|
defaultSize={20}
|
||||||
|
minSize={15}
|
||||||
|
maxSize={30}
|
||||||
|
className="border-r"
|
||||||
|
>
|
||||||
|
<PopPanel
|
||||||
|
layout={layout}
|
||||||
|
activeModeKey={activeModeKey}
|
||||||
|
selectedSectionId={selectedSectionId}
|
||||||
|
selectedSection={selectedSection}
|
||||||
|
onUpdateSectionDefinition={handleUpdateSectionDefinition}
|
||||||
|
onDeleteSection={handleDeleteSection}
|
||||||
|
activeDevice={activeDevice}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 오른쪽: 캔버스 (가로+세로 2개 동시 표시) */}
|
||||||
|
<ResizablePanel defaultSize={80}>
|
||||||
|
<PopCanvas
|
||||||
|
layout={layout}
|
||||||
|
activeDevice={activeDevice}
|
||||||
|
activeModeKey={activeModeKey}
|
||||||
|
onModeKeyChange={handleModeKeyChange}
|
||||||
|
selectedSectionId={selectedSectionId}
|
||||||
|
selectedComponentId={selectedComponentId}
|
||||||
|
onSelectSection={setSelectedSectionId}
|
||||||
|
onSelectComponent={setSelectedComponentId}
|
||||||
|
onUpdateSectionPosition={handleUpdateSectionPosition}
|
||||||
|
onUpdateComponentPosition={handleUpdateComponentPosition}
|
||||||
|
onDeleteSection={handleDeleteSection}
|
||||||
|
onDropSection={handleDropSection}
|
||||||
|
onDropComponent={handleDropComponent}
|
||||||
|
onDeleteComponent={handleDeleteComponent}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
</DndProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<PopComponentData>) => void;
|
||||||
|
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionGrid({
|
||||||
|
section,
|
||||||
|
isActive,
|
||||||
|
selectedComponentId,
|
||||||
|
onSelectComponent,
|
||||||
|
onDropComponent,
|
||||||
|
onUpdateComponent,
|
||||||
|
onDeleteComponent,
|
||||||
|
}: SectionGridProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { components } = section;
|
||||||
|
|
||||||
|
// 컨테이너 크기 측정
|
||||||
|
const [containerSize, setContainerSize] = useState({ width: 300, height: 200 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateSize = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
setContainerSize({
|
||||||
|
width: containerRef.current.offsetWidth,
|
||||||
|
height: containerRef.current.offsetHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSize();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateSize);
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 셀 크기 계산 - 고정 셀 크기 기반으로 자동 계산
|
||||||
|
const padding = 8; // p-2 = 8px
|
||||||
|
const gap = 4; // 고정 간격
|
||||||
|
const availableWidth = containerSize.width - padding * 2;
|
||||||
|
const availableHeight = containerSize.height - padding * 2;
|
||||||
|
|
||||||
|
// 고정 셀 크기 (40px) 기반으로 열/행 수 자동 계산
|
||||||
|
const CELL_SIZE = 40;
|
||||||
|
const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap)));
|
||||||
|
const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap)));
|
||||||
|
const cellHeight = CELL_SIZE;
|
||||||
|
|
||||||
|
// GridLayout용 레이아웃 변환 (자동 계산된 cols/rows 사용)
|
||||||
|
const gridLayoutItems: Layout[] = useMemo(() => {
|
||||||
|
return components.map((comp) => {
|
||||||
|
// 컴포넌트 위치가 그리드 범위를 벗어나지 않도록 조정
|
||||||
|
const x = Math.min(Math.max(0, comp.grid.col - 1), Math.max(0, cols - 1));
|
||||||
|
const y = Math.min(Math.max(0, comp.grid.row - 1), Math.max(0, rows - 1));
|
||||||
|
// colSpan/rowSpan도 범위 제한
|
||||||
|
const w = Math.min(Math.max(1, comp.grid.colSpan), Math.max(1, cols - x));
|
||||||
|
const h = Math.min(Math.max(1, comp.grid.rowSpan), Math.max(1, rows - y));
|
||||||
|
|
||||||
|
return {
|
||||||
|
i: comp.id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
minW: 1,
|
||||||
|
minH: 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [components, cols, rows]);
|
||||||
|
|
||||||
|
// 드래그/리사이즈 완료 핸들러 (onDragStop, onResizeStop 사용)
|
||||||
|
const handleDragStop = useCallback(
|
||||||
|
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
|
||||||
|
const comp = components.find((c) => c.id === newItem.i);
|
||||||
|
if (!comp) return;
|
||||||
|
|
||||||
|
const newGrid: GridPosition = {
|
||||||
|
col: newItem.x + 1,
|
||||||
|
row: newItem.y + 1,
|
||||||
|
colSpan: newItem.w,
|
||||||
|
rowSpan: newItem.h,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
comp.grid.col !== newGrid.col ||
|
||||||
|
comp.grid.row !== newGrid.row
|
||||||
|
) {
|
||||||
|
onUpdateComponent(section.id, comp.id, { grid: newGrid });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[components, section.id, onUpdateComponent]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResizeStop = useCallback(
|
||||||
|
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
|
||||||
|
const comp = components.find((c) => c.id === newItem.i);
|
||||||
|
if (!comp) return;
|
||||||
|
|
||||||
|
const newGrid: GridPosition = {
|
||||||
|
col: newItem.x + 1,
|
||||||
|
row: newItem.y + 1,
|
||||||
|
colSpan: newItem.w,
|
||||||
|
rowSpan: newItem.h,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
comp.grid.colSpan !== newGrid.colSpan ||
|
||||||
|
comp.grid.rowSpan !== newGrid.rowSpan
|
||||||
|
) {
|
||||||
|
onUpdateComponent(section.id, comp.id, { grid: newGrid });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[components, section.id, onUpdateComponent]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 빈 셀 찾기 (드롭 위치용) - 자동 계산된 cols/rows 사용
|
||||||
|
const findEmptyCell = useCallback((): GridPosition => {
|
||||||
|
const occupied = new Set<string>();
|
||||||
|
|
||||||
|
components.forEach((comp) => {
|
||||||
|
for (let c = comp.grid.col; c < comp.grid.col + comp.grid.colSpan; c++) {
|
||||||
|
for (let r = comp.grid.row; r < comp.grid.row + comp.grid.rowSpan; r++) {
|
||||||
|
occupied.add(`${c}-${r}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 빈 셀 찾기
|
||||||
|
for (let r = 1; r <= rows; r++) {
|
||||||
|
for (let c = 1; c <= cols; c++) {
|
||||||
|
if (!occupied.has(`${c}-${r}`)) {
|
||||||
|
return { col: c, row: r, colSpan: 1, rowSpan: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 빈 셀 없으면 첫 번째 위치에
|
||||||
|
return { col: 1, row: 1, colSpan: 1, rowSpan: 1 };
|
||||||
|
}, [components, cols, rows]);
|
||||||
|
|
||||||
|
// 컴포넌트 드롭 핸들러
|
||||||
|
const [{ isOver, canDrop }, drop] = useDrop(() => ({
|
||||||
|
accept: DND_ITEM_TYPES.COMPONENT,
|
||||||
|
drop: (item: DragItemComponent) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
const emptyCell = findEmptyCell();
|
||||||
|
onDropComponent(section.id, item.componentType, emptyCell);
|
||||||
|
return { dropped: true };
|
||||||
|
},
|
||||||
|
canDrop: () => isActive,
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOver: monitor.isOver(),
|
||||||
|
canDrop: monitor.canDrop(),
|
||||||
|
}),
|
||||||
|
}), [isActive, section.id, findEmptyCell, onDropComponent]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
containerRef.current = node;
|
||||||
|
drop(node);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"relative h-full w-full p-2 transition-colors",
|
||||||
|
isOver && canDrop && "bg-blue-50"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectComponent(null);
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* 빈 상태 안내 텍스트 */}
|
||||||
|
{components.length === 0 && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center z-10">
|
||||||
|
<span className={cn(
|
||||||
|
"rounded bg-white/80 px-2 py-1 text-xs",
|
||||||
|
isOver && canDrop ? "text-primary font-medium" : "text-gray-400"
|
||||||
|
)}>
|
||||||
|
{isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컴포넌트 GridLayout */}
|
||||||
|
{components.length > 0 && availableWidth > 0 && cols > 0 && (
|
||||||
|
<GridLayout
|
||||||
|
className="layout relative z-10"
|
||||||
|
layout={gridLayoutItems}
|
||||||
|
cols={cols}
|
||||||
|
rowHeight={cellHeight}
|
||||||
|
width={availableWidth}
|
||||||
|
margin={[gap, gap]}
|
||||||
|
containerPadding={[0, 0]}
|
||||||
|
onDragStop={handleDragStop}
|
||||||
|
onResizeStop={handleResizeStop}
|
||||||
|
isDraggable={isActive}
|
||||||
|
isResizable={isActive}
|
||||||
|
compactType={null}
|
||||||
|
preventCollision={false}
|
||||||
|
useCSSTransforms={true}
|
||||||
|
draggableHandle=".component-drag-handle"
|
||||||
|
resizeHandles={["se", "e", "s"]}
|
||||||
|
>
|
||||||
|
{components.map((comp) => (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex flex-col rounded border bg-white text-xs transition-all overflow-hidden",
|
||||||
|
selectedComponentId === comp.id
|
||||||
|
? "border-primary ring-2 ring-primary/30"
|
||||||
|
: "border-gray-200 hover:border-gray-400"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectComponent(comp.id);
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 드래그 핸들 바 */}
|
||||||
|
<div className="component-drag-handle flex h-5 cursor-move items-center justify-center border-b bg-gray-50 opacity-60 hover:opacity-100 transition-opacity">
|
||||||
|
<Move className="h-3 w-3 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컴포넌트 내용 */}
|
||||||
|
<div className="flex flex-1 items-center justify-center p-1">
|
||||||
|
<ComponentPreview component={comp} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
{selectedComponentId === comp.id && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute -right-2 -top-2 h-5 w-5 rounded-full bg-white shadow text-destructive hover:bg-destructive/10 z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteComponent(section.id, comp.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridLayout>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 미리보기
|
||||||
|
interface ComponentPreviewProps {
|
||||||
|
component: PopComponentData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComponentPreview({ component }: ComponentPreviewProps) {
|
||||||
|
const { type, label } = component;
|
||||||
|
|
||||||
|
// 타입별 미리보기 렌더링
|
||||||
|
const renderPreview = () => {
|
||||||
|
switch (type) {
|
||||||
|
case "pop-field":
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "필드"}</span>
|
||||||
|
<div className="h-6 w-full rounded border border-gray-200 bg-gray-50" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-button":
|
||||||
|
return (
|
||||||
|
<div className="flex h-8 w-full items-center justify-center rounded bg-primary/10 text-primary font-medium">
|
||||||
|
{label || "버튼"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-list":
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "리스트"}</span>
|
||||||
|
<div className="h-3 w-full rounded bg-gray-100" />
|
||||||
|
<div className="h-3 w-3/4 rounded bg-gray-100" />
|
||||||
|
<div className="h-3 w-full rounded bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-indicator":
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col items-center gap-1">
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
|
||||||
|
<span className="text-lg font-bold text-primary">0</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-scanner":
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col items-center gap-1">
|
||||||
|
<div className="h-8 w-8 rounded border-2 border-dashed border-gray-300 flex items-center justify-center">
|
||||||
|
<span className="text-[8px] text-gray-400">QR</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-numpad":
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-0.5 w-full">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex h-4 items-center justify-center rounded bg-gray-100 text-[8px]"
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <span className="text-gray-500">{label || type}</span>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div className="w-full overflow-hidden">{renderPreview()}</div>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,373 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||||
|
import { useDrop } from "react-dnd";
|
||||||
|
import GridLayout, { Layout } from "react-grid-layout";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
PopSectionDefinition,
|
||||||
|
PopComponentDefinition,
|
||||||
|
PopComponentType,
|
||||||
|
GridPosition,
|
||||||
|
} from "./types/pop-layout";
|
||||||
|
import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel";
|
||||||
|
import { Trash2, Move } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
import "react-grid-layout/css/styles.css";
|
||||||
|
import "react-resizable/css/styles.css";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props
|
||||||
|
// ========================================
|
||||||
|
interface SectionGridV2Props {
|
||||||
|
sectionId: string;
|
||||||
|
sectionDef: PopSectionDefinition;
|
||||||
|
components: Record<string, PopComponentDefinition>;
|
||||||
|
componentPositions: Record<string, GridPosition>;
|
||||||
|
isActive: boolean;
|
||||||
|
selectedComponentId: string | null;
|
||||||
|
onSelectComponent: (id: string | null) => void;
|
||||||
|
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
|
||||||
|
onUpdateComponentPosition: (componentId: string, position: GridPosition) => void;
|
||||||
|
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ========================================
|
||||||
|
export function SectionGridV2({
|
||||||
|
sectionId,
|
||||||
|
sectionDef,
|
||||||
|
components,
|
||||||
|
componentPositions,
|
||||||
|
isActive,
|
||||||
|
selectedComponentId,
|
||||||
|
onSelectComponent,
|
||||||
|
onDropComponent,
|
||||||
|
onUpdateComponentPosition,
|
||||||
|
onDeleteComponent,
|
||||||
|
}: SectionGridV2Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 이 섹션에 포함된 컴포넌트 ID 목록
|
||||||
|
const componentIds = sectionDef.componentIds || [];
|
||||||
|
|
||||||
|
// 컨테이너 크기 측정
|
||||||
|
const [containerSize, setContainerSize] = useState({ width: 300, height: 200 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateSize = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
setContainerSize({
|
||||||
|
width: containerRef.current.offsetWidth,
|
||||||
|
height: containerRef.current.offsetHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSize();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateSize);
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 셀 크기 계산 - 고정 셀 크기 기반으로 자동 계산
|
||||||
|
const padding = 8; // p-2 = 8px
|
||||||
|
const gap = 4; // 고정 간격
|
||||||
|
const availableWidth = containerSize.width - padding * 2;
|
||||||
|
const availableHeight = containerSize.height - padding * 2;
|
||||||
|
|
||||||
|
// 고정 셀 크기 (40px) 기반으로 열/행 수 자동 계산
|
||||||
|
const CELL_SIZE = 40;
|
||||||
|
const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap)));
|
||||||
|
const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap)));
|
||||||
|
const cellHeight = CELL_SIZE;
|
||||||
|
|
||||||
|
// GridLayout용 레이아웃 변환
|
||||||
|
const gridLayoutItems: Layout[] = useMemo(() => {
|
||||||
|
return componentIds
|
||||||
|
.map((compId) => {
|
||||||
|
const pos = componentPositions[compId];
|
||||||
|
if (!pos) return null;
|
||||||
|
|
||||||
|
// 위치가 그리드 범위를 벗어나지 않도록 조정
|
||||||
|
const x = Math.min(Math.max(0, pos.col - 1), Math.max(0, cols - 1));
|
||||||
|
const y = Math.min(Math.max(0, pos.row - 1), Math.max(0, rows - 1));
|
||||||
|
const w = Math.min(Math.max(1, pos.colSpan), Math.max(1, cols - x));
|
||||||
|
const h = Math.min(Math.max(1, pos.rowSpan), Math.max(1, rows - y));
|
||||||
|
|
||||||
|
return {
|
||||||
|
i: compId,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
minW: 1,
|
||||||
|
minH: 1,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is Layout => item !== null);
|
||||||
|
}, [componentIds, componentPositions, cols, rows]);
|
||||||
|
|
||||||
|
// 드래그 완료 핸들러
|
||||||
|
const handleDragStop = useCallback(
|
||||||
|
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
|
||||||
|
const newPos: GridPosition = {
|
||||||
|
col: newItem.x + 1,
|
||||||
|
row: newItem.y + 1,
|
||||||
|
colSpan: newItem.w,
|
||||||
|
rowSpan: newItem.h,
|
||||||
|
};
|
||||||
|
|
||||||
|
const oldPos = componentPositions[newItem.i];
|
||||||
|
if (!oldPos || oldPos.col !== newPos.col || oldPos.row !== newPos.row) {
|
||||||
|
onUpdateComponentPosition(newItem.i, newPos);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[componentPositions, onUpdateComponentPosition]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 리사이즈 완료 핸들러
|
||||||
|
const handleResizeStop = useCallback(
|
||||||
|
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
|
||||||
|
const newPos: GridPosition = {
|
||||||
|
col: newItem.x + 1,
|
||||||
|
row: newItem.y + 1,
|
||||||
|
colSpan: newItem.w,
|
||||||
|
rowSpan: newItem.h,
|
||||||
|
};
|
||||||
|
|
||||||
|
const oldPos = componentPositions[newItem.i];
|
||||||
|
if (!oldPos || oldPos.colSpan !== newPos.colSpan || oldPos.rowSpan !== newPos.rowSpan) {
|
||||||
|
onUpdateComponentPosition(newItem.i, newPos);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[componentPositions, onUpdateComponentPosition]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 빈 셀 찾기 (드롭 위치용)
|
||||||
|
const findEmptyCell = useCallback((): GridPosition => {
|
||||||
|
const occupied = new Set<string>();
|
||||||
|
|
||||||
|
componentIds.forEach((compId) => {
|
||||||
|
const pos = componentPositions[compId];
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
for (let c = pos.col; c < pos.col + pos.colSpan; c++) {
|
||||||
|
for (let r = pos.row; r < pos.row + pos.rowSpan; r++) {
|
||||||
|
occupied.add(`${c}-${r}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 빈 셀 찾기
|
||||||
|
for (let r = 1; r <= rows; r++) {
|
||||||
|
for (let c = 1; c <= cols; c++) {
|
||||||
|
if (!occupied.has(`${c}-${r}`)) {
|
||||||
|
return { col: c, row: r, colSpan: 1, rowSpan: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 빈 셀 없으면 첫 번째 위치
|
||||||
|
return { col: 1, row: 1, colSpan: 1, rowSpan: 1 };
|
||||||
|
}, [componentIds, componentPositions, cols, rows]);
|
||||||
|
|
||||||
|
// 컴포넌트 드롭 핸들러
|
||||||
|
const [{ isOver, canDrop }, drop] = useDrop(
|
||||||
|
() => ({
|
||||||
|
accept: DND_ITEM_TYPES.COMPONENT,
|
||||||
|
drop: (item: DragItemComponent) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
const emptyCell = findEmptyCell();
|
||||||
|
onDropComponent(sectionId, item.componentType, emptyCell);
|
||||||
|
return { dropped: true };
|
||||||
|
},
|
||||||
|
canDrop: () => isActive,
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOver: monitor.isOver(),
|
||||||
|
canDrop: monitor.canDrop(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[isActive, sectionId, findEmptyCell, onDropComponent]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
containerRef.current = node;
|
||||||
|
drop(node);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"relative h-full w-full p-2 transition-colors",
|
||||||
|
isOver && canDrop && "bg-blue-50"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectComponent(null);
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 빈 상태 안내 텍스트 */}
|
||||||
|
{componentIds.length === 0 && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded bg-white/80 px-2 py-1 text-xs",
|
||||||
|
isOver && canDrop ? "font-medium text-primary" : "text-gray-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컴포넌트 GridLayout */}
|
||||||
|
{componentIds.length > 0 && availableWidth > 0 && cols > 0 && (
|
||||||
|
<GridLayout
|
||||||
|
className="layout relative z-10"
|
||||||
|
layout={gridLayoutItems}
|
||||||
|
cols={cols}
|
||||||
|
rowHeight={cellHeight}
|
||||||
|
width={availableWidth}
|
||||||
|
margin={[gap, gap]}
|
||||||
|
containerPadding={[0, 0]}
|
||||||
|
onDragStop={handleDragStop}
|
||||||
|
onResizeStop={handleResizeStop}
|
||||||
|
isDraggable={isActive}
|
||||||
|
isResizable={isActive}
|
||||||
|
compactType={null}
|
||||||
|
preventCollision={false}
|
||||||
|
useCSSTransforms={true}
|
||||||
|
draggableHandle=".component-drag-handle"
|
||||||
|
resizeHandles={["se", "e", "s"]}
|
||||||
|
>
|
||||||
|
{componentIds.map((compId) => {
|
||||||
|
const compDef = components[compId];
|
||||||
|
if (!compDef) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={compId}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex flex-col overflow-hidden rounded border bg-white text-xs transition-all",
|
||||||
|
selectedComponentId === compId
|
||||||
|
? "border-primary ring-2 ring-primary/30"
|
||||||
|
: "border-gray-200 hover:border-gray-400"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectComponent(compId);
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 드래그 핸들 바 */}
|
||||||
|
<div className="component-drag-handle flex h-5 cursor-move items-center justify-center border-b bg-gray-50 opacity-60 transition-opacity hover:opacity-100">
|
||||||
|
<Move className="h-3 w-3 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컴포넌트 내용 */}
|
||||||
|
<div className="flex flex-1 items-center justify-center p-1">
|
||||||
|
<ComponentPreviewV2 component={compDef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
{selectedComponentId === compId && isActive && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute -right-2 -top-2 z-10 h-5 w-5 rounded-full bg-white text-destructive shadow hover:bg-destructive/10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteComponent(sectionId, compId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</GridLayout>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 미리보기
|
||||||
|
// ========================================
|
||||||
|
interface ComponentPreviewV2Props {
|
||||||
|
component: PopComponentDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComponentPreviewV2({ component }: ComponentPreviewV2Props) {
|
||||||
|
const { type, label } = component;
|
||||||
|
|
||||||
|
const renderPreview = () => {
|
||||||
|
switch (type) {
|
||||||
|
case "pop-field":
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "필드"}</span>
|
||||||
|
<div className="h-6 w-full rounded border border-gray-200 bg-gray-50" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-button":
|
||||||
|
return (
|
||||||
|
<div className="flex h-8 w-full items-center justify-center rounded bg-primary/10 font-medium text-primary">
|
||||||
|
{label || "버튼"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-list":
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "리스트"}</span>
|
||||||
|
<div className="h-3 w-full rounded bg-gray-100" />
|
||||||
|
<div className="h-3 w-3/4 rounded bg-gray-100" />
|
||||||
|
<div className="h-3 w-full rounded bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-indicator":
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col items-center gap-1">
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
|
||||||
|
<span className="text-lg font-bold text-primary">0</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-scanner":
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col items-center gap-1">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded border-2 border-dashed border-gray-300">
|
||||||
|
<span className="text-[8px] text-gray-400">QR</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-numpad":
|
||||||
|
return (
|
||||||
|
<div className="grid w-full grid-cols-3 gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex h-4 items-center justify-center rounded bg-gray-100 text-[8px]"
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <span className="text-gray-500">{label || type}</span>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div className="w-full overflow-hidden">{renderPreview()}</div>;
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -0,0 +1,484 @@
|
||||||
|
"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 {
|
||||||
|
PopLayoutDataV2,
|
||||||
|
PopLayoutModeKey,
|
||||||
|
PopSectionDefinition,
|
||||||
|
PopComponentType,
|
||||||
|
MODE_RESOLUTIONS,
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 팔레트 정의
|
||||||
|
// ========================================
|
||||||
|
const COMPONENT_PALETTE: {
|
||||||
|
type: PopComponentType;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
type: "pop-field",
|
||||||
|
label: "필드",
|
||||||
|
icon: Type,
|
||||||
|
description: "텍스트, 숫자 등 데이터 입력",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-button",
|
||||||
|
label: "버튼",
|
||||||
|
icon: MousePointer,
|
||||||
|
description: "저장, 삭제 등 액션 실행",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-list",
|
||||||
|
label: "리스트",
|
||||||
|
icon: List,
|
||||||
|
description: "데이터 목록 표시",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-indicator",
|
||||||
|
label: "인디케이터",
|
||||||
|
icon: Activity,
|
||||||
|
description: "KPI, 상태 표시",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-scanner",
|
||||||
|
label: "스캐너",
|
||||||
|
icon: ScanLine,
|
||||||
|
description: "바코드/QR 스캔",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-numpad",
|
||||||
|
label: "숫자패드",
|
||||||
|
icon: Calculator,
|
||||||
|
description: "숫자 입력 전용",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props
|
||||||
|
// ========================================
|
||||||
|
interface PopPanelProps {
|
||||||
|
layout: PopLayoutDataV2;
|
||||||
|
activeModeKey: PopLayoutModeKey;
|
||||||
|
selectedSectionId: string | null;
|
||||||
|
selectedSection: PopSectionDefinition | null;
|
||||||
|
onUpdateSectionDefinition: (id: string, updates: Partial<PopSectionDefinition>) => void;
|
||||||
|
onDeleteSection: (id: string) => void;
|
||||||
|
activeDevice: "mobile" | "tablet";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ========================================
|
||||||
|
export function PopPanel({
|
||||||
|
layout,
|
||||||
|
activeModeKey,
|
||||||
|
selectedSectionId,
|
||||||
|
selectedSection,
|
||||||
|
onUpdateSectionDefinition,
|
||||||
|
onDeleteSection,
|
||||||
|
activeDevice,
|
||||||
|
}: PopPanelProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<string>("components");
|
||||||
|
|
||||||
|
// 현재 모드의 섹션 위치
|
||||||
|
const currentModeLayout = layout.layouts[activeModeKey];
|
||||||
|
const selectedSectionPosition = selectedSectionId
|
||||||
|
? currentModeLayout.sectionPositions[selectedSectionId]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
className="flex h-full flex-col"
|
||||||
|
>
|
||||||
|
<TabsList className="mx-2 mt-2 grid w-auto grid-cols-2">
|
||||||
|
<TabsTrigger value="components" className="text-xs">
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
|
컴포넌트
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="edit" className="text-xs">
|
||||||
|
<Settings className="mr-1 h-3.5 w-3.5" />
|
||||||
|
편집
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 컴포넌트 탭 */}
|
||||||
|
<TabsContent value="components" className="flex-1 overflow-auto p-2">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 현재 모드 표시 */}
|
||||||
|
<div className="rounded-lg bg-muted p-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
편집 중: {getModeLabel(activeModeKey)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{MODE_RESOLUTIONS[activeModeKey].width} x {MODE_RESOLUTIONS[activeModeKey].height}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 섹션 드래그 아이템 */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
레이아웃
|
||||||
|
</h4>
|
||||||
|
<DraggableSectionItem />
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
캔버스에 드래그하여 섹션 추가
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컴포넌트 팔레트 */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
컴포넌트
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{COMPONENT_PALETTE.map((item) => (
|
||||||
|
<DraggableComponentItem
|
||||||
|
key={item.type}
|
||||||
|
type={item.type}
|
||||||
|
label={item.label}
|
||||||
|
icon={item.icon}
|
||||||
|
description={item.description}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
섹션 안으로 드래그하여 배치
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 편집 탭 */}
|
||||||
|
<TabsContent value="edit" className="flex-1 overflow-auto p-2">
|
||||||
|
{selectedSection && selectedSectionPosition ? (
|
||||||
|
<SectionEditorV2
|
||||||
|
section={selectedSection}
|
||||||
|
position={selectedSectionPosition}
|
||||||
|
activeModeKey={activeModeKey}
|
||||||
|
onUpdateDefinition={(updates) =>
|
||||||
|
onUpdateSectionDefinition(selectedSection.id, updates)
|
||||||
|
}
|
||||||
|
onDelete={() => onDeleteSection(selectedSection.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
섹션을 선택하세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 모드 라벨 헬퍼
|
||||||
|
// ========================================
|
||||||
|
function getModeLabel(modeKey: PopLayoutModeKey): string {
|
||||||
|
const labels: Record<PopLayoutModeKey, string> = {
|
||||||
|
tablet_landscape: "태블릿 가로",
|
||||||
|
tablet_portrait: "태블릿 세로",
|
||||||
|
mobile_landscape: "모바일 가로",
|
||||||
|
mobile_portrait: "모바일 세로",
|
||||||
|
};
|
||||||
|
return labels[modeKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 드래그 가능한 섹션 아이템
|
||||||
|
// ========================================
|
||||||
|
function DraggableSectionItem() {
|
||||||
|
const [{ isDragging }, drag] = useDrag(() => ({
|
||||||
|
type: DND_ITEM_TYPES.SECTION,
|
||||||
|
item: { type: DND_ITEM_TYPES.SECTION } as DragItemSection,
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isDragging: monitor.isDragging(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={drag}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-grab items-center gap-3 rounded-lg border p-3 transition-all",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
isDragging && "opacity-50 ring-2 ring-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">섹션</p>
|
||||||
|
<p className="text-xs text-muted-foreground">컴포넌트를 그룹화하는 컨테이너</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
// ========================================
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={drag}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-grab items-start gap-3 rounded-lg border p-3 transition-all",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
isDragging && "opacity-50 ring-2 ring-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GripVertical className="mt-0.5 h-4 w-4 text-gray-400" />
|
||||||
|
<Icon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">{label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v2 섹션 편집기
|
||||||
|
// ========================================
|
||||||
|
interface SectionEditorV2Props {
|
||||||
|
section: PopSectionDefinition;
|
||||||
|
position: { col: number; row: number; colSpan: number; rowSpan: number };
|
||||||
|
activeModeKey: PopLayoutModeKey;
|
||||||
|
onUpdateDefinition: (updates: Partial<PopSectionDefinition>) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionEditorV2({
|
||||||
|
section,
|
||||||
|
position,
|
||||||
|
activeModeKey,
|
||||||
|
onUpdateDefinition,
|
||||||
|
onDelete,
|
||||||
|
}: SectionEditorV2Props) {
|
||||||
|
const [isGridOpen, setIsGridOpen] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 섹션 기본 정보 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">섹션 설정</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 라벨 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">라벨 (공유)</Label>
|
||||||
|
<Input
|
||||||
|
value={section.label || ""}
|
||||||
|
onChange={(e) => onUpdateDefinition({ label: e.target.value })}
|
||||||
|
placeholder="섹션 이름"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
라벨은 4개 모드에서 공유됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현재 모드 위치 (읽기 전용 - 드래그로 조정) */}
|
||||||
|
<Collapsible open={isGridOpen} onOpenChange={setIsGridOpen}>
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-sm font-medium">
|
||||||
|
현재 모드 위치
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 transition-transform",
|
||||||
|
isGridOpen && "rotate-180"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-3 pt-2">
|
||||||
|
<div className="rounded-lg bg-muted p-3">
|
||||||
|
<p className="mb-2 text-xs font-medium">{getModeLabel(activeModeKey)}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">시작 열:</span>
|
||||||
|
<span className="font-medium">{position.col}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">시작 행:</span>
|
||||||
|
<span className="font-medium">{position.row}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">열 크기:</span>
|
||||||
|
<span className="font-medium">{position.colSpan}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">행 크기:</span>
|
||||||
|
<span className="font-medium">{position.rowSpan}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
위치/크기는 캔버스에서 드래그하여 조정하세요.
|
||||||
|
각 모드(가로/세로)별로 별도 저장됩니다.
|
||||||
|
</p>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* 내부 그리드 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">내부 그리드 (공유)</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">내부 열 수</Label>
|
||||||
|
<Select
|
||||||
|
value={String(section.innerGrid.columns)}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onUpdateDefinition({
|
||||||
|
innerGrid: { ...section.innerGrid, columns: parseInt(v) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1열</SelectItem>
|
||||||
|
<SelectItem value="2">2열</SelectItem>
|
||||||
|
<SelectItem value="3">3열</SelectItem>
|
||||||
|
<SelectItem value="4">4열</SelectItem>
|
||||||
|
<SelectItem value="6">6열</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">내부 행 수</Label>
|
||||||
|
<Select
|
||||||
|
value={String(section.innerGrid.rows)}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onUpdateDefinition({
|
||||||
|
innerGrid: { ...section.innerGrid, rows: parseInt(v) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1행</SelectItem>
|
||||||
|
<SelectItem value="2">2행</SelectItem>
|
||||||
|
<SelectItem value="3">3행</SelectItem>
|
||||||
|
<SelectItem value="4">4행</SelectItem>
|
||||||
|
<SelectItem value="5">5행</SelectItem>
|
||||||
|
<SelectItem value="6">6행</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
내부 그리드 설정은 4개 모드에서 공유됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컴포넌트 목록 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">
|
||||||
|
포함된 컴포넌트 ({section.componentIds.length}개)
|
||||||
|
</h4>
|
||||||
|
{section.componentIds.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{section.componentIds.map((compId) => (
|
||||||
|
<div
|
||||||
|
key={compId}
|
||||||
|
className="rounded border bg-muted/50 px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{compId}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
아직 컴포넌트가 없습니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
// POP 디자이너 패널 export
|
||||||
|
export { PopPanel } from "./PopPanel";
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
// POP 디자이너 타입 export
|
||||||
|
export * from "./pop-layout";
|
||||||
|
|
@ -0,0 +1,952 @@
|
||||||
|
// POP 디자이너 레이아웃 타입 정의
|
||||||
|
// 그리드 기반 반응형 레이아웃 (픽셀 좌표 없음, 그리드 셀 기반)
|
||||||
|
// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 레이아웃 모드 키 (4가지)
|
||||||
|
// ========================================
|
||||||
|
export type PopLayoutModeKey =
|
||||||
|
| "tablet_landscape" // 태블릿 가로 (1024x768)
|
||||||
|
| "tablet_portrait" // 태블릿 세로 (768x1024)
|
||||||
|
| "mobile_landscape" // 모바일 가로 (667x375)
|
||||||
|
| "mobile_portrait"; // 모바일 세로 (375x667)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모드별 해상도 상수
|
||||||
|
*/
|
||||||
|
export const MODE_RESOLUTIONS: Record<PopLayoutModeKey, { width: number; height: number }> = {
|
||||||
|
tablet_landscape: { width: 1024, height: 768 },
|
||||||
|
tablet_portrait: { width: 768, height: 1024 },
|
||||||
|
mobile_landscape: { width: 667, height: 375 },
|
||||||
|
mobile_portrait: { width: 375, height: 667 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v1.0 레이아웃 (기존 - 하위 호환)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 레이아웃 v1.0 (기존 구조)
|
||||||
|
* - 단일 sections 배열로 모든 모드 공유
|
||||||
|
* @deprecated v2.0 사용 권장
|
||||||
|
*/
|
||||||
|
export interface PopLayoutDataV1 {
|
||||||
|
version: "pop-1.0";
|
||||||
|
layoutMode: "grid";
|
||||||
|
deviceTarget: PopDeviceTarget;
|
||||||
|
canvasGrid: PopCanvasGrid;
|
||||||
|
sections: PopSectionDataV1[];
|
||||||
|
metadata?: PopLayoutMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v2.0 레이아웃 (신규)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 레이아웃 v2.0
|
||||||
|
* - 4개 모드별 섹션/컴포넌트 위치 분리
|
||||||
|
* - 섹션/컴포넌트 정의는 공유
|
||||||
|
* - 3단계 데이터 흐름 지원
|
||||||
|
*/
|
||||||
|
export interface PopLayoutDataV2 {
|
||||||
|
version: "pop-2.0";
|
||||||
|
|
||||||
|
// 4개 모드별 레이아웃 (위치/크기만)
|
||||||
|
layouts: {
|
||||||
|
tablet_landscape: PopModeLayout;
|
||||||
|
tablet_portrait: PopModeLayout;
|
||||||
|
mobile_landscape: PopModeLayout;
|
||||||
|
mobile_portrait: PopModeLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공유 섹션 정의 (ID → 정의)
|
||||||
|
sections: Record<string, PopSectionDefinition>;
|
||||||
|
|
||||||
|
// 공유 컴포넌트 정의 (ID → 정의)
|
||||||
|
components: Record<string, PopComponentDefinition>;
|
||||||
|
|
||||||
|
// 3단계 데이터 흐름
|
||||||
|
dataFlow: PopDataFlow;
|
||||||
|
|
||||||
|
// 전역 설정
|
||||||
|
settings: PopGlobalSettings;
|
||||||
|
|
||||||
|
// 메타데이터
|
||||||
|
metadata?: PopLayoutMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모드별 레이아웃 (위치/크기만 저장)
|
||||||
|
*/
|
||||||
|
export interface PopModeLayout {
|
||||||
|
// 섹션별 위치 (섹션 ID → 위치)
|
||||||
|
sectionPositions: Record<string, GridPosition>;
|
||||||
|
// 컴포넌트별 위치 (컴포넌트 ID → 위치) - 섹션 내부 그리드 기준
|
||||||
|
componentPositions: Record<string, GridPosition>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 섹션 정의 (위치 제외)
|
||||||
|
*/
|
||||||
|
export interface PopSectionDefinition {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
// 이 섹션에 포함된 컴포넌트 ID 목록
|
||||||
|
componentIds: string[];
|
||||||
|
// 내부 그리드 설정
|
||||||
|
innerGrid: PopInnerGrid;
|
||||||
|
// 데이터 소스 (섹션 레벨)
|
||||||
|
dataSource?: PopDataSource;
|
||||||
|
// 섹션 내 컴포넌트 간 연결 (Level 1)
|
||||||
|
connections?: PopConnection[];
|
||||||
|
// 스타일
|
||||||
|
style?: PopSectionStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 컴포넌트 정의 (위치 제외)
|
||||||
|
*/
|
||||||
|
export interface PopComponentDefinition {
|
||||||
|
id: string;
|
||||||
|
type: PopComponentType;
|
||||||
|
label?: string;
|
||||||
|
// 데이터 바인딩
|
||||||
|
dataBinding?: PopDataBinding;
|
||||||
|
// 스타일 프리셋
|
||||||
|
style?: PopStylePreset;
|
||||||
|
// 컴포넌트별 설정
|
||||||
|
config?: PopComponentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 소스 설정
|
||||||
|
*/
|
||||||
|
export interface PopDataSource {
|
||||||
|
type: "api" | "static" | "parent";
|
||||||
|
endpoint?: string;
|
||||||
|
params?: Record<string, any>;
|
||||||
|
staticData?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트/섹션 간 연결
|
||||||
|
*/
|
||||||
|
export interface PopConnection {
|
||||||
|
from: string; // 소스 ID (컴포넌트 또는 섹션)
|
||||||
|
to: string; // 타겟 ID
|
||||||
|
trigger: PopTrigger; // 트리거 이벤트
|
||||||
|
action: PopAction; // 수행할 액션
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PopTrigger =
|
||||||
|
| "onChange" // 값 변경 시
|
||||||
|
| "onSubmit" // 제출 시
|
||||||
|
| "onClick" // 클릭 시
|
||||||
|
| "onScan" // 스캔 완료 시
|
||||||
|
| "onSelect"; // 선택 시
|
||||||
|
|
||||||
|
export type PopAction =
|
||||||
|
| { type: "setValue"; targetField: string } // 값 설정
|
||||||
|
| { type: "filter"; filterField: string } // 필터링
|
||||||
|
| { type: "refresh" } // 새로고침
|
||||||
|
| { type: "navigate"; screenId: number } // 화면 이동
|
||||||
|
| { type: "api"; endpoint: string; method: string }; // API 호출
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3단계 데이터 흐름
|
||||||
|
*/
|
||||||
|
export interface PopDataFlow {
|
||||||
|
// Level 2: 섹션 간 연결
|
||||||
|
sectionConnections: PopConnection[];
|
||||||
|
|
||||||
|
// Level 3: 화면 로드 시 파라미터 수신
|
||||||
|
onScreenLoad?: {
|
||||||
|
paramMapping: Record<string, string>; // URL 파라미터 → 컴포넌트 ID
|
||||||
|
};
|
||||||
|
|
||||||
|
// Level 3: 다음 화면으로 데이터 전달
|
||||||
|
navigationOutput?: {
|
||||||
|
screenId: number;
|
||||||
|
paramMapping: Record<string, string>; // 컴포넌트 ID → URL 파라미터명
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전역 설정
|
||||||
|
*/
|
||||||
|
export interface PopGlobalSettings {
|
||||||
|
// 최소 터치 타겟 크기 (px)
|
||||||
|
touchTargetMin: number; // 기본 48px, 산업용 60px
|
||||||
|
// 모드 (일반/산업용)
|
||||||
|
mode: "normal" | "industrial";
|
||||||
|
// 캔버스 그리드 설정
|
||||||
|
canvasGrid: PopCanvasGrid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 통합 타입 (v1 또는 v2)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 레이아웃 데이터 (v1 또는 v2)
|
||||||
|
*/
|
||||||
|
export type PopLayoutData = PopLayoutDataV1 | PopLayoutDataV2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 버전 체크 타입 가드
|
||||||
|
*/
|
||||||
|
export function isV2Layout(data: PopLayoutData): data is PopLayoutDataV2 {
|
||||||
|
return data.version === "pop-2.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isV1Layout(data: PopLayoutData): data is PopLayoutDataV1 {
|
||||||
|
return data.version === "pop-1.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캔버스 그리드 설정
|
||||||
|
*/
|
||||||
|
export interface PopCanvasGrid {
|
||||||
|
columns: number; // 기본 12열
|
||||||
|
rowHeight: number; // 행 높이 (px) - 태블릿 기준
|
||||||
|
gap: number; // 그리드 간격 (px)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대상 디바이스
|
||||||
|
*/
|
||||||
|
export type PopDeviceTarget = "mobile" | "tablet" | "both";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 메타데이터
|
||||||
|
*/
|
||||||
|
export interface PopLayoutMetadata {
|
||||||
|
lastModified?: string;
|
||||||
|
modifiedBy?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 섹션 타입 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그리드 위치/크기
|
||||||
|
* - col/row: 1-based 시작 위치
|
||||||
|
* - colSpan/rowSpan: 차지하는 칸 수
|
||||||
|
*/
|
||||||
|
export interface GridPosition {
|
||||||
|
col: number; // 시작 열 (1-based)
|
||||||
|
row: number; // 시작 행 (1-based)
|
||||||
|
colSpan: number; // 열 개수
|
||||||
|
rowSpan: number; // 행 개수
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v1 섹션/컴포넌트 타입 (기존 구조)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 섹션 데이터 v1 (기존 구조 - 위치 포함)
|
||||||
|
* @deprecated v2에서는 PopSectionDefinition 사용
|
||||||
|
*/
|
||||||
|
export interface PopSectionDataV1 {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
grid: GridPosition;
|
||||||
|
mobileGrid?: GridPosition;
|
||||||
|
innerGrid: PopInnerGrid;
|
||||||
|
components: PopComponentDataV1[];
|
||||||
|
style?: PopSectionStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트 데이터 v1 (기존 구조 - 위치 포함)
|
||||||
|
* @deprecated v2에서는 PopComponentDefinition 사용
|
||||||
|
*/
|
||||||
|
export interface PopComponentDataV1 {
|
||||||
|
id: string;
|
||||||
|
type: PopComponentType;
|
||||||
|
grid: GridPosition;
|
||||||
|
mobileGrid?: GridPosition;
|
||||||
|
label?: string;
|
||||||
|
dataBinding?: PopDataBinding;
|
||||||
|
style?: PopStylePreset;
|
||||||
|
config?: PopComponentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 하위 호환을 위한 alias
|
||||||
|
export type PopSectionData = PopSectionDataV1;
|
||||||
|
export type PopComponentData = PopComponentDataV1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 섹션 내부 그리드 설정
|
||||||
|
*/
|
||||||
|
export interface PopInnerGrid {
|
||||||
|
columns: number; // 내부 열 수 (2, 3, 4, 6 등)
|
||||||
|
rows: number; // 내부 행 수
|
||||||
|
gap: number; // 간격 (px)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 섹션 스타일
|
||||||
|
*/
|
||||||
|
export interface PopSectionStyle {
|
||||||
|
showBorder?: boolean;
|
||||||
|
backgroundColor?: string;
|
||||||
|
padding?: PopPaddingPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 컴포넌트 타입 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 컴포넌트 타입
|
||||||
|
* - 6개 핵심 컴포넌트 (섹션 제외, 섹션은 별도 타입)
|
||||||
|
*/
|
||||||
|
export type PopComponentType =
|
||||||
|
| "pop-field" // 데이터 입력/표시
|
||||||
|
| "pop-button" // 액션 실행
|
||||||
|
| "pop-list" // 데이터 목록
|
||||||
|
| "pop-indicator" // 상태/수치 표시
|
||||||
|
| "pop-scanner" // 바코드/QR 입력
|
||||||
|
| "pop-numpad"; // 숫자 입력 특화
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 컴포넌트 데이터
|
||||||
|
* - 섹션 내부 그리드에 배치
|
||||||
|
* - grid: 섹션 내부 그리드 위치
|
||||||
|
*/
|
||||||
|
export interface PopComponentData {
|
||||||
|
id: string;
|
||||||
|
type: PopComponentType;
|
||||||
|
|
||||||
|
// 섹션 내부 그리드 위치
|
||||||
|
grid: GridPosition;
|
||||||
|
|
||||||
|
// 모바일용 그리드 위치 (선택, 없으면 자동)
|
||||||
|
mobileGrid?: GridPosition;
|
||||||
|
|
||||||
|
// 라벨
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
// 데이터 바인딩
|
||||||
|
dataBinding?: PopDataBinding;
|
||||||
|
|
||||||
|
// 스타일 프리셋
|
||||||
|
style?: PopStylePreset;
|
||||||
|
|
||||||
|
// 컴포넌트별 설정 (타입별로 다름)
|
||||||
|
config?: PopComponentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 프리셋 =====
|
||||||
|
|
||||||
|
export type PopGapPreset = "none" | "small" | "medium" | "large";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스타일 프리셋
|
||||||
|
*/
|
||||||
|
export interface PopStylePreset {
|
||||||
|
variant: PopVariant;
|
||||||
|
padding: PopPaddingPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PopVariant = "default" | "primary" | "success" | "warning" | "danger";
|
||||||
|
export type PopPaddingPreset = "none" | "small" | "medium" | "large";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 패딩 프리셋 → 실제 값 매핑
|
||||||
|
*/
|
||||||
|
export const POP_PADDING_MAP: Record<PopPaddingPreset, string> = {
|
||||||
|
none: "0",
|
||||||
|
small: "8px",
|
||||||
|
medium: "16px",
|
||||||
|
large: "24px",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 데이터 바인딩 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 바인딩 설정
|
||||||
|
* - 기존 데스크톱 시스템과 호환
|
||||||
|
*/
|
||||||
|
export interface PopDataBinding {
|
||||||
|
tableName: string;
|
||||||
|
columnName: string;
|
||||||
|
displayField?: string;
|
||||||
|
filter?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ===== 컴포넌트별 설정 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트별 설정 (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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리스트 설정
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v1 생성 함수 (기존 - 하위 호환)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 v1 레이아웃 생성
|
||||||
|
* @deprecated createEmptyPopLayoutV2 사용 권장
|
||||||
|
*/
|
||||||
|
export const createEmptyPopLayoutV1 = (): PopLayoutDataV1 => ({
|
||||||
|
version: "pop-1.0",
|
||||||
|
layoutMode: "grid",
|
||||||
|
deviceTarget: "both",
|
||||||
|
canvasGrid: { ...DEFAULT_CANVAS_GRID },
|
||||||
|
sections: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 하위 호환을 위한 alias
|
||||||
|
export const createEmptyPopLayout = createEmptyPopLayoutV1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 섹션 생성 (v1)
|
||||||
|
*/
|
||||||
|
export const createPopSection = (
|
||||||
|
id: string,
|
||||||
|
grid: GridPosition = { col: 1, row: 1, colSpan: 3, rowSpan: 4 }
|
||||||
|
): PopSectionDataV1 => ({
|
||||||
|
id,
|
||||||
|
grid,
|
||||||
|
innerGrid: { ...DEFAULT_INNER_GRID },
|
||||||
|
components: [],
|
||||||
|
style: {
|
||||||
|
showBorder: true,
|
||||||
|
padding: "small",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 컴포넌트 생성 (v1)
|
||||||
|
*/
|
||||||
|
export const createPopComponent = (
|
||||||
|
id: string,
|
||||||
|
type: PopComponentType,
|
||||||
|
grid: GridPosition = { col: 1, row: 1, colSpan: 1, rowSpan: 1 },
|
||||||
|
label?: string
|
||||||
|
): PopComponentDataV1 => ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
grid,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v2 생성 함수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 v2 레이아웃 생성
|
||||||
|
*/
|
||||||
|
export const createEmptyPopLayoutV2 = (): PopLayoutDataV2 => ({
|
||||||
|
version: "pop-2.0",
|
||||||
|
layouts: {
|
||||||
|
tablet_landscape: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
tablet_portrait: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
mobile_landscape: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
mobile_portrait: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
},
|
||||||
|
sections: {},
|
||||||
|
components: {},
|
||||||
|
dataFlow: {
|
||||||
|
sectionConnections: [],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
touchTargetMin: 48,
|
||||||
|
mode: "normal",
|
||||||
|
canvasGrid: { ...DEFAULT_CANVAS_GRID },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 섹션 정의 생성
|
||||||
|
*/
|
||||||
|
export const createSectionDefinition = (
|
||||||
|
id: string,
|
||||||
|
label?: string
|
||||||
|
): PopSectionDefinition => ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
componentIds: [],
|
||||||
|
innerGrid: { ...DEFAULT_INNER_GRID },
|
||||||
|
style: {
|
||||||
|
showBorder: true,
|
||||||
|
padding: "small",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 컴포넌트 정의 생성
|
||||||
|
*/
|
||||||
|
export const createComponentDefinition = (
|
||||||
|
id: string,
|
||||||
|
type: PopComponentType,
|
||||||
|
label?: string
|
||||||
|
): PopComponentDefinition => ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 마이그레이션 함수 (v1 → v2)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1 레이아웃을 v2로 마이그레이션
|
||||||
|
* - 기존 섹션/컴포넌트를 tablet_landscape 기준으로 4모드에 복제
|
||||||
|
* - 정의와 위치를 분리
|
||||||
|
*/
|
||||||
|
export const migrateV1ToV2 = (v1: PopLayoutDataV1): PopLayoutDataV2 => {
|
||||||
|
const v2 = createEmptyPopLayoutV2();
|
||||||
|
|
||||||
|
// 캔버스 그리드 설정 복사
|
||||||
|
v2.settings.canvasGrid = { ...v1.canvasGrid };
|
||||||
|
|
||||||
|
// 메타데이터 복사
|
||||||
|
if (v1.metadata) {
|
||||||
|
v2.metadata = { ...v1.metadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 섹션별 마이그레이션
|
||||||
|
for (const section of v1.sections) {
|
||||||
|
// 1. 섹션 정의 생성
|
||||||
|
const sectionDef: PopSectionDefinition = {
|
||||||
|
id: section.id,
|
||||||
|
label: section.label,
|
||||||
|
componentIds: section.components.map(c => c.id),
|
||||||
|
innerGrid: { ...section.innerGrid },
|
||||||
|
style: section.style ? { ...section.style } : undefined,
|
||||||
|
};
|
||||||
|
v2.sections[section.id] = sectionDef;
|
||||||
|
|
||||||
|
// 2. 섹션 위치 복사 (4모드 모두 동일하게)
|
||||||
|
const sectionPos: GridPosition = { ...section.grid };
|
||||||
|
v2.layouts.tablet_landscape.sectionPositions[section.id] = { ...sectionPos };
|
||||||
|
v2.layouts.tablet_portrait.sectionPositions[section.id] = { ...sectionPos };
|
||||||
|
v2.layouts.mobile_landscape.sectionPositions[section.id] = { ...sectionPos };
|
||||||
|
v2.layouts.mobile_portrait.sectionPositions[section.id] = { ...sectionPos };
|
||||||
|
|
||||||
|
// 3. 컴포넌트별 마이그레이션
|
||||||
|
for (const comp of section.components) {
|
||||||
|
// 컴포넌트 정의 생성
|
||||||
|
const compDef: PopComponentDefinition = {
|
||||||
|
id: comp.id,
|
||||||
|
type: comp.type,
|
||||||
|
label: comp.label,
|
||||||
|
dataBinding: comp.dataBinding ? { ...comp.dataBinding } : undefined,
|
||||||
|
style: comp.style ? { ...comp.style } : undefined,
|
||||||
|
config: comp.config,
|
||||||
|
};
|
||||||
|
v2.components[comp.id] = compDef;
|
||||||
|
|
||||||
|
// 컴포넌트 위치 복사 (4모드 모두 동일하게)
|
||||||
|
const compPos: GridPosition = { ...comp.grid };
|
||||||
|
v2.layouts.tablet_landscape.componentPositions[comp.id] = { ...compPos };
|
||||||
|
v2.layouts.tablet_portrait.componentPositions[comp.id] = { ...compPos };
|
||||||
|
v2.layouts.mobile_landscape.componentPositions[comp.id] = { ...compPos };
|
||||||
|
v2.layouts.mobile_portrait.componentPositions[comp.id] = { ...compPos };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return v2;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 데이터를 v2로 보장 (필요시 마이그레이션)
|
||||||
|
*/
|
||||||
|
export const ensureV2Layout = (data: PopLayoutData): PopLayoutDataV2 => {
|
||||||
|
if (isV2Layout(data)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (isV1Layout(data)) {
|
||||||
|
return migrateV1ToV2(data);
|
||||||
|
}
|
||||||
|
// 알 수 없는 버전 - 빈 v2 반환
|
||||||
|
console.warn("알 수 없는 레이아웃 버전, 빈 v2 레이아웃 생성");
|
||||||
|
return createEmptyPopLayoutV2();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v2 헬퍼 함수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 레이아웃에 섹션 추가 (4모드 동기화)
|
||||||
|
*/
|
||||||
|
export const addSectionToV2Layout = (
|
||||||
|
layout: PopLayoutDataV2,
|
||||||
|
sectionId: string,
|
||||||
|
position: GridPosition,
|
||||||
|
label?: string
|
||||||
|
): PopLayoutDataV2 => {
|
||||||
|
const newLayout = { ...layout };
|
||||||
|
|
||||||
|
// 섹션 정의 추가
|
||||||
|
newLayout.sections = {
|
||||||
|
...newLayout.sections,
|
||||||
|
[sectionId]: createSectionDefinition(sectionId, label),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4모드 모두에 위치 추가
|
||||||
|
const modeKeys: PopLayoutModeKey[] = [
|
||||||
|
"tablet_landscape", "tablet_portrait",
|
||||||
|
"mobile_landscape", "mobile_portrait"
|
||||||
|
];
|
||||||
|
|
||||||
|
const newLayouts = { ...newLayout.layouts };
|
||||||
|
for (const mode of modeKeys) {
|
||||||
|
newLayouts[mode] = {
|
||||||
|
...newLayouts[mode],
|
||||||
|
sectionPositions: {
|
||||||
|
...newLayouts[mode].sectionPositions,
|
||||||
|
[sectionId]: { ...position },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
newLayout.layouts = newLayouts;
|
||||||
|
|
||||||
|
return newLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 레이아웃에 컴포넌트 추가 (4모드 동기화)
|
||||||
|
*/
|
||||||
|
export const addComponentToV2Layout = (
|
||||||
|
layout: PopLayoutDataV2,
|
||||||
|
sectionId: string,
|
||||||
|
componentId: string,
|
||||||
|
type: PopComponentType,
|
||||||
|
position: GridPosition,
|
||||||
|
label?: string
|
||||||
|
): PopLayoutDataV2 => {
|
||||||
|
const newLayout = { ...layout };
|
||||||
|
|
||||||
|
// 컴포넌트 정의 추가
|
||||||
|
newLayout.components = {
|
||||||
|
...newLayout.components,
|
||||||
|
[componentId]: createComponentDefinition(componentId, type, label),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 섹션의 componentIds에 추가
|
||||||
|
const section = newLayout.sections[sectionId];
|
||||||
|
if (section) {
|
||||||
|
newLayout.sections = {
|
||||||
|
...newLayout.sections,
|
||||||
|
[sectionId]: {
|
||||||
|
...section,
|
||||||
|
componentIds: [...section.componentIds, componentId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4모드 모두에 위치 추가
|
||||||
|
const modeKeys: PopLayoutModeKey[] = [
|
||||||
|
"tablet_landscape", "tablet_portrait",
|
||||||
|
"mobile_landscape", "mobile_portrait"
|
||||||
|
];
|
||||||
|
|
||||||
|
const newLayouts = { ...newLayout.layouts };
|
||||||
|
for (const mode of modeKeys) {
|
||||||
|
newLayouts[mode] = {
|
||||||
|
...newLayouts[mode],
|
||||||
|
componentPositions: {
|
||||||
|
...newLayouts[mode].componentPositions,
|
||||||
|
[componentId]: { ...position },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
newLayout.layouts = newLayouts;
|
||||||
|
|
||||||
|
return newLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 레이아웃에서 섹션 삭제 (4모드 동기화)
|
||||||
|
*/
|
||||||
|
export const removeSectionFromV2Layout = (
|
||||||
|
layout: PopLayoutDataV2,
|
||||||
|
sectionId: string
|
||||||
|
): PopLayoutDataV2 => {
|
||||||
|
const newLayout = { ...layout };
|
||||||
|
|
||||||
|
// 섹션에 포함된 컴포넌트 ID 가져오기
|
||||||
|
const section = newLayout.sections[sectionId];
|
||||||
|
const componentIds = section?.componentIds || [];
|
||||||
|
|
||||||
|
// 섹션 정의 삭제
|
||||||
|
const { [sectionId]: _, ...remainingSections } = newLayout.sections;
|
||||||
|
newLayout.sections = remainingSections;
|
||||||
|
|
||||||
|
// 컴포넌트 정의 삭제
|
||||||
|
let remainingComponents = { ...newLayout.components };
|
||||||
|
for (const compId of componentIds) {
|
||||||
|
const { [compId]: __, ...rest } = remainingComponents;
|
||||||
|
remainingComponents = rest;
|
||||||
|
}
|
||||||
|
newLayout.components = remainingComponents;
|
||||||
|
|
||||||
|
// 4모드 모두에서 위치 삭제
|
||||||
|
const modeKeys: PopLayoutModeKey[] = [
|
||||||
|
"tablet_landscape", "tablet_portrait",
|
||||||
|
"mobile_landscape", "mobile_portrait"
|
||||||
|
];
|
||||||
|
|
||||||
|
const newLayouts = { ...newLayout.layouts };
|
||||||
|
for (const mode of modeKeys) {
|
||||||
|
const { [sectionId]: ___, ...remainingSecPos } = newLayouts[mode].sectionPositions;
|
||||||
|
let remainingCompPos = { ...newLayouts[mode].componentPositions };
|
||||||
|
for (const compId of componentIds) {
|
||||||
|
const { [compId]: ____, ...rest } = remainingCompPos;
|
||||||
|
remainingCompPos = rest;
|
||||||
|
}
|
||||||
|
newLayouts[mode] = {
|
||||||
|
sectionPositions: remainingSecPos,
|
||||||
|
componentPositions: remainingCompPos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
newLayout.layouts = newLayouts;
|
||||||
|
|
||||||
|
return newLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 레이아웃에서 컴포넌트 삭제 (4모드 동기화)
|
||||||
|
*/
|
||||||
|
export const removeComponentFromV2Layout = (
|
||||||
|
layout: PopLayoutDataV2,
|
||||||
|
sectionId: string,
|
||||||
|
componentId: string
|
||||||
|
): PopLayoutDataV2 => {
|
||||||
|
const newLayout = { ...layout };
|
||||||
|
|
||||||
|
// 컴포넌트 정의 삭제
|
||||||
|
const { [componentId]: _, ...remainingComponents } = newLayout.components;
|
||||||
|
newLayout.components = remainingComponents;
|
||||||
|
|
||||||
|
// 섹션의 componentIds에서 제거
|
||||||
|
const section = newLayout.sections[sectionId];
|
||||||
|
if (section) {
|
||||||
|
newLayout.sections = {
|
||||||
|
...newLayout.sections,
|
||||||
|
[sectionId]: {
|
||||||
|
...section,
|
||||||
|
componentIds: section.componentIds.filter(id => id !== componentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4모드 모두에서 위치 삭제
|
||||||
|
const modeKeys: PopLayoutModeKey[] = [
|
||||||
|
"tablet_landscape", "tablet_portrait",
|
||||||
|
"mobile_landscape", "mobile_portrait"
|
||||||
|
];
|
||||||
|
|
||||||
|
const newLayouts = { ...newLayout.layouts };
|
||||||
|
for (const mode of modeKeys) {
|
||||||
|
const { [componentId]: __, ...remainingCompPos } = newLayouts[mode].componentPositions;
|
||||||
|
newLayouts[mode] = {
|
||||||
|
...newLayouts[mode],
|
||||||
|
componentPositions: remainingCompPos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
newLayout.layouts = newLayouts;
|
||||||
|
|
||||||
|
return newLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 레이아웃에서 특정 모드의 섹션 위치 업데이트
|
||||||
|
*/
|
||||||
|
export const updateSectionPositionInMode = (
|
||||||
|
layout: PopLayoutDataV2,
|
||||||
|
modeKey: PopLayoutModeKey,
|
||||||
|
sectionId: string,
|
||||||
|
position: GridPosition
|
||||||
|
): PopLayoutDataV2 => {
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
layouts: {
|
||||||
|
...layout.layouts,
|
||||||
|
[modeKey]: {
|
||||||
|
...layout.layouts[modeKey],
|
||||||
|
sectionPositions: {
|
||||||
|
...layout.layouts[modeKey].sectionPositions,
|
||||||
|
[sectionId]: position,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 레이아웃에서 특정 모드의 컴포넌트 위치 업데이트
|
||||||
|
*/
|
||||||
|
export const updateComponentPositionInMode = (
|
||||||
|
layout: PopLayoutDataV2,
|
||||||
|
modeKey: PopLayoutModeKey,
|
||||||
|
componentId: string,
|
||||||
|
position: GridPosition
|
||||||
|
): PopLayoutDataV2 => {
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
layouts: {
|
||||||
|
...layout.layouts,
|
||||||
|
[modeKey]: {
|
||||||
|
...layout.layouts[modeKey],
|
||||||
|
componentPositions: {
|
||||||
|
...layout.layouts[modeKey].componentPositions,
|
||||||
|
[componentId]: position,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 타입 가드
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export const isPopField = (comp: PopComponentDataV1 | PopComponentDefinition): boolean =>
|
||||||
|
comp.type === "pop-field";
|
||||||
|
|
||||||
|
export const isPopButton = (comp: PopComponentDataV1 | PopComponentDefinition): boolean =>
|
||||||
|
comp.type === "pop-button";
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-3 rounded-lg border-2 shadow-sm min-w-[140px] text-center transition-colors",
|
||||||
|
isMain
|
||||||
|
? "bg-primary/10 border-primary text-primary"
|
||||||
|
: "bg-background border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-1">
|
||||||
|
{isMain ? (
|
||||||
|
<Monitor className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Layers className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{isMain ? "메인 화면" : data.type === "modal" ? "모달" : data.type === "drawer" ? "드로어" : "전체화면"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-sm">{data.label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypes = {
|
||||||
|
screenNode: ScreenNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function PopScreenFlowView({ screen, className, onSubScreenSelect }: PopScreenFlowViewProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [layoutData, setLayoutData] = useState<PopLayoutData | null>(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 (
|
||||||
|
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
|
||||||
|
<div className="shrink-0 p-3 border-b bg-background">
|
||||||
|
<h3 className="text-sm font-medium">화면 흐름</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<ArrowRight className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="text-sm">화면을 선택하면 흐름이 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
|
||||||
|
<div className="shrink-0 p-3 border-b bg-background">
|
||||||
|
<h3 className="text-sm font-medium">화면 흐름</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layoutData) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
|
||||||
|
<div className="shrink-0 p-3 border-b bg-background">
|
||||||
|
<h3 className="text-sm font-medium">화면 흐름</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<ArrowRight className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="text-sm">POP 레이아웃이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col h-full", className)}>
|
||||||
|
<div className="shrink-0 p-3 border-b bg-background flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-medium">화면 흐름</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{screen.screenName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!hasSubScreens && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
하위 화면 없음
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
{hasSubScreens ? (
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
minZoom={0.5}
|
||||||
|
maxZoom={1.5}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background color="#ddd" gap={16} />
|
||||||
|
<Controls showInteractive={false} />
|
||||||
|
<MiniMap
|
||||||
|
nodeColor={(node) => (node.data?.isMain ? "#3b82f6" : "#9ca3af")}
|
||||||
|
maskColor="rgba(0, 0, 0, 0.1)"
|
||||||
|
className="!bg-muted/50"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
) : (
|
||||||
|
// 하위 화면이 없으면 간단한 단일 노드 표시
|
||||||
|
<div className="h-full flex items-center justify-center bg-muted/10">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-flex items-center justify-center px-6 py-4 rounded-lg border-2 border-primary bg-primary/10">
|
||||||
|
<Monitor className="h-5 w-5 mr-2 text-primary" />
|
||||||
|
<span className="font-medium text-primary">{screen.screenName}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-4">
|
||||||
|
이 화면에 연결된 하위 화면(모달)이 없습니다.
|
||||||
|
<br />
|
||||||
|
화면 설정에서 하위 화면을 추가할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Smartphone, Tablet, Loader2, ExternalLink, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 타입 정의
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type DeviceType = "mobile" | "tablet";
|
||||||
|
|
||||||
|
interface PopScreenPreviewProps {
|
||||||
|
screen: ScreenDefinition | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디바이스 프레임 크기
|
||||||
|
// 모바일: 세로(portrait), 태블릿: 가로(landscape) 디폴트
|
||||||
|
const DEVICE_SIZES = {
|
||||||
|
mobile: { width: 375, height: 667 }, // iPhone SE 기준 (세로)
|
||||||
|
tablet: { width: 1024, height: 768 }, // iPad 기준 (가로)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function PopScreenPreview({ screen, className }: PopScreenPreviewProps) {
|
||||||
|
const [deviceType, setDeviceType] = useState<DeviceType>("tablet");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasLayout, setHasLayout] = useState(false);
|
||||||
|
const [key, setKey] = useState(0); // iframe 새로고침용
|
||||||
|
|
||||||
|
// 레이아웃 존재 여부 확인
|
||||||
|
useEffect(() => {
|
||||||
|
if (!screen) {
|
||||||
|
setHasLayout(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkLayout = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const layout = await screenApi.getLayoutPop(screen.screenId);
|
||||||
|
|
||||||
|
// v2 레이아웃: sections는 객체 (Record<string, PopSectionDefinition>)
|
||||||
|
// v1 레이아웃: sections는 배열
|
||||||
|
if (layout) {
|
||||||
|
const isV2 = layout.version === "pop-2.0";
|
||||||
|
const hasSections = isV2
|
||||||
|
? layout.sections && Object.keys(layout.sections).length > 0
|
||||||
|
: layout.sections && Array.isArray(layout.sections) && layout.sections.length > 0;
|
||||||
|
|
||||||
|
setHasLayout(hasSections);
|
||||||
|
} else {
|
||||||
|
setHasLayout(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setHasLayout(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkLayout();
|
||||||
|
}, [screen]);
|
||||||
|
|
||||||
|
// 미리보기 URL
|
||||||
|
const previewUrl = screen ? `/pop/screens/${screen.screenId}?preview=true&device=${deviceType}` : null;
|
||||||
|
|
||||||
|
// 새 탭에서 열기
|
||||||
|
const openInNewTab = () => {
|
||||||
|
if (previewUrl) {
|
||||||
|
const size = DEVICE_SIZES[deviceType];
|
||||||
|
window.open(previewUrl, "_blank", `width=${size.width + 40},height=${size.height + 80}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// iframe 새로고침
|
||||||
|
const refreshPreview = () => {
|
||||||
|
setKey((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deviceSize = DEVICE_SIZES[deviceType];
|
||||||
|
// 미리보기 컨테이너에 맞게 스케일 조정
|
||||||
|
const scale = deviceType === "tablet" ? 0.5 : 0.6;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="shrink-0 p-3 border-b bg-background flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-medium">미리보기</h3>
|
||||||
|
{screen && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||||
|
{screen.screenName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 디바이스 선택 */}
|
||||||
|
<Tabs value={deviceType} onValueChange={(v) => setDeviceType(v as DeviceType)}>
|
||||||
|
<TabsList className="h-8">
|
||||||
|
<TabsTrigger value="mobile" className="h-7 px-3 gap-1.5" title="모바일 (375x667)">
|
||||||
|
<Smartphone className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-xs">모바일</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tablet" className="h-7 px-3 gap-1.5" title="태블릿 (1024x768 가로)">
|
||||||
|
<Tablet className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-xs">태블릿</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{screen && hasLayout && (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={refreshPreview}>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={openInNewTab}>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 미리보기 영역 */}
|
||||||
|
<div className="flex-1 flex items-center justify-center p-4 overflow-auto">
|
||||||
|
{!screen ? (
|
||||||
|
// 화면 미선택
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
|
||||||
|
{deviceType === "mobile" ? (
|
||||||
|
<Smartphone className="h-8 w-8" />
|
||||||
|
) : (
|
||||||
|
<Tablet className="h-8 w-8" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">화면을 선택하면 미리보기가 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
// 로딩 중
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-3" />
|
||||||
|
<p className="text-sm">레이아웃 확인 중...</p>
|
||||||
|
</div>
|
||||||
|
) : !hasLayout ? (
|
||||||
|
// 레이아웃 없음
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
|
||||||
|
{deviceType === "mobile" ? (
|
||||||
|
<Smartphone className="h-8 w-8" />
|
||||||
|
) : (
|
||||||
|
<Tablet className="h-8 w-8" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mb-2">POP 레이아웃이 없습니다.</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
화면을 더블클릭하여 설계 모드로 이동하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 디바이스 프레임 + iframe (심플한 테두리)
|
||||||
|
<div
|
||||||
|
className="relative border-2 border-gray-300 rounded-lg shadow-lg overflow-hidden"
|
||||||
|
style={{
|
||||||
|
width: deviceSize.width * scale,
|
||||||
|
height: deviceSize.height * scale,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
key={key}
|
||||||
|
src={previewUrl || ""}
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
style={{
|
||||||
|
width: deviceSize.width,
|
||||||
|
height: deviceSize.height,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: "top left",
|
||||||
|
}}
|
||||||
|
title="POP Screen Preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,442 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
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 { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Layers,
|
||||||
|
GitBranch,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
GripVertical,
|
||||||
|
Loader2,
|
||||||
|
Save,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
import { PopScreenGroup, getPopScreenGroups } from "@/lib/api/popScreenGroup";
|
||||||
|
import { PopScreenFlowView } from "./PopScreenFlowView";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 타입 정의
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface PopScreenSettingModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
screen: ScreenDefinition | null;
|
||||||
|
onSave?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubScreenItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "modal" | "drawer" | "fullscreen";
|
||||||
|
triggerFrom?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function PopScreenSettingModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
screen,
|
||||||
|
onSave,
|
||||||
|
}: PopScreenSettingModalProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 개요 탭 상태
|
||||||
|
const [screenName, setScreenName] = useState("");
|
||||||
|
const [screenDescription, setScreenDescription] = useState("");
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string>("");
|
||||||
|
const [screenIcon, setScreenIcon] = useState("");
|
||||||
|
|
||||||
|
// 하위 화면 탭 상태
|
||||||
|
const [subScreens, setSubScreens] = useState<SubScreenItem[]>([]);
|
||||||
|
|
||||||
|
// 카테고리 목록
|
||||||
|
const [categories, setCategories] = useState<PopScreenGroup[]>([]);
|
||||||
|
|
||||||
|
// 초기 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !screen) return;
|
||||||
|
|
||||||
|
// 화면 정보 설정
|
||||||
|
setScreenName(screen.screenName || "");
|
||||||
|
setScreenDescription(screen.description || "");
|
||||||
|
setScreenIcon("");
|
||||||
|
setSelectedCategoryId("");
|
||||||
|
|
||||||
|
// 카테고리 목록 로드
|
||||||
|
loadCategories();
|
||||||
|
|
||||||
|
// 레이아웃에서 하위 화면 정보 로드
|
||||||
|
loadLayoutData();
|
||||||
|
}, [open, screen]);
|
||||||
|
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getPopScreenGroups();
|
||||||
|
setCategories(data.filter((g) => g.hierarchy_path?.startsWith("POP/")));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadLayoutData = async () => {
|
||||||
|
if (!screen) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const layout = await screenApi.getLayoutPop(screen.screenId);
|
||||||
|
|
||||||
|
if (layout && layout.subScreens) {
|
||||||
|
setSubScreens(
|
||||||
|
layout.subScreens.map((sub: any) => ({
|
||||||
|
id: sub.id || `sub-${Date.now()}`,
|
||||||
|
name: sub.name || "",
|
||||||
|
type: sub.type || "modal",
|
||||||
|
triggerFrom: sub.triggerFrom || "main",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSubScreens([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 로드 실패:", error);
|
||||||
|
setSubScreens([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 하위 화면 추가
|
||||||
|
const addSubScreen = () => {
|
||||||
|
const newSubScreen: SubScreenItem = {
|
||||||
|
id: `sub-${Date.now()}`,
|
||||||
|
name: `새 모달 ${subScreens.length + 1}`,
|
||||||
|
type: "modal",
|
||||||
|
triggerFrom: "main",
|
||||||
|
};
|
||||||
|
setSubScreens([...subScreens, newSubScreen]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 하위 화면 삭제
|
||||||
|
const removeSubScreen = (id: string) => {
|
||||||
|
setSubScreens(subScreens.filter((s) => s.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 하위 화면 업데이트
|
||||||
|
const updateSubScreen = (id: string, field: keyof SubScreenItem, value: string) => {
|
||||||
|
setSubScreens(
|
||||||
|
subScreens.map((s) => (s.id === id ? { ...s, [field]: value } : s))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!screen) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
// 화면 기본 정보 업데이트
|
||||||
|
const screenUpdate: Partial<ScreenDefinition> = {
|
||||||
|
screenName,
|
||||||
|
description: screenDescription,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃에 하위 화면 정보 저장
|
||||||
|
const currentLayout = await screenApi.getLayoutPop(screen.screenId);
|
||||||
|
const updatedLayout = {
|
||||||
|
...currentLayout,
|
||||||
|
version: "pop-1.0",
|
||||||
|
subScreens: subScreens,
|
||||||
|
// flow 배열 자동 생성 (메인 → 각 서브)
|
||||||
|
flow: subScreens.map((sub) => ({
|
||||||
|
from: sub.triggerFrom || "main",
|
||||||
|
to: sub.id,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
await screenApi.saveLayoutPop(screen.screenId, updatedLayout);
|
||||||
|
|
||||||
|
toast.success("화면 설정이 저장되었습니다.");
|
||||||
|
onSave?.(screenUpdate);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("저장 실패:", error);
|
||||||
|
toast.error("저장에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!screen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
|
||||||
|
<DialogHeader className="p-4 pb-0 shrink-0">
|
||||||
|
<DialogTitle className="text-base sm:text-lg">POP 화면 설정</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
{screen.screenName} ({screen.screenCode})
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
className="flex-1 flex flex-col min-h-0"
|
||||||
|
>
|
||||||
|
<TabsList className="shrink-0 mx-4 justify-start border-b rounded-none bg-transparent h-auto p-0">
|
||||||
|
<TabsTrigger
|
||||||
|
value="overview"
|
||||||
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
개요
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="subscreens"
|
||||||
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||||
|
>
|
||||||
|
<Layers className="h-4 w-4 mr-2" />
|
||||||
|
하위 화면
|
||||||
|
{subScreens.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-2 text-xs">
|
||||||
|
{subScreens.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="flow"
|
||||||
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||||
|
>
|
||||||
|
<GitBranch className="h-4 w-4 mr-2" />
|
||||||
|
화면 흐름
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 개요 탭 */}
|
||||||
|
<TabsContent value="overview" className="flex-1 m-0 p-4 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 max-w-[500px]">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="screenName" className="text-xs sm:text-sm">
|
||||||
|
화면명 *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="screenName"
|
||||||
|
value={screenName}
|
||||||
|
onChange={(e) => setScreenName(e.target.value)}
|
||||||
|
placeholder="화면 이름"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="category" className="text-xs sm:text-sm">
|
||||||
|
카테고리
|
||||||
|
</Label>
|
||||||
|
<Select value={selectedCategoryId} onValueChange={setSelectedCategoryId}>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="카테고리 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={String(cat.id)}>
|
||||||
|
{cat.group_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||||
|
설명
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={screenDescription}
|
||||||
|
onChange={(e) => setScreenDescription(e.target.value)}
|
||||||
|
placeholder="화면에 대한 설명"
|
||||||
|
rows={3}
|
||||||
|
className="text-xs sm:text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="icon" className="text-xs sm:text-sm">
|
||||||
|
아이콘
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="icon"
|
||||||
|
value={screenIcon}
|
||||||
|
onChange={(e) => setScreenIcon(e.target.value)}
|
||||||
|
placeholder="lucide 아이콘 이름 (예: Package)"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
lucide-react 아이콘 이름을 입력하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 하위 화면 탭 */}
|
||||||
|
<TabsContent value="subscreens" className="flex-1 m-0 p-4 overflow-auto">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
이 화면에서 열리는 모달, 드로어 등의 하위 화면을 관리합니다.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" onClick={addSubScreen}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[300px]">
|
||||||
|
{subScreens.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
<Layers className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="text-sm">하위 화면이 없습니다.</p>
|
||||||
|
<Button variant="link" className="text-xs" onClick={addSubScreen}>
|
||||||
|
하위 화면 추가하기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{subScreens.map((subScreen, index) => (
|
||||||
|
<div
|
||||||
|
key={subScreen.id}
|
||||||
|
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 mt-1 cursor-grab" />
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={subScreen.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSubScreen(subScreen.id, "name", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="화면 이름"
|
||||||
|
className="h-8 text-xs flex-1"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={subScreen.type}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateSubScreen(subScreen.id, "type", v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs w-[100px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="modal">모달</SelectItem>
|
||||||
|
<SelectItem value="drawer">드로어</SelectItem>
|
||||||
|
<SelectItem value="fullscreen">전체화면</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
트리거:
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={subScreen.triggerFrom || "main"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateSubScreen(subScreen.id, "triggerFrom", v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs flex-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="main">메인 화면</SelectItem>
|
||||||
|
{subScreens
|
||||||
|
.filter((s) => s.id !== subScreen.id)
|
||||||
|
.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => removeSubScreen(subScreen.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 화면 흐름 탭 */}
|
||||||
|
<TabsContent value="flow" className="flex-1 m-0 overflow-hidden">
|
||||||
|
<PopScreenFlowView screen={screen} className="h-full" />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="shrink-0 p-4 border-t flex items-center justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/**
|
||||||
|
* POP 화면 관리 컴포넌트
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { PopCategoryTree } from "./PopCategoryTree";
|
||||||
|
export { PopScreenPreview } from "./PopScreenPreview";
|
||||||
|
export { PopScreenFlowView } from "./PopScreenFlowView";
|
||||||
|
export { PopScreenSettingModal } from "./PopScreenSettingModal";
|
||||||
|
|
@ -26,9 +26,10 @@ interface CreateScreenModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onCreated?: (screen: ScreenDefinition) => 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 { user } = useAuth();
|
||||||
|
|
||||||
const [screenName, setScreenName] = useState("");
|
const [screenName, setScreenName] = useState("");
|
||||||
|
|
@ -246,6 +247,19 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||||
|
|
||||||
const created = await screenApi.createScreen(createData);
|
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 = {
|
const mapped: ScreenDefinition = {
|
||||||
...created,
|
...created,
|
||||||
|
|
@ -278,7 +292,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>새 화면 생성</DialogTitle>
|
<DialogTitle>{isPop ? "새 POP 화면 생성" : "새 화면 생성"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,9 @@ interface ScreenDesignerProps {
|
||||||
selectedScreen: ScreenDefinition | null;
|
selectedScreen: ScreenDefinition | null;
|
||||||
onBackToList: () => void;
|
onBackToList: () => void;
|
||||||
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
||||||
|
// POP 모드 지원
|
||||||
|
isPop?: boolean;
|
||||||
|
defaultDevicePreview?: "mobile" | "tablet";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 패널 설정 (통합 패널 1개)
|
// 패널 설정 (통합 패널 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);
|
const { panelStates, togglePanel, openPanel, closePanel } = usePanelState(panelConfigs);
|
||||||
|
|
||||||
|
|
@ -1253,9 +1264,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
console.warn("⚠️ 화면에 할당된 메뉴가 없습니다");
|
console.warn("⚠️ 화면에 할당된 메뉴가 없습니다");
|
||||||
}
|
}
|
||||||
|
|
||||||
// V2 API 사용 여부에 따라 분기
|
// V2/POP API 사용 여부에 따라 분기
|
||||||
let response: any;
|
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);
|
const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId);
|
||||||
response = v2Response ? convertV2ToLegacy(v2Response) : null;
|
response = v2Response ? convertV2ToLegacy(v2Response) : null;
|
||||||
console.log("📦 V2 레이아웃 로드:", v2Response?.components?.length || 0, "개 컴포넌트");
|
console.log("📦 V2 레이아웃 로드:", v2Response?.components?.length || 0, "개 컴포넌트");
|
||||||
|
|
@ -1698,9 +1715,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// V2 API 사용 여부에 따라 분기
|
// V2/POP API 사용 여부에 따라 분기
|
||||||
if (USE_V2_API) {
|
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||||
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);
|
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
||||||
console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
|
console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1725,6 +1747,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
}
|
}
|
||||||
}, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]);
|
}, [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 () => {
|
const handleGenerateMultilang = useCallback(async () => {
|
||||||
if (!selectedScreen?.screenId) {
|
if (!selectedScreen?.screenId) {
|
||||||
|
|
@ -1803,8 +1837,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
|
|
||||||
// 자동 저장 (매핑 정보가 손실되지 않도록)
|
// 자동 저장 (매핑 정보가 손실되지 않도록)
|
||||||
try {
|
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);
|
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
||||||
} else {
|
} else {
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
|
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
|
||||||
|
|
@ -4801,9 +4837,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
gridSettings: layoutWithResolution.gridSettings,
|
gridSettings: layoutWithResolution.gridSettings,
|
||||||
screenResolution: layoutWithResolution.screenResolution,
|
screenResolution: layoutWithResolution.screenResolution,
|
||||||
});
|
});
|
||||||
// V2 API 사용 여부에 따라 분기
|
// V2/POP API 사용 여부에 따라 분기
|
||||||
if (USE_V2_API) {
|
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||||
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);
|
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
||||||
} else {
|
} else {
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||||
|
|
@ -4919,14 +4957,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
onBack={onBackToList}
|
onBack={onBackToList}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
|
onPreview={isPop ? handlePopPreview : undefined}
|
||||||
onResolutionChange={setScreenResolution}
|
onResolutionChange={setScreenResolution}
|
||||||
gridSettings={layout.gridSettings}
|
gridSettings={layout.gridSettings}
|
||||||
onGridSettingsChange={updateGridSettings}
|
onGridSettingsChange={updateGridSettings}
|
||||||
onGenerateMultilang={handleGenerateMultilang}
|
onGenerateMultilang={handleGenerateMultilang}
|
||||||
isGeneratingMultilang={isGeneratingMultilang}
|
isGeneratingMultilang={isGeneratingMultilang}
|
||||||
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
|
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
|
||||||
isPanelOpen={panelStates.v2?.isOpen || false}
|
isPanelOpen={panelStates.v2?.isOpen || false}
|
||||||
onTogglePanel={() => togglePanel("v2")}
|
onTogglePanel={() => togglePanel("v2")}
|
||||||
/>
|
/>
|
||||||
{/* 메인 컨테이너 (패널들 + 캔버스) */}
|
{/* 메인 컨테이너 (패널들 + 캔버스) */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ interface ScreenRelationFlowProps {
|
||||||
screen: ScreenDefinition | null;
|
screen: ScreenDefinition | null;
|
||||||
selectedGroup?: { id: number; name: string; company_code?: string } | null;
|
selectedGroup?: { id: number; name: string; company_code?: string } | null;
|
||||||
initialFocusedScreenId?: number | null;
|
initialFocusedScreenId?: number | null;
|
||||||
|
isPop?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 노드 타입 (Record<string, unknown> 확장)
|
// 노드 타입 (Record<string, unknown> 확장)
|
||||||
|
|
@ -69,7 +70,7 @@ type TableNodeType = Node<TableNodeData & Record<string, unknown>>;
|
||||||
type AllNodeType = ScreenNodeType | TableNodeType;
|
type AllNodeType = ScreenNodeType | TableNodeType;
|
||||||
|
|
||||||
// 내부 컴포넌트 (useReactFlow 사용 가능)
|
// 내부 컴포넌트 (useReactFlow 사용 가능)
|
||||||
function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }: ScreenRelationFlowProps) {
|
function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId, isPop = false }: ScreenRelationFlowProps) {
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<AllNodeType>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<AllNodeType>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -2295,6 +2296,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
fieldMappings={settingModalNode.existingConfig?.fieldMappings}
|
fieldMappings={settingModalNode.existingConfig?.fieldMappings}
|
||||||
componentCount={0}
|
componentCount={0}
|
||||||
onSaveSuccess={handleRefreshVisualization}
|
onSaveSuccess={handleRefreshVisualization}
|
||||||
|
isPop={isPop}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ interface ScreenSettingModalProps {
|
||||||
fieldMappings?: FieldMappingInfo[];
|
fieldMappings?: FieldMappingInfo[];
|
||||||
componentCount?: number;
|
componentCount?: number;
|
||||||
onSaveSuccess?: () => void;
|
onSaveSuccess?: () => void;
|
||||||
|
isPop?: boolean; // POP 화면 여부
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색 가능한 Select 컴포넌트
|
// 검색 가능한 Select 컴포넌트
|
||||||
|
|
@ -239,6 +240,7 @@ export function ScreenSettingModal({
|
||||||
fieldMappings = [],
|
fieldMappings = [],
|
||||||
componentCount = 0,
|
componentCount = 0,
|
||||||
onSaveSuccess,
|
onSaveSuccess,
|
||||||
|
isPop = false,
|
||||||
}: ScreenSettingModalProps) {
|
}: ScreenSettingModalProps) {
|
||||||
const [activeTab, setActiveTab] = useState("overview");
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -519,6 +521,7 @@ export function ScreenSettingModal({
|
||||||
iframeKey={iframeKey}
|
iframeKey={iframeKey}
|
||||||
canvasWidth={canvasSize.width}
|
canvasWidth={canvasSize.width}
|
||||||
canvasHeight={canvasSize.height}
|
canvasHeight={canvasSize.height}
|
||||||
|
isPop={isPop}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4630,9 +4633,10 @@ interface PreviewTabProps {
|
||||||
iframeKey?: number; // iframe 새로고침용 키
|
iframeKey?: number; // iframe 새로고침용 키
|
||||||
canvasWidth?: number; // 화면 캔버스 너비
|
canvasWidth?: number; // 화면 캔버스 너비
|
||||||
canvasHeight?: 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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -4686,12 +4690,18 @@ function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWi
|
||||||
if (companyCode) {
|
if (companyCode) {
|
||||||
params.set("company_code", 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") {
|
if (typeof window !== "undefined") {
|
||||||
const baseUrl = window.location.origin;
|
const baseUrl = window.location.origin;
|
||||||
return `${baseUrl}/screens/${screenId}?${params.toString()}`;
|
return `${baseUrl}${screenPath}?${params.toString()}`;
|
||||||
}
|
}
|
||||||
return `/screens/${screenId}?${params.toString()}`;
|
return `${screenPath}?${params.toString()}`;
|
||||||
}, [screenId, companyCode]);
|
}, [screenId, companyCode, isPop]);
|
||||||
|
|
||||||
const handleIframeLoad = () => {
|
const handleIframeLoad = () => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
|
||||||
|
|
@ -329,8 +329,8 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{onPreview && (
|
{onPreview && (
|
||||||
<Button variant="outline" onClick={onPreview} className="flex items-center space-x-2">
|
<Button variant="outline" onClick={onPreview} className="flex items-center space-x-2">
|
||||||
<Smartphone className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
<span>반응형 미리보기</span>
|
<span>POP 미리보기</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onGenerateMultilang && (
|
{onGenerateMultilang && (
|
||||||
|
|
|
||||||
|
|
@ -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<typeof ResizablePrimitive.PanelGroup>) => (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ResizablePanel = ResizablePrimitive.Panel;
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean;
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||||
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
/**
|
||||||
|
* POP 화면 그룹 관리 API 클라이언트
|
||||||
|
* - hierarchy_path LIKE 'POP/%' 필터로 POP 카테고리만 조회
|
||||||
|
* - 데스크톱 screen_groups와 동일 테이블 사용, 필터로 분리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import { ScreenGroup, ScreenGroupScreen } from "./screenGroup";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// POP 화면 그룹 타입 (ScreenGroup 재활용)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface PopScreenGroup extends ScreenGroup {
|
||||||
|
// 추가 필드 필요시 여기에 정의
|
||||||
|
children?: PopScreenGroup[]; // 트리 구조용
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePopScreenGroupRequest {
|
||||||
|
group_name: string;
|
||||||
|
group_code: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
display_order?: number;
|
||||||
|
parent_group_id?: number | null;
|
||||||
|
target_company_code?: string; // 최고관리자용
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePopScreenGroupRequest {
|
||||||
|
group_name?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
display_order?: number;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// API 함수
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 화면 그룹 목록 조회
|
||||||
|
* - hierarchy_path가 'POP'으로 시작하는 그룹만 조회
|
||||||
|
*/
|
||||||
|
export async function getPopScreenGroups(searchTerm?: string): Promise<PopScreenGroup[]> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (searchTerm) {
|
||||||
|
params.append("searchTerm", searchTerm);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/screen-groups/pop/groups${params.toString() ? `?${params.toString()}` : ""}`;
|
||||||
|
const response = await apiClient.get<{ success: boolean; data: PopScreenGroup[] }>(url);
|
||||||
|
|
||||||
|
if (response.data?.success) {
|
||||||
|
return response.data.data || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("POP 화면 그룹 조회 실패:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 화면 그룹 생성
|
||||||
|
*/
|
||||||
|
export async function createPopScreenGroup(
|
||||||
|
data: CreatePopScreenGroupRequest
|
||||||
|
): Promise<{ success: boolean; data?: PopScreenGroup; message?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{ success: boolean; data: PopScreenGroup; message: string }>(
|
||||||
|
"/screen-groups/pop/groups",
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("POP 화면 그룹 생성 실패:", error);
|
||||||
|
return { success: false, message: error.response?.data?.message || "생성에 실패했습니다." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 화면 그룹 수정
|
||||||
|
*/
|
||||||
|
export async function updatePopScreenGroup(
|
||||||
|
id: number,
|
||||||
|
data: UpdatePopScreenGroupRequest
|
||||||
|
): Promise<{ success: boolean; data?: PopScreenGroup; message?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put<{ success: boolean; data: PopScreenGroup; message: string }>(
|
||||||
|
`/screen-groups/pop/groups/${id}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("POP 화면 그룹 수정 실패:", error);
|
||||||
|
return { success: false, message: error.response?.data?.message || "수정에 실패했습니다." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 화면 그룹 삭제
|
||||||
|
*/
|
||||||
|
export async function deletePopScreenGroup(
|
||||||
|
id: number
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete<{ success: boolean; message: string }>(
|
||||||
|
`/screen-groups/pop/groups/${id}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("POP 화면 그룹 삭제 실패:", error);
|
||||||
|
return { success: false, message: error.response?.data?.message || "삭제에 실패했습니다." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 루트 그룹 확보 (없으면 자동 생성)
|
||||||
|
*/
|
||||||
|
export async function ensurePopRootGroup(): Promise<{ success: boolean; data?: PopScreenGroup; message?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{ success: boolean; data: PopScreenGroup; message: string }>(
|
||||||
|
"/screen-groups/pop/ensure-root"
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("POP 루트 그룹 확보 실패:", error);
|
||||||
|
return { success: false, message: error.response?.data?.message || "루트 그룹 확보에 실패했습니다." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 유틸리티 함수
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플랫 리스트를 트리 구조로 변환
|
||||||
|
*/
|
||||||
|
export function buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[] {
|
||||||
|
const groupMap = new Map<number, PopScreenGroup>();
|
||||||
|
const rootGroups: PopScreenGroup[] = [];
|
||||||
|
|
||||||
|
// 먼저 모든 그룹을 맵에 저장
|
||||||
|
groups.forEach((group) => {
|
||||||
|
groupMap.set(group.id, { ...group, children: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 트리 구조 생성
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const node = groupMap.get(group.id)!;
|
||||||
|
|
||||||
|
if (group.parent_group_id && groupMap.has(group.parent_group_id)) {
|
||||||
|
// 부모가 있으면 부모의 children에 추가
|
||||||
|
const parent = groupMap.get(group.parent_group_id)!;
|
||||||
|
parent.children = parent.children || [];
|
||||||
|
parent.children.push(node);
|
||||||
|
} else {
|
||||||
|
// 부모가 없거나 POP 루트면 최상위에 추가
|
||||||
|
// hierarchy_path가 'POP'이거나 'POP/XXX' 형태인지 확인
|
||||||
|
if (group.hierarchy_path === "POP" ||
|
||||||
|
(group.hierarchy_path?.startsWith("POP/") &&
|
||||||
|
group.hierarchy_path.split("/").length === 2)) {
|
||||||
|
rootGroups.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// display_order로 정렬
|
||||||
|
const sortByOrder = (a: PopScreenGroup, b: PopScreenGroup) =>
|
||||||
|
(a.display_order || 0) - (b.display_order || 0);
|
||||||
|
|
||||||
|
rootGroups.sort(sortByOrder);
|
||||||
|
rootGroups.forEach((group) => {
|
||||||
|
if (group.children) {
|
||||||
|
group.children.sort(sortByOrder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return rootGroups;
|
||||||
|
}
|
||||||
|
|
@ -213,6 +213,32 @@ export const screenApi = {
|
||||||
await apiClient.post(`/screen-management/screens/${screenId}/layout-v2`, layoutData);
|
await apiClient.post(`/screen-management/screens/${screenId}/layout-v2`, layoutData);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// POP 레이아웃 관리 (모바일/태블릿)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// POP 레이아웃 조회
|
||||||
|
getLayoutPop: async (screenId: number): Promise<any> => {
|
||||||
|
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout-pop`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// POP 레이아웃 저장
|
||||||
|
saveLayoutPop: async (screenId: number, layoutData: any): Promise<void> => {
|
||||||
|
await apiClient.post(`/screen-management/screens/${screenId}/layout-pop`, layoutData);
|
||||||
|
},
|
||||||
|
|
||||||
|
// POP 레이아웃 삭제
|
||||||
|
deleteLayoutPop: async (screenId: number): Promise<void> => {
|
||||||
|
await apiClient.delete(`/screen-management/screens/${screenId}/layout-pop`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// POP 레이아웃이 존재하는 화면 ID 목록 조회
|
||||||
|
getScreenIdsWithPopLayout: async (): Promise<number[]> => {
|
||||||
|
const response = await apiClient.get(`/screen-management/pop-layout-screen-ids`);
|
||||||
|
return response.data.data || [];
|
||||||
|
},
|
||||||
|
|
||||||
// 연결된 모달 화면 감지
|
// 연결된 모달 화면 감지
|
||||||
detectLinkedModals: async (
|
detectLinkedModals: async (
|
||||||
screenId: number,
|
screenId: number,
|
||||||
|
|
|
||||||
|
|
@ -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<any>;
|
||||||
|
configPanel?: React.ComponentType<any>;
|
||||||
|
defaultProps?: Record<string, any>;
|
||||||
|
// 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<string, PopComponentDefinition>();
|
||||||
|
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<PopComponentCategory, number> {
|
||||||
|
const stats: Record<PopComponentCategory, number> = {
|
||||||
|
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;
|
||||||
|
|
@ -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 { };
|
||||||
|
|
@ -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<string, z.ZodTypeAny> = {
|
||||||
|
"pop-card-list": popCardListOverridesSchema,
|
||||||
|
"pop-touch-button": popTouchButtonOverridesSchema,
|
||||||
|
"pop-scanner-input": popScannerInputOverridesSchema,
|
||||||
|
"pop-status-badge": popStatusBadgeOverridesSchema,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// POP 컴포넌트 기본값 레지스트리
|
||||||
|
// ============================================
|
||||||
|
export const popComponentDefaultsRegistry: Record<string, Record<string, any>> = {
|
||||||
|
"pop-card-list": popCardListDefaults,
|
||||||
|
"pop-touch-button": popTouchButtonDefaults,
|
||||||
|
"pop-scanner-input": popScannerInputDefaults,
|
||||||
|
"pop-status-badge": popStatusBadgeDefaults,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// POP 기본값 조회 함수
|
||||||
|
// ============================================
|
||||||
|
export function getPopComponentDefaults(componentType: string): Record<string, any> {
|
||||||
|
return popComponentDefaultsRegistry[componentType] || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// POP URL로 기본값 조회
|
||||||
|
// ============================================
|
||||||
|
export function getPopDefaultsByUrl(componentUrl: string): Record<string, any> {
|
||||||
|
// "@/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<string, any>,
|
||||||
|
): Record<string, any> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/react-grid-layout": "^1.3.6",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@types/three": "^0.180.0",
|
"@types/three": "^0.180.0",
|
||||||
"@xyflow/react": "^12.8.4",
|
"@xyflow/react": "^12.8.4",
|
||||||
|
|
@ -76,6 +77,7 @@
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-grid-layout": "^2.2.2",
|
||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-is": "^18.3.1",
|
"react-is": "^18.3.1",
|
||||||
|
|
@ -259,7 +261,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
|
|
@ -301,7 +302,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -335,7 +335,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|
@ -2666,7 +2665,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
||||||
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.17.8",
|
"@babel/runtime": "^7.17.8",
|
||||||
"@types/react-reconciler": "^0.32.0",
|
"@types/react-reconciler": "^0.32.0",
|
||||||
|
|
@ -3320,7 +3318,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
||||||
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.6"
|
"@tanstack/query-core": "5.90.6"
|
||||||
},
|
},
|
||||||
|
|
@ -3388,7 +3385,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
|
@ -3702,7 +3698,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
||||||
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-changeset": "^2.3.0",
|
"prosemirror-changeset": "^2.3.0",
|
||||||
"prosemirror-collab": "^1.3.1",
|
"prosemirror-collab": "^1.3.1",
|
||||||
|
|
@ -6203,7 +6198,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
|
|
@ -6214,11 +6208,19 @@
|
||||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/react-reconciler": {
|
||||||
"version": "0.32.2",
|
"version": "0.32.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
"@tweenjs/tween.js": "~23.1.3",
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
|
@ -6331,7 +6332,6 @@
|
||||||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.46.2",
|
"@typescript-eslint/scope-manager": "8.46.2",
|
||||||
"@typescript-eslint/types": "8.46.2",
|
"@typescript-eslint/types": "8.46.2",
|
||||||
|
|
@ -6964,7 +6964,6 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -8115,8 +8114,7 @@
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/d3": {
|
"node_modules/d3": {
|
||||||
"version": "7.9.0",
|
"version": "7.9.0",
|
||||||
|
|
@ -8438,7 +8436,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
|
|
@ -9198,7 +9195,6 @@
|
||||||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -9287,7 +9283,6 @@
|
||||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
|
|
@ -9389,7 +9384,6 @@
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
|
|
@ -9793,6 +9787,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/immer"
|
"url": "https://opencollective.com/immer"
|
||||||
|
|
@ -11121,7 +11120,6 @@
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
|
|
@ -11322,8 +11320,7 @@
|
||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
|
|
@ -11730,7 +11727,6 @@
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
|
|
@ -12161,7 +12157,6 @@
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
|
@ -12622,7 +12617,6 @@
|
||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
|
|
@ -12791,7 +12785,6 @@
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
|
|
@ -12803,7 +12796,6 @@
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/prosemirror-changeset": {
|
"node_modules/prosemirror-changeset": {
|
||||||
|
|
@ -12918,7 +12910,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"orderedmap": "^2.0.0"
|
"orderedmap": "^2.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -12948,7 +12939,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.0.0",
|
"prosemirror-model": "^1.0.0",
|
||||||
"prosemirror-transform": "^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",
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.20.0",
|
"prosemirror-model": "^1.20.0",
|
||||||
"prosemirror-state": "^1.0.0",
|
"prosemirror-state": "^1.0.0",
|
||||||
|
|
@ -13124,7 +13113,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -13194,7 +13182,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.26.0"
|
"scheduler": "^0.26.0"
|
||||||
},
|
},
|
||||||
|
|
@ -13208,12 +13195,43 @@
|
||||||
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.66.0",
|
"version": "7.66.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"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": {
|
"node_modules/react-resizable-panels": {
|
||||||
"version": "3.0.6",
|
"version": "3.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
|
@ -13563,8 +13594,7 @@
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/recharts/node_modules/redux-thunk": {
|
"node_modules/recharts/node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
|
|
@ -13665,6 +13695,12 @@
|
||||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
|
|
@ -14588,8 +14624,7 @@
|
||||||
"version": "0.180.0",
|
"version": "0.180.0",
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/three-mesh-bvh": {
|
"node_modules/three-mesh-bvh": {
|
||||||
"version": "0.8.3",
|
"version": "0.8.3",
|
||||||
|
|
@ -14677,7 +14712,6 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -15026,7 +15060,6 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/react-grid-layout": "^1.3.6",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@types/three": "^0.180.0",
|
"@types/three": "^0.180.0",
|
||||||
"@xyflow/react": "^12.8.4",
|
"@xyflow/react": "^12.8.4",
|
||||||
|
|
@ -85,6 +86,7 @@
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-grid-layout": "^2.2.2",
|
||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-is": "^18.3.1",
|
"react-is": "^18.3.1",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue