diff --git a/POPUPDATE.md b/POPUPDATE.md new file mode 100644 index 00000000..836cdb1f --- /dev/null +++ b/POPUPDATE.md @@ -0,0 +1,1041 @@ +# POP 화면 관리 시스템 개발 기록 + +> **AI 에이전트 안내**: 이 문서는 Progressive Disclosure 방식으로 구성되어 있습니다. +> 1. 먼저 [Quick Reference](#quick-reference)에서 필요한 정보 확인 +> 2. 상세 내용이 필요하면 해당 섹션으로 이동 +> 3. 코드가 필요하면 파일 직접 참조 + +--- + +## Quick Reference + +### POP이란? +Point of Production - 현장 작업자용 모바일/태블릿 화면 시스템 + +### 핵심 결정사항 +- **분리 방식**: 레이아웃 기반 구분 (screen_layouts_pop 테이블) +- **식별 방법**: `screen_layouts_pop`에 레코드 존재 여부로 POP 화면 판별 +- **데스크톱 영향**: 없음 (모든 isPop 기본값 = false) + +### 주요 경로 + +| 용도 | 경로 | +|------|------| +| POP 뷰어 URL | `/pop/screens/{screenId}?preview=true&device=tablet` | +| POP 관리 페이지 | `/admin/screenMng/popScreenMngList` | +| POP 레이아웃 API | `/api/screen-management/layout-pop/:screenId` | + +### 파일 찾기 가이드 + +| 작업 | 파일 | +|------|------| +| POP 레이아웃 DB 스키마 | `db/migrations/052_create_screen_layouts_pop.sql` | +| POP API 서비스 로직 | `backend-node/src/services/screenManagementService.ts` (getLayoutPop, saveLayoutPop) | +| POP API 라우트 | `backend-node/src/routes/screenManagementRoutes.ts` | +| 프론트엔드 API 클라이언트 | `frontend/lib/api/screen.ts` (screenApi.getLayoutPop 등) | +| POP 화면 관리 UI | `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` | +| POP 뷰어 페이지 | `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` | +| 미리보기 URL 분기 | `frontend/components/screen/ScreenSettingModal.tsx` (PreviewTab) | +| POP 컴포넌트 설계서 | `docs/pop/components-spec.md` (13개 컴포넌트 상세) | + +--- + +## 섹션 목차 + +| # | 섹션 | 한 줄 요약 | +|---|------|----------| +| 1 | [아키텍처](#1-아키텍처) | 레이아웃 테이블로 POP/데스크톱 분리 | +| 2 | [데이터베이스](#2-데이터베이스) | screen_layouts_pop 테이블 (FK 없음) | +| 3 | [백엔드 API](#3-백엔드-api) | CRUD 4개 엔드포인트 | +| 4 | [프론트엔드 API](#4-프론트엔드-api) | screenApi에 4개 함수 추가 | +| 5 | [관리 페이지](#5-관리-페이지) | POP 화면만 필터링하여 표시 | +| 6 | [뷰어](#6-뷰어) | 모바일/태블릿 프레임 미리보기 | +| 7 | [미리보기](#7-미리보기) | isPop prop으로 URL 분기 | +| 8 | [파일 목록](#8-파일-목록) | 생성 3개, 수정 9개 | +| 9 | [반응형 전략](#9-반응형-전략-신규-결정사항) | Flow 레이아웃 (세로 쌓기) 채택 | +| 10 | [POP 사용자 앱](#10-pop-사용자-앱-구조-신규-결정사항) | 대시보드 카드 → 화면 뷰어 | +| 11 | [POP 디자이너](#11-pop-디자이너-신규-계획) | 좌(탭패널) + 우(팬캔버스), 반응형 편집 | +| 12 | [데이터 구조](#12-pop-레이아웃-데이터-구조-신규) | PopLayoutData, mobileOverride | +| 13 | [컴포넌트 재사용성](#13-컴포넌트-재사용성-분석-신규) | 2개 재사용, 4개 부분, 7개 신규 | + +--- + +## 1. 아키텍처 + +**결정**: Option B (레이아웃 기반 구분) + +``` +screen_definitions (공용) + ├── screen_layouts_v2 (데스크톱) + └── screen_layouts_pop (POP) +``` + +**선택 이유**: 기존 테이블 변경 없음, 데스크톱 영향 없음, 향후 통합 가능 + +--- + +## 2. 데이터베이스 + +**테이블**: `screen_layouts_pop` + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | SERIAL | PK | +| screen_id | INTEGER | 화면 ID (unique) | +| layout_data | JSONB | 컴포넌트 JSON | + +**특이사항**: FK 없음 (soft-delete 지원) + +**파일**: `db/migrations/052_create_screen_layouts_pop.sql` + +--- + +## 3. 백엔드 API + +| Method | Endpoint | 용도 | +|--------|----------|------| +| GET | `/api/screen-management/layout-pop/:screenId` | 조회 | +| POST | `/api/screen-management/layout-pop/:screenId` | 저장 | +| DELETE | `/api/screen-management/layout-pop/:screenId` | 삭제 | +| GET | `/api/screen-management/pop-layout-screen-ids` | ID 목록 | + +**파일**: `backend-node/src/services/screenManagementService.ts` + +--- + +## 4. 프론트엔드 API + +**파일**: `frontend/lib/api/screen.ts` + +```typescript +screenApi.getLayoutPop(screenId) // 조회 +screenApi.saveLayoutPop(screenId, data) // 저장 +screenApi.deleteLayoutPop(screenId) // 삭제 +screenApi.getScreenIdsWithPopLayout() // ID 목록 +``` + +--- + +## 5. 관리 페이지 + +**파일**: `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` + +**핵심 로직**: +```typescript +const popIds = await screenApi.getScreenIdsWithPopLayout(); +const filteredScreens = screens.filter(s => new Set(popIds).has(s.screenId)); +``` + +**기능**: POP 화면만 표시, 새 POP 화면 생성):, 보기/설계 버튼 + +--- + +## 6. 뷰어 + +**파일**: `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` + +**URL 파라미터**: +| 파라미터 | 값 | 설명 | +|---------|---|------| +| preview | true | 툴바 표시 | +| device | mobile/tablet | 디바이스 크기 (기본: tablet) | + +**디바이스 크기**: mobile(375x812), tablet(768x1024) + +--- + +## 7. 미리보기 + +**핵심**: `isPop` prop으로 URL 분기 + +``` +popScreenMngList + └─► ScreenRelationFlow(isPop=true) + └─► ScreenSettingModal + └─► PreviewTab → /pop/screens/{id} + +screenMngList (데스크톱) + └─► ScreenRelationFlow(isPop=false 기본값) + └─► ScreenSettingModal + └─► PreviewTab → /screens/{id} +``` + +**안전성**: isPop 기본값 = false → 데스크톱 영향 없음 + +--- + +## 8. 파일 목록 + +### 생성 (3개) + +| 파일 | 용도 | +|------|------| +| `db/migrations/052_create_screen_layouts_pop.sql` | DB 스키마 | +| `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` | POP 뷰어 | +| `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` | POP 관리 | + +### 수정 (9개) + +| 파일 | 변경 내용 | +|------|----------| +| `backend-node/src/services/screenManagementService.ts` | POP CRUD 함수 | +| `backend-node/src/controllers/screenManagementController.ts` | 컨트롤러 | +| `backend-node/src/routes/screenManagementRoutes.ts` | 라우트 | +| `frontend/lib/api/screen.ts` | API 클라이언트 | +| `frontend/components/screen/CreateScreenModal.tsx` | isPop prop | +| `frontend/components/screen/ScreenSettingModal.tsx` | isPop, PreviewTab | +| `frontend/components/screen/ScreenRelationFlow.tsx` | isPop 전달 | +| `frontend/components/screen/ScreenDesigner.tsx` | isPop, 미리보기 | +| `frontend/components/screen/toolbar/SlimToolbar.tsx` | POP 미리보기 버튼 | + +--- + +## 9. 반응형 전략 (신규 결정사항) + +### 문제점 +- 데스크톱은 절대 좌표(`position: { x, y }`) 사용 +- 모바일 화면 크기가 달라지면 레이아웃 깨짐 + +### 결정: Flow 레이아웃 채택 + +| 항목 | 데스크톱 | POP | +|-----|---------|-----| +| 배치 방식 | `position: { x, y }` | `order: number` (순서) | +| 컨테이너 | 자유 배치 | 중첩 구조 (섹션 > 필드) | +| 렌더러 | 절대 좌표 계산 | Flexbox column (세로 쌓기) | + +### Flow 레이아웃 데이터 구조 +```typescript +{ + layoutMode: "flow", // flow | absolute + components: [ + { + id: "section-1", + type: "pop-section", + order: 0, // 순서로 배치 + children: [...] + } + ] +} +``` + +--- + +## 10. POP 사용자 앱 구조 (신규 결정사항) + +### 데스크톱 vs POP 진입 구조 + +| | 데스크톱 | POP | +|---|---------|-----| +| 메뉴 | 왼쪽 사이드바 | 대시보드 카드 | +| 네비게이션 | 복잡한 트리 구조 | 화면 → 뒤로가기 | +| URL | `/screens/{id}` | `/pop/screens/{id}` | + +### POP 화면 흐름 +``` +/pop/login (POP 로그인) + ↓ +/pop/dashboard (화면 목록 - 카드형) + ↓ +/pop/screens/{id} (화면 뷰어) +``` + +--- + +## 11. POP 디자이너 (신규 계획) + +### 진입 경로 +``` +popScreenMngList → [설계] 버튼 → PopDesigner 컴포넌트 +``` + +### 레이아웃 구조 (2026-02-02 수정) +데스크톱 Screen Designer와 유사하게 **좌측 탭 패널 + 우측 캔버스**: +``` +┌─────────────────────────────────────────────────────────────────┐ +│ [툴바] ← 목록 | 화면명 | 📱모바일 📱태블릿 | 🔄 | 💾저장 │ +├────────────────┬────────────────────────────────────────────────┤ +│ [패널] │ [캔버스 영역] │ +│ ◀━━━━▶ │ │ +│ (리사이즈) │ ┌────────────────────────┐ │ +│ │ │ 디바이스 프레임 │ ← 드래그로 │ +│ ┌────────────┐ │ │ │ 팬 이동 │ +│ │컴포넌트│편집│ │ │ [섹션 1] │ │ +│ └────────────┘ │ │ ├─ 필드 A │ │ +│ │ │ └─ 필드 B │ │ +│ (컴포넌트 탭) │ │ │ │ +│ 📦 섹션 │ │ [섹션 2] │ │ +│ 📝 필드 │ │ ├─ 버튼1 ─ 버튼2 │ │ +│ 🔘 버튼 │ │ │ │ +│ 📋 리스트 │ └────────────────────────┘ │ +│ 📊 인디케이터 │ │ +│ │ │ +│ (편집 탭) │ │ +│ 선택된 컴포 │ │ +│ 넌트 설정 │ │ +└────────────────┴────────────────────────────────────────────────┘ +``` + +### 패널 기능 +| 기능 | 설명 | +|-----|------| +| **리사이즈** | 드래그로 패널 너비 조절 (min: 200px, max: 400px) | +| **컴포넌트 탭** | POP 전용 컴포넌트만 표시 | +| **편집 탭** | 선택된 컴포넌트 설정 (프리셋 기반) | + +### 캔버스 기능 +| 기능 | 설명 | +|-----|------| +| **팬(Pan)** | 마우스 드래그로 보는 위치 이동 | +| **줌** | 마우스 휠로 확대/축소 (선택사항) | +| **디바이스 탭** | 📱모바일 / 📱태블릿 전환 | +| **나란히 보기** | 옵션으로 둘 다 표시 가능 | +| **실시간 미리보기** | 편집 = 미리보기 (별도 창 불필요) | + +### 캔버스 방식: 블록 쌓기 +- 섹션끼리는 위→아래로 쌓임 +- 섹션 안에서는 가로(row) 또는 세로(column) 선택 가능 +- 드래그앤드롭으로 순서 변경 +- 캔버스 자체가 실시간 미리보기 + +### 기준 해상도 +| 디바이스 | 논리적 크기 (dp) | 용도 | +|---------|-----------------|------| +| 모바일 | 360 x 640 | Zebra TC52/57 등 산업용 핸드헬드 | +| 태블릿 | 768 x 1024 | 8~10인치 산업용 태블릿 | + +### 터치 타겟 (장갑 착용 고려) +- 최소 버튼 크기: **60dp** (일반 앱 48dp보다 큼) +- 버튼 간격: **16dp** 이상 + +### 반응형 편집 방식 +| 모드 | 설명 | +|-----|------| +| **기준 디바이스** | 태블릿 (메인 편집) | +| **자동 조정** | CSS flex-wrap, grid로 모바일 자동 줄바꿈 | +| **수동 조정** | 모바일 탭에서 그리드 열 수, 숨기기 설정 | + +**흐름:** +``` +1. 태블릿 탭에서 편집 (기준) + → 모든 컴포넌트, 섹션, 순서, 데이터 바인딩 설정 + +2. 모바일 탭에서 확인 + A) 자동 조정 OK → 그대로 저장 + B) 배치 어색함 → 그리드 열 수 조정 또는 숨기기 +``` + +### 섹션 내 컴포넌트 배치 옵션 +| 설정 | 옵션 | +|-----|------| +| 배치 방향 | `row` / `column` | +| 순서 | 드래그로 변경 | +| 비율 | flex (1:1, 2:1, 1:2 등) | +| 정렬 | `start` / `center` / `end` | +| 간격 | `none` / `small` / `medium` / `large` | +| 줄바꿈 | `wrap` / `nowrap` | +| **그리드 열 수** | 태블릿용, 모바일용 각각 설정 가능 | + +### 관리자가 설정 가능한 것 +| 항목 | 설정 방식 | +|-----|----------| +| 섹션 순서 | 드래그로 위/아래 이동 | +| 섹션 내 배치 | 가로(row) / 세로(column) | +| 정렬 | 왼쪽/가운데/오른쪽, 위/가운데/아래 | +| 컴포넌트 비율 | 1:1, 2:1, 1:2 등 (flex) | +| 크기 | S/M/L/XL 프리셋 | +| 여백/간격 | 작음/보통/넓음 프리셋 | +| 아이콘 | 선택 가능 | +| 테마/색상 | 프리셋 또는 커스텀 | +| 그리드 열 수 | 태블릿/모바일 각각 | +| 모바일 숨기기 | 특정 컴포넌트 숨김 | + +### 관리자가 설정 불가능한 것 (반응형 유지) +- 정확한 x, y 좌표 +- 정확한 픽셀 크기 (예: 347px) +- 고정 위치 (예: 왼쪽에서 100px) + +### 스타일 분리 원칙 +``` +뼈대 (변경 어려움 - 처음부터 잘 설계): +- 데이터 바인딩 구조 (columnName, dataSource) +- 컴포넌트 계층 (섹션 > 필드) +- 액션 로직 + +옷 (변경 쉬움 - 나중에 조정 가능): +- 색상, 폰트 크기 → CSS 변수/테마 +- 버튼 모양 → 프리셋 +- 아이콘 → 선택 +``` + +### 다국어 연동 (준비) +- 상태: `showMultilangSettingsModal` 미리 추가 +- 버튼: 툴바에 자리만 (비활성) +- 연결: 추후 `MultilangSettingsModal` import + +### 데스크톱 시스템 재사용 +| 기능 | 재사용 | 비고 | +|-----|-------|------| +| formData 관리 | O | 그대로 | +| 필드간 연결 | O | cascading, hierarchy | +| 테이블 참조 | O | dataSource, filter | +| 저장 이벤트 | O | beforeFormSave | +| 집계 | O | 스타일만 변경 | +| 설정 패널 | O | 탭 방식 참고 | +| CRUD API | O | 그대로 | +| buttonActions | O | 그대로 | +| 다국어 | O | MultilangSettingsModal | + +### 파일 구조 (신규 생성 예정) +``` +frontend/components/pop/ +├── PopDesigner.tsx # 메인 (좌: 패널, 우: 캔버스) +├── PopCanvas.tsx # 캔버스 (팬/줌 + 프레임) +├── PopToolbar.tsx # 상단 툴바 +│ +├── panels/ +│ └── PopPanel.tsx # 통합 패널 (컴포넌트/편집 탭) +│ +├── components/ # POP 전용 컴포넌트 +│ ├── PopSection.tsx +│ ├── PopField.tsx +│ ├── PopButton.tsx +│ └── ... +│ +└── types/ + └── pop-layout.ts # PopLayoutData, PopComponentData +``` + +--- + +## 12. POP 레이아웃 데이터 구조 (신규) + +### PopLayoutData +```typescript +interface PopLayoutData { + version: "pop-1.0"; + layoutMode: "flow"; // 항상 flow (절대좌표 없음) + deviceTarget: "mobile" | "tablet" | "both"; + components: PopComponentData[]; +} +``` + +### PopComponentData +```typescript +interface PopComponentData { + id: string; + type: "pop-section" | "pop-field" | "pop-button" | "pop-list" | "pop-indicator"; + order: number; // 순서 (x, y 좌표 대신) + + // 개별 컴포넌트 flex 비율 + flex?: number; // 기본 1 + + // 섹션인 경우: 내부 레이아웃 설정 + layout?: { + direction: "row" | "column"; + justify: "start" | "center" | "end" | "between"; + align: "start" | "center" | "end"; + gap: "none" | "small" | "medium" | "large"; + wrap: boolean; + grid?: number; // 태블릿 기준 열 수 + }; + + // 크기 프리셋 + size?: "S" | "M" | "L" | "XL" | "full"; + + // 데이터 바인딩 + dataBinding?: { + tableName: string; + columnName: string; + displayField?: string; + }; + + // 스타일 프리셋 + style?: { + variant: "default" | "primary" | "success" | "warning" | "danger"; + padding: "none" | "small" | "medium" | "large"; + }; + + // 모바일 오버라이드 (선택사항) + mobileOverride?: { + grid?: number; // 모바일 열 수 (없으면 자동) + hidden?: boolean; // 모바일에서 숨기기 + }; + + // 하위 컴포넌트 (섹션 내부) + children?: PopComponentData[]; + + // 컴포넌트별 설정 + config?: Record; +} +``` + +### 데스크톱 vs POP 데이터 비교 +| 항목 | 데스크톱 (LayoutData) | POP (PopLayoutData) | +|-----|----------------------|---------------------| +| 배치 | `position: { x, y, z }` | `order: number` | +| 크기 | `size: { width, height }` (픽셀) | `size: "S" | "M" | "L"` (프리셋) | +| 컨테이너 | 없음 (자유 배치) | `layout: { direction, grid }` | +| 반응형 | 없음 | `mobileOverride` | + +--- + +## 13. 컴포넌트 재사용성 분석 + +### 최종 분류 + +| 분류 | 개수 | 컴포넌트 | +|-----|-----|---------| +| 완전 재사용 | 2 | form-field, action-button | +| 부분 재사용 | 4 | tab-panel, data-table, kpi-gauge, process-flow | +| 신규 개발 | 7 | section, card-list, status-indicator, number-pad, barcode-scanner, timer, alarm-list | + +### 핵심 컴포넌트 7개 (최소 필수) + +| 컴포넌트 | 역할 | 포함 기능 | +|---------|------|----------| +| **pop-section** | 레이아웃 컨테이너 | 카드, 그룹핑, 접기/펼치기 | +| **pop-field** | 데이터 입력/표시 | 텍스트, 숫자, 드롭다운, 바코드, 숫자패드 | +| **pop-button** | 액션 실행 | 저장, 삭제, API 호출, 화면이동 | +| **pop-list** | 데이터 목록 | 카드리스트, 선택목록, 테이블 참조 | +| **pop-indicator** | 상태/수치 표시 | KPI, 게이지, 신호등, 진행률 | +| **pop-scanner** | 바코드/QR 입력 | 카메라, 외부 스캐너 | +| **pop-numpad** | 숫자 입력 특화 | 큰 버튼, 계산기 모드 | + +--- + +## TODO + +### Phase 1: POP 디자이너 개발 (현재 진행) + +| # | 작업 | 설명 | 상태 | +|---|------|------|------| +| 1 | `PopLayoutData` 타입 정의 | order, layout, mobileOverride | 완료 | +| 2 | `PopDesigner.tsx` | 좌: 리사이즈 패널, 우: 팬 가능 캔버스 | 완료 | +| 3 | `PopPanel.tsx` | 탭 (컴포넌트/편집), POP 컴포넌트만 | 완료 | +| 4 | `PopCanvas.tsx` | 팬/줌 + 디바이스 프레임 + 블록 렌더링 | 완료 | +| 5 | `SectionGrid.tsx` | 섹션 내부 컴포넌트 배치 (react-grid-layout) | 완료 | +| 6 | 드래그앤드롭 | 팔레트→캔버스 (섹션), 팔레트→섹션 (컴포넌트) | 완료 | +| 7 | 컴포넌트 자유 배치/리사이즈 | 고정 셀 크기(40px) 기반 자동 그리드 | 완료 | +| 8 | 편집 탭 | 그리드 설정, 모바일 오버라이드 | 완료 (기본) | +| 9 | 저장/로드 | 기존 API 재사용 (saveLayoutPop) | 완료 | + +### Phase 2: POP 컴포넌트 개발 + +상세: `docs/pop/components-spec.md` + +1단계 (우선): +- [ ] pop-section (레이아웃 컨테이너) +- [ ] pop-field (범용 입력) +- [ ] pop-button (액션) + +2단계: +- [ ] pop-list (카드형 목록) +- [ ] pop-indicator (상태/KPI) +- [ ] pop-numpad (숫자패드) + +3단계: +- [ ] pop-scanner (바코드) +- [ ] pop-timer (타이머) +- [ ] pop-alarm (알람) + +### Phase 3: POP 사용자 앱 +- [ ] `/pop/login` - POP 전용 로그인 +- [ ] `/pop/dashboard` - 화면 목록 (카드형) +- [ ] `/pop/screens/[id]` - Flow 렌더러 적용 + +### 기타 +- [ ] POP 컴포넌트 레지스트리 +- [ ] POP 메뉴/폴더 관리 +- [ ] POP 인증 분리 +- [ ] 다국어 연동 + +--- + +## 핵심 파일 참조 + +### 기존 파일 (참고용) +| 파일 | 용도 | +|------|------| +| `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` | 진입점, PopDesigner 호출 위치 | +| `frontend/components/screen/ScreenDesigner.tsx` | 데스크톱 디자이너 (구조 참고) | +| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 모달 (추후 연동) | +| `frontend/lib/api/screen.ts` | API (getLayoutPop, saveLayoutPop) | +| `backend-node/src/services/screenManagementService.ts` | POP CRUD (4720~4920행) | + +### 신규 생성 예정 +| 파일 | 용도 | +|------|------| +| `frontend/components/pop/PopDesigner.tsx` | 메인 디자이너 | +| `frontend/components/pop/PopCanvas.tsx` | 캔버스 (팬/줌) | +| `frontend/components/pop/PopToolbar.tsx` | 툴바 | +| `frontend/components/pop/panels/PopPanel.tsx` | 통합 패널 | +| `frontend/components/pop/types/pop-layout.ts` | 타입 정의 | +| `frontend/components/pop/components/PopSection.tsx` | 섹션 컴포넌트 | + +--- + +--- + +## 14. 그리드 시스템 단순화 (2026-02-02 변경) + +### 기존 문제: 이중 그리드 구조 +``` +캔버스 (24열, rowHeight 20px) + └─ 섹션 (colSpan/rowSpan으로 크기 지정) + └─ 내부 그리드 (columns/rows로 컴포넌트 배치) +``` + +**문제점:** +1. 섹션 크기와 내부 그리드가 독립적이라 동기화 안됨 +2. 섹션을 늘려도 내부 그리드 점은 그대로 (비례 확대만) +3. 사용자가 두 가지 단위를 이해해야 함 + +### 변경: 단일 자동계산 그리드 + +**핵심 변경사항:** +- 그리드 점(dot) 제거 +- 고정 셀 크기(40px) 기반으로 섹션 크기에 따라 열/행 수 자동 계산 +- 컴포넌트는 react-grid-layout으로 자유롭게 드래그/리사이즈 + +**코드 (SectionGrid.tsx):** +```typescript +const CELL_SIZE = 40; +const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap))); +const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap))); +``` + +**결과:** +- 섹션 크기 변경 → 내부 셀 개수 자동 조정 +- 컴포넌트 자유 배치/리사이즈 가능 +- 직관적인 사용자 경험 + +### onLayoutChange 대신 onDragStop/onResizeStop 사용 + +**문제:** onLayoutChange는 드롭 직후에도 호출되어 섹션 크기가 자동 확대됨 + +**해결:** +```typescript +// 변경 전 + + +// 변경 후 + +``` + +상태 업데이트는 드래그/리사이즈 완료 후에만 실행 + +--- + +## POP 화면 관리 페이지 개발 (2026-02-02) + +### POP 카테고리 트리 API 구현 + +**기능:** +- POP 화면을 카테고리별로 관리하는 트리 구조 구현 +- 기존 `screen_groups` 테이블을 `hierarchy_path LIKE 'POP/%'` 조건으로 필터링하여 재사용 +- 데스크탑 화면 관리와 별도로 POP 전용 카테고리 체계 구성 + +**백엔드 API:** +- `GET /api/screen-groups/pop/groups` - POP 그룹 목록 조회 +- `POST /api/screen-groups/pop/groups` - POP 그룹 생성 +- `PUT /api/screen-groups/pop/groups/:id` - POP 그룹 수정 +- `DELETE /api/screen-groups/pop/groups/:id` - POP 그룹 삭제 +- `POST /api/screen-groups/pop/ensure-root` - POP 루트 그룹 자동 생성 + +### 트러블슈팅: API 경로 중복 문제 + +**문제:** 카테고리 생성 시 404 에러 발생 + +**원인:** +- `apiClient`의 baseURL이 이미 `http://localhost:8080/api`로 설정됨 +- API 호출 경로에 `/api/screen-groups/...`를 사용하여 최종 URL이 `/api/api/screen-groups/...`로 중복 + +**해결:** +```typescript +// 변경 전 +const response = await apiClient.post("/api/screen-groups/pop/groups", data); + +// 변경 후 +const response = await apiClient.post("/screen-groups/pop/groups", data); +``` + +### 트러블슈팅: created_by 컬럼 오류 + +**문제:** `column "created_by" of relation "screen_groups" does not exist` + +**원인:** +- 신규 작성 코드에서 `created_by` 컬럼을 사용했으나 +- 기존 `screen_groups` 테이블 스키마에는 `writer` 컬럼이 존재 + +**해결:** +```sql +-- 변경 전 +INSERT INTO screen_groups (..., created_by) VALUES (..., $9) + +-- 변경 후 +INSERT INTO screen_groups (..., writer) VALUES (..., $9) +``` + +### 트러블슈팅: is_active 컬럼 타입 불일치 + +**문제:** `value too long for type character varying(1)` 에러로 카테고리 생성 실패 + +**원인:** +- `is_active` 컬럼이 `VARCHAR(1)` 타입 +- INSERT 쿼리에서 `true`(boolean, 4자)를 직접 사용 + +**해결:** +```sql +-- 변경 전 +INSERT INTO screen_groups (..., is_active) VALUES (..., true) + +-- 변경 후 +INSERT INTO screen_groups (..., is_active) VALUES (..., 'Y') +``` + +**교훈:** +- 기존 테이블 스키마를 반드시 확인 후 쿼리 작성 +- `is_active`는 `VARCHAR(1)` 타입으로 'Y'/'N' 값 사용 +- `created_by` 대신 `writer` 컬럼명 사용 + +### 카테고리 트리 UI 개선 + +**문제:** 하위 폴더와 상위 폴더의 계층 관계가 시각적으로 불명확 + +**해결:** +1. 들여쓰기 증가: `level * 16px` → `level * 24px` +2. 트리 연결 표시: "ㄴ" 문자로 하위 항목 명시 +3. 루트 폴더 강조: 주황색 아이콘 + 볼드 텍스트, 하위는 노란색 아이콘 + +```tsx +// 하위 레벨에 연결 표시 추가 +{level > 0 && ( + +)} + +// 루트와 하위 폴더 시각적 구분 + +{group.group_name} +``` + +### 미분류 화면 이동 기능 추가 + +**기능:** 미분류 화면을 특정 카테고리로 이동하는 드롭다운 메뉴 + +**구현:** +```tsx +// 이동 드롭다운 메뉴 + + + + + + {treeData.map((g) => ( + handleMoveScreenToGroup(screen, g)}> + + {g.group_name} + + ))} + + + +// API 호출 (apiClient 사용) +const handleMoveScreenToGroup = async (screen, group) => { + await apiClient.post("/screen-groups/group-screens", { + group_id: group.id, + screen_id: screen.screenId, + screen_role: "main", + display_order: 0, + is_default: false, + }); +}; +``` + +**주의:** API 호출 시 `apiClient`를 사용해야 환경별 URL이 자동 처리됨 + +### 화면 이동 로직 수정 (복사 → 이동) + +**문제:** 화면을 다른 카테고리로 이동할 때 복사가 되어 중복 발생 + +**원인:** 기존 그룹 연결 삭제 없이 새 그룹에만 연결 추가 + +**해결:** 2단계 처리 - 기존 연결 삭제 후 새 연결 추가 + +```tsx +const handleMoveScreenToGroup = async (screen: ScreenDefinition, targetGroup: PopScreenGroup) => { + // 1. 기존 연결 찾기 및 삭제 + for (const g of groups) { + const existingLink = g.screens?.find((s) => s.screen_id === screen.screenId); + if (existingLink) { + await apiClient.delete(`/screen-groups/group-screens/${existingLink.id}`); + break; + } + } + + // 2. 새 그룹에 연결 추가 + await apiClient.post("/screen-groups/group-screens", { + group_id: targetGroup.id, + screen_id: screen.screenId, + screen_role: "main", + display_order: 0, + is_default: false, + }); + + loadGroups(); // 목록 새로고침 +}; +``` + +### 화면/카테고리 메뉴 UI 개선 + +**변경 사항:** +1. 화면에 "..." 더보기 메뉴 추가 (폴더와 동일한 스타일) +2. 메뉴 항목: 설계, 위로 이동, 아래로 이동, 다른 카테고리로 이동, 그룹에서 제거 +3. 폴더 메뉴에도 위로/아래로 이동 추가 + +**순서 변경 구현:** +```tsx +// 그룹 순서 변경 (display_order 교환) +const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => { + const siblingGroups = groups + .filter((g) => g.parent_id === targetGroup.parent_id) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + + const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); + if (currentIndex <= 0) return; + + const prevGroup = siblingGroups[currentIndex - 1]; + + await Promise.all([ + apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { display_order: prevGroup.display_order }), + apiClient.put(`/screen-groups/groups/${prevGroup.id}`, { display_order: targetGroup.display_order }), + ]); + + loadGroups(); +}; + +// 화면 순서 변경 (screen_group_screens의 display_order 교환) +const handleMoveScreenUp = async (screen: ScreenDefinition, groupId: number) => { + const targetGroup = groups.find((g) => g.id === groupId); + const sortedScreens = [...targetGroup.screens].sort((a, b) => a.display_order - b.display_order); + const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); + + if (currentIndex <= 0) return; + + const currentLink = sortedScreens[currentIndex]; + const prevLink = sortedScreens[currentIndex - 1]; + + await Promise.all([ + apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { display_order: prevLink.display_order }), + apiClient.put(`/screen-groups/group-screens/${prevLink.id}`, { display_order: currentLink.display_order }), + ]); + + loadGroups(); +}; +``` + +### 카테고리 이동 모달 (서브메뉴 → 모달 방식) + +**문제:** 카테고리가 많아지면 서브메뉴 방식은 관리 어려움 + +**해결:** 검색 기능이 있는 모달로 변경 + +**구현:** +```tsx +// 이동 모달 상태 +const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); +const [movingScreen, setMovingScreen] = useState(null); +const [movingFromGroupId, setMovingFromGroupId] = useState(null); +const [moveSearchTerm, setMoveSearchTerm] = useState(""); + +// 필터링된 그룹 목록 +const filteredMoveGroups = useMemo(() => { + if (!moveSearchTerm) return flattenedGroups; + const searchLower = moveSearchTerm.toLowerCase(); + return flattenedGroups.filter((g) => + (g._displayName || g.group_name).toLowerCase().includes(searchLower) + ); +}, [flattenedGroups, moveSearchTerm]); + +// 모달 UI 특징: +// 1. 검색 입력창 (Search 아이콘 포함) +// 2. 트리 구조 표시 (depth에 따라 들여쓰기) +// 3. 현재 소속 그룹 표시 및 선택 불가 처리 +// 4. ScrollArea로 긴 목록 스크롤 지원 +``` + +**모달 구조:** +``` +┌─────────────────────────────┐ +│ 카테고리로 이동 │ +│ "화면명" 화면을 이동할... │ +├─────────────────────────────┤ +│ 🔍 카테고리 검색... │ +├─────────────────────────────┤ +│ 📁 POP 화면 │ +│ 📁 홈 관리 │ +│ 📁 출고관리 │ +│ 📁 수주관리 │ +│ 📁 생산 관리 (현재) │ +├─────────────────────────────┤ +│ [ 취소 ] │ +└─────────────────────────────┘ +``` + +--- + +## 14. 비율 기반 그리드 시스템 (2026-02-03) + +### 문제 발견 + +POP 디자이너에서 섹션을 크게 설정해도 뷰어에서 매우 얇게(약 20px) 렌더링되는 문제 발생. + +### 근본 원인 분석 + +1. **기존 구조**: `canvasGrid.rowHeight = 20` (고정 픽셀) +2. **react-grid-layout 동작**: 작은 리사이즈 → `rowSpan: 1`로 반올림 → DB 저장 +3. **뷰어 렌더링**: `gridAutoRows: 20px` → 섹션 높이 = 20px (매우 얇음) +4. **비교**: 가로(columns)는 `1fr` 비율 기반으로 잘 작동 + +### 해결책: 비율 기반 행 시스템 + +| 구분 | 이전 | 이후 | +|------|------|------| +| 타입 | `rowHeight: number` (px) | `rows: number` (개수) | +| 기본값 | `rowHeight: 20` | `rows: 24` | +| 뷰어 CSS | `gridAutoRows: 20px` | `gridTemplateRows: repeat(24, 1fr)` | +| 디자이너 계산 | 고정 20px | `resolution.height / 24` | + +### 수정된 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `types/pop-layout.ts` | `PopCanvasGrid.rowHeight` → `rows`, `DEFAULT_CANVAS_GRID.rows = 24` | +| `renderers/PopLayoutRenderer.tsx` | `gridAutoRows` → `gridTemplateRows: repeat(rows, 1fr)` | +| `PopCanvas.tsx` | `rowHeight = Math.floor(resolution.height / canvasGrid.rows)` | + +### 모드별 행 높이 계산 + +| 모드 | 해상도 높이 | 행 높이 (24행 기준) | +|------|-------------|---------------------| +| tablet_landscape | 768px | 32px | +| tablet_portrait | 1024px | 42.7px | +| mobile_landscape | 375px | 15.6px | +| mobile_portrait | 667px | 27.8px | + +### 기존 데이터 호환성 + +- 기존 `rowHeight: 20` 데이터는 `rows || 24` fallback으로 처리 +- 기존 `rowSpan: 1` 데이터는 1/24 = 4.17%로 렌더링 (여전히 작음) +- **권장**: 디자이너에서 섹션 재조정 후 재저장 + +--- + +## 15. 화면 삭제 기능 추가 (2026-02-03) + +### 추가된 기능 + +POP 카테고리 트리에서 화면 자체를 삭제하는 기능 추가. + +### UI 변경 + +| 위치 | 메뉴 항목 | 동작 | +|------|----------|------| +| 그룹 내 화면 드롭다운 | "화면 삭제" | 휴지통으로 이동 | +| 미분류 화면 드롭다운 | "화면 삭제" | 휴지통으로 이동 | + +### 삭제 흐름 + +``` +1. 드롭다운 메뉴에서 "화면 삭제" 클릭 +2. 확인 다이얼로그 표시 ("삭제된 화면은 휴지통으로 이동됩니다") +3. 확인 → DELETE /api/screen-management/screens/:id +4. 화면 is_deleted = 'Y'로 변경 (soft delete) +5. 그룹 목록 새로고침 +``` + +### 완전 삭제 vs 휴지통 이동 + +| API | 동작 | 복원 가능 | +|-----|------|----------| +| `DELETE /screens/:id` | 휴지통으로 이동 (is_deleted='Y') | O | +| `DELETE /screens/:id/permanent` | DB에서 완전 삭제 | X | + +### 수정된 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `PopCategoryTree.tsx` | `handleDeleteScreen`, `confirmDeleteScreen` 함수 추가 | +| `PopCategoryTree.tsx` | `isScreenDeleteDialogOpen`, `deletingScreen` 상태 추가 | +| `PopCategoryTree.tsx` | TreeNode에 `onDeleteScreen` prop 추가 | +| `PopCategoryTree.tsx` | 화면 삭제 확인 AlertDialog 추가 | + +--- + +## 16. 멀티테넌시 이슈 해결 (2026-02-03) + +### 문제 + +화면 그룹에서 제거 시 404 에러 발생. + +### 원인 + +- DB 데이터: `company_code = "*"` (최고 관리자 전용) +- 현재 세션: `company_code = "COMPANY_7"` +- 컨트롤러 WHERE 조건: `id = $1 AND company_code = $2` → 0 rows + +### 해결 + +세션 불일치 문제로 DB에서 직접 삭제 처리. + +### 교훈 + +- 최고 관리자로 생성한 데이터는 일반 회사 사용자가 삭제 불가 +- 로그인 후 토큰 갱신 필요 시 브라우저 완전 새로고침 + +--- + +## 트러블슈팅 + +### Export default doesn't exist in target module + +**문제:** `import apiClient from "@/lib/api/client"` 에러 + +**원인:** `apiClient`가 named export로 정의됨 + +**해결:** `import { apiClient } from "@/lib/api/client"` 사용 + +### 섹션이 매우 얇게 렌더링되는 문제 + +**문제:** 디자이너에서 크게 설정한 섹션이 뷰어에서 20px 높이로 표시 + +**원인:** `canvasGrid.rowHeight = 20` 고정값 + react-grid-layout의 rowSpan 반올림 + +**해결:** 비율 기반 rows 시스템으로 변경 (섹션 14 참조) + +### 화면 삭제 404 에러 + +**문제:** 화면 그룹에서 제거 시 404 에러 + +**원인:** company_code 불일치 (세션 vs DB) + +**해결:** 브라우저 새로고침으로 토큰 갱신 또는 DB 직접 처리 + +### 관련 파일 + +| 파일 | 역할 | +|------|------| +| `frontend/components/pop/management/PopCategoryTree.tsx` | POP 카테고리 트리 (전체 UI) | +| `frontend/lib/api/popScreenGroup.ts` | POP 그룹 API 클라이언트 | +| `backend-node/src/controllers/screenGroupController.ts` | 그룹 CRUD 컨트롤러 | +| `backend-node/src/routes/screenGroupRoutes.ts` | 그룹 API 라우트 | +| `frontend/components/pop/designer/types/pop-layout.ts` | POP 레이아웃 타입 정의 | +| `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx` | CSS Grid 기반 렌더러 | +| `frontend/components/pop/designer/PopCanvas.tsx` | react-grid-layout 디자이너 캔버스 | + +--- + +*최종 업데이트: 2026-02-03* diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 43b698d2..ae55e3c4 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -18,6 +18,7 @@ "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "html-to-docx": "^1.8.0", @@ -1044,7 +1045,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2372,7 +2372,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3476,7 +3475,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3713,7 +3711,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3931,7 +3928,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4458,7 +4454,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5669,7 +5664,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5989,6 +5983,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-async-errors": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz", + "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==", + "license": "ISC", + "peerDependencies": { + "express": "^4.16.2" + } + }, "node_modules/express-rate-limit": { "version": "7.5.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", @@ -7432,7 +7435,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8402,6 +8404,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9290,7 +9293,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10141,6 +10143,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -10949,7 +10952,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11055,7 +11057,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend-node/package.json b/backend-node/package.json index b1bfa319..310ab401 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -32,6 +32,7 @@ "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "html-to-docx": "^1.8.0", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 07f7fae8..f058360b 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -1,4 +1,5 @@ import "dotenv/config"; +import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달 import express from "express"; import cors from "cors"; import helmet from "helmet"; diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index a89e50d1..7b3b1033 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -19,8 +19,6 @@ export async function getAdminMenus( res: Response ): Promise { try { - logger.info("=== 관리자 메뉴 목록 조회 시작 ==="); - // 현재 로그인한 사용자의 정보 가져오기 const userId = req.user?.userId; const userCompanyCode = req.user?.companyCode || "ILSHIN"; @@ -29,13 +27,6 @@ export async function getAdminMenus( const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가 const includeInactive = req.query.includeInactive === "true"; // includeInactive 파라미터 추가 - logger.info(`사용자 ID: ${userId}`); - logger.info(`사용자 회사 코드: ${userCompanyCode}`); - logger.info(`사용자 유형: ${userType}`); - logger.info(`사용자 로케일: ${userLang}`); - logger.info(`메뉴 타입: ${menuType || "전체"}`); - logger.info(`비활성 메뉴 포함: ${includeInactive}`); - const paramMap = { userId, userCompanyCode, @@ -47,13 +38,6 @@ export async function getAdminMenus( const menuList = await AdminService.getAdminMenuList(paramMap); - logger.info( - `관리자 메뉴 조회 결과: ${menuList.length}개 (타입: ${menuType || "전체"}, 회사: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", menuList[0]); - } - const response: ApiResponse = { success: true, message: "관리자 메뉴 목록 조회 성공", @@ -85,19 +69,12 @@ export async function getUserMenus( res: Response ): Promise { try { - logger.info("=== 사용자 메뉴 목록 조회 시작 ==="); - // 현재 로그인한 사용자의 정보 가져오기 const userId = req.user?.userId; const userCompanyCode = req.user?.companyCode || "ILSHIN"; const userType = req.user?.userType; const userLang = (req.query.userLang as string) || "ko"; - logger.info(`사용자 ID: ${userId}`); - logger.info(`사용자 회사 코드: ${userCompanyCode}`); - logger.info(`사용자 유형: ${userType}`); - logger.info(`사용자 로케일: ${userLang}`); - const paramMap = { userId, userCompanyCode, @@ -107,13 +84,6 @@ export async function getUserMenus( const menuList = await AdminService.getUserMenuList(paramMap); - logger.info( - `사용자 메뉴 조회 결과: ${menuList.length}개 (회사: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", menuList[0]); - } - const response: ApiResponse = { success: true, message: "사용자 메뉴 목록 조회 성공", @@ -473,7 +443,7 @@ export const getUserLocale = async ( res: Response ): Promise => { try { - logger.info("사용자 로케일 조회 요청", { + logger.debug("사용자 로케일 조회 요청", { query: req.query, user: req.user, }); @@ -496,7 +466,7 @@ export const getUserLocale = async ( if (userInfo?.locale) { userLocale = userInfo.locale; - logger.info("데이터베이스에서 사용자 로케일 조회 성공", { + logger.debug("데이터베이스에서 사용자 로케일 조회 성공", { userId: req.user.userId, locale: userLocale, }); @@ -513,7 +483,7 @@ export const getUserLocale = async ( message: "사용자 로케일 조회 성공", }; - logger.info("사용자 로케일 조회 성공", { + logger.debug("사용자 로케일 조회 성공", { userLocale, userId: req.user.userId, fromDatabase: !!userInfo?.locale, @@ -618,7 +588,7 @@ export const getCompanyList = async ( res: Response ) => { try { - logger.info("회사 목록 조회 요청", { + logger.debug("회사 목록 조회 요청", { query: req.query, user: req.user, }); @@ -658,12 +628,8 @@ export const getCompanyList = async ( message: "회사 목록 조회 성공", }; - logger.info("회사 목록 조회 성공", { + logger.debug("회사 목록 조회 성공", { totalCount: companies.length, - companies: companies.map((c) => ({ - code: c.company_code, - name: c.company_name, - })), }); res.status(200).json(response); @@ -1443,13 +1409,7 @@ async function collectAllChildMenuIds(parentObjid: number): Promise { * 메뉴 및 관련 데이터 정리 헬퍼 함수 */ async function cleanupMenuRelatedData(menuObjid: number): Promise { - // 1. category_column_mapping에서 menu_objid를 NULL로 설정 - await query( - `UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`, - [menuObjid] - ); - - // 2. code_category에서 menu_objid를 NULL로 설정 + // 1. code_category에서 menu_objid를 NULL로 설정 await query( `UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`, [menuObjid] @@ -1870,7 +1830,7 @@ export async function getCompanyListFromDB( res: Response ): Promise { try { - logger.info("회사 목록 조회 요청 (Raw Query)", { user: req.user }); + logger.debug("회사 목록 조회 요청 (Raw Query)", { user: req.user }); // Raw Query로 회사 목록 조회 const companies = await query( @@ -1890,7 +1850,7 @@ export async function getCompanyListFromDB( ORDER BY regdate DESC` ); - logger.info("회사 목록 조회 성공 (Raw Query)", { count: companies.length }); + logger.debug("회사 목록 조회 성공 (Raw Query)", { count: companies.length }); const response: ApiResponse = { success: true, diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 1903d397..ebf3e8f5 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -17,9 +17,7 @@ export class AuthController { const { userId, password }: LoginRequest = req.body; const remoteAddr = req.ip || req.connection.remoteAddress || "unknown"; - logger.info(`=== API 로그인 호출됨 ===`); - logger.info(`userId: ${userId}`); - logger.info(`password: ${password ? "***" : "null"}`); + logger.debug(`로그인 요청: ${userId}`); // 입력값 검증 if (!userId || !password) { @@ -50,14 +48,7 @@ export class AuthController { companyCode: loginResult.userInfo.companyCode || "ILSHIN", }; - logger.info(`=== API 로그인 사용자 정보 디버그 ===`); - logger.info( - `PersonBean companyCode: ${loginResult.userInfo.companyCode}` - ); - logger.info(`반환할 사용자 정보:`); - logger.info(`- userId: ${userInfo.userId}`); - logger.info(`- userName: ${userInfo.userName}`); - logger.info(`- companyCode: ${userInfo.companyCode}`); + logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`); // 사용자의 첫 번째 접근 가능한 메뉴 조회 let firstMenuPath: string | null = null; @@ -71,7 +62,7 @@ export class AuthController { }; const menuList = await AdminService.getUserMenuList(paramMap); - logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); + logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); // 접근 가능한 첫 번째 메뉴 찾기 // 조건: @@ -87,16 +78,9 @@ export class AuthController { if (firstMenu) { firstMenuPath = firstMenu.menu_url || firstMenu.url; - logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, { - name: firstMenu.menu_name_kor || firstMenu.translated_name, - url: firstMenuPath, - level: firstMenu.lev || firstMenu.level, - seq: firstMenu.seq, - }); + logger.debug(`첫 번째 메뉴: ${firstMenuPath}`); } else { - logger.info( - "⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다." - ); + logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동"); } } catch (menuError) { logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError); diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index f4f89d25..15e05473 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -193,10 +193,11 @@ export class EntityJoinController { async getEntityJoinConfigs(req: Request, res: Response): Promise { try { const { tableName } = req.params; + const companyCode = (req as any).user?.companyCode; - logger.info(`Entity 조인 설정 조회: ${tableName}`); + logger.info(`Entity 조인 설정 조회: ${tableName} (companyCode: ${companyCode})`); - const joinConfigs = await entityJoinService.detectEntityJoins(tableName); + const joinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode); res.status(200).json({ success: true, @@ -224,11 +225,12 @@ export class EntityJoinController { async getReferenceTableColumns(req: Request, res: Response): Promise { try { const { tableName } = req.params; + const companyCode = (req as any).user?.companyCode; - logger.info(`참조 테이블 컬럼 조회: ${tableName}`); + logger.info(`참조 테이블 컬럼 조회: ${tableName} (companyCode: ${companyCode})`); const columns = - await tableManagementService.getReferenceTableColumns(tableName); + await tableManagementService.getReferenceTableColumns(tableName, companyCode); res.status(200).json({ success: true, @@ -408,11 +410,12 @@ export class EntityJoinController { async getEntityJoinColumns(req: Request, res: Response): Promise { try { const { tableName } = req.params; + const companyCode = (req as any).user?.companyCode; - logger.info(`Entity 조인 컬럼 조회: ${tableName}`); + logger.info(`Entity 조인 컬럼 조회: ${tableName} (companyCode: ${companyCode})`); // 1. 현재 테이블의 Entity 조인 설정 조회 - const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName); + const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode); // 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외 // 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨 @@ -439,7 +442,7 @@ export class EntityJoinController { try { const columns = await tableManagementService.getReferenceTableColumns( - config.referenceTable + config.referenceTable, companyCode ); // 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음) diff --git a/backend-node/src/controllers/entityReferenceController.ts b/backend-node/src/controllers/entityReferenceController.ts index 18f0036f..3fa9d1f6 100644 --- a/backend-node/src/controllers/entityReferenceController.ts +++ b/backend-node/src/controllers/entityReferenceController.ts @@ -30,10 +30,13 @@ export class EntityReferenceController { try { const { tableName, columnName } = req.params; const { limit = 100, search } = req.query; + // 멀티테넌시: 인증된 사용자의 회사 코드 + const companyCode = (req as any).user?.companyCode; logger.info(`엔티티 참조 데이터 조회 요청: ${tableName}.${columnName}`, { limit, search, + companyCode, }); // 컬럼 정보 조회 (table_type_columns에서) @@ -89,16 +92,34 @@ export class EntityReferenceController { }); } - // 동적 쿼리로 참조 데이터 조회 - let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`; + // 참조 테이블에 company_code 컬럼이 있는지 확인 + const hasCompanyCode = await queryOne( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code' AND table_schema = 'public'`, + [referenceTable] + ); + + // 동적 쿼리로 참조 데이터 조회 (멀티테넌시 필터 적용) + const whereConditions: string[] = []; const queryParams: any[] = []; + // 멀티테넌시: company_code 필터링 (참조 테이블에 company_code가 있는 경우) + if (hasCompanyCode && companyCode && companyCode !== "*") { + queryParams.push(companyCode); + whereConditions.push(`company_code = $${queryParams.length}`); + logger.info(`멀티테넌시 필터 적용: company_code = ${companyCode}`, { referenceTable }); + } + // 검색 조건 추가 if (search) { - sqlQuery += ` WHERE ${displayColumn} ILIKE $1`; queryParams.push(`%${search}%`); + whereConditions.push(`${displayColumn} ILIKE $${queryParams.length}`); } + let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`; + if (whereConditions.length > 0) { + sqlQuery += ` WHERE ${whereConditions.join(" AND ")}`; + } sqlQuery += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`; queryParams.push(Number(limit)); @@ -107,6 +128,7 @@ export class EntityReferenceController { referenceTable, referenceColumn, displayColumn, + companyCode, }); const referenceData = await query(sqlQuery, queryParams); @@ -119,7 +141,7 @@ export class EntityReferenceController { }) ); - logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`); + logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`, { companyCode }); return res.json({ success: true, @@ -149,13 +171,16 @@ export class EntityReferenceController { try { const { codeCategory } = req.params; const { limit = 100, search } = req.query; + // 멀티테넌시: 인증된 사용자의 회사 코드 + const companyCode = (req as any).user?.companyCode; logger.info(`공통 코드 데이터 조회 요청: ${codeCategory}`, { limit, search, + companyCode, }); - // code_info 테이블에서 코드 데이터 조회 + // code_info 테이블에서 코드 데이터 조회 (멀티테넌시 필터 적용) const queryParams: any[] = [codeCategory, 'Y']; let sqlQuery = ` SELECT code_value, code_name @@ -163,9 +188,16 @@ export class EntityReferenceController { WHERE code_category = $1 AND is_active = $2 `; + // 멀티테넌시: company_code 필터링 + if (companyCode && companyCode !== "*") { + queryParams.push(companyCode); + sqlQuery += ` AND company_code = $${queryParams.length}`; + logger.info(`공통 코드 멀티테넌시 필터 적용: company_code = ${companyCode}`); + } + if (search) { - sqlQuery += ` AND code_name ILIKE $3`; queryParams.push(`%${search}%`); + sqlQuery += ` AND code_name ILIKE $${queryParams.length}`; } sqlQuery += ` ORDER BY code_name ASC LIMIT $${queryParams.length + 1}`; @@ -174,12 +206,12 @@ export class EntityReferenceController { const codeData = await query(sqlQuery, queryParams); // 옵션 형태로 변환 - const options: EntityReferenceOption[] = codeData.map((code) => ({ + const options: EntityReferenceOption[] = codeData.map((code: any) => ({ value: code.code_value, label: code.code_name, })); - logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`); + logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`, { companyCode }); return res.json({ success: true, diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 2e850a03..bbc42568 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -395,11 +395,35 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { ? `WHERE ${whereConditions.join(" AND ")}` : ""; + // 정렬 컬럼 결정: id가 있으면 id, 없으면 첫 번째 컬럼 사용 + let orderByColumn = "1"; // 기본: 첫 번째 컬럼 + if (existingColumns.has("id")) { + orderByColumn = '"id"'; + } else { + // PK 컬럼 조회 시도 + try { + const pkResult = await pool.query( + `SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass AND i.indisprimary + ORDER BY array_position(i.indkey, a.attnum) + LIMIT 1`, + [tableName] + ); + if (pkResult.rows.length > 0) { + orderByColumn = `"${pkResult.rows[0].attname}"`; + } + } catch { + // PK 조회 실패 시 기본값 유지 + } + } + // 쿼리 실행 (pool은 위에서 이미 선언됨) const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`; const dataQuery = ` SELECT * FROM ${tableName} ${whereClause} - ORDER BY id DESC + ORDER BY ${orderByColumn} DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index b617b262..4a6a1e03 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -46,17 +46,7 @@ export class FlowController { const userId = (req as any).user?.userId || "system"; const userCompanyCode = (req as any).user?.companyCode; - console.log("🔍 createFlowDefinition called with:", { - name, - description, - tableName, - dbSourceType, - dbConnectionId, - restApiConnectionId, - restApiEndpoint, - restApiJsonPath, - userCompanyCode, - }); + if (!name) { res.status(400).json({ @@ -121,13 +111,7 @@ export class FlowController { const user = (req as any).user; const userCompanyCode = user?.companyCode; - console.log("🎯 getFlowDefinitions called:", { - userId: user?.userId, - userCompanyCode: userCompanyCode, - userType: user?.userType, - tableName, - isActive, - }); + const flows = await this.flowDefinitionService.findAll( tableName as string | undefined, @@ -135,7 +119,7 @@ export class FlowController { userCompanyCode ); - console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`); + res.json({ success: true, @@ -583,14 +567,11 @@ export class FlowController { getStepColumnLabels = async (req: Request, res: Response): Promise => { try { const { flowId, stepId } = req.params; - console.log("🏷️ [FlowController] 컬럼 라벨 조회 요청:", { - flowId, - stepId, - }); + const step = await this.flowStepService.findById(parseInt(stepId)); if (!step) { - console.warn("⚠️ [FlowController] 스텝을 찾을 수 없음:", stepId); + res.status(404).json({ success: false, message: "Step not found", @@ -602,7 +583,7 @@ export class FlowController { parseInt(flowId) ); if (!flowDef) { - console.warn("⚠️ [FlowController] 플로우를 찾을 수 없음:", flowId); + res.status(404).json({ success: false, message: "Flow definition not found", @@ -612,14 +593,10 @@ export class FlowController { // 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블) const tableName = step.tableName || flowDef.tableName; - console.log("📋 [FlowController] 테이블명 결정:", { - stepTableName: step.tableName, - flowTableName: flowDef.tableName, - selectedTableName: tableName, - }); + if (!tableName) { - console.warn("⚠️ [FlowController] 테이블명이 지정되지 않음"); + res.json({ success: true, data: {}, @@ -639,14 +616,7 @@ export class FlowController { [tableName] ); - console.log(`✅ [FlowController] table_type_columns 조회 완료:`, { - tableName, - rowCount: labelRows.length, - labels: labelRows.map((r) => ({ - col: r.column_name, - label: r.column_label, - })), - }); + // { columnName: label } 형태의 객체로 변환 const labels: Record = {}; @@ -656,7 +626,7 @@ export class FlowController { } }); - console.log("📦 [FlowController] 반환할 라벨 객체:", labels); + res.json({ success: true, diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 32ce60c3..0e97e2e2 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1488,13 +1488,13 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, - jsonb_array_elements_text( + sd.table_name::text as main_table, + jsonb_array_elements( COALESCE( sl.properties->'componentConfig'->'columns', '[]'::jsonb ) - )::jsonb->>'columnName' as column_name + )->>'columnName' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) @@ -1507,7 +1507,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, COALESCE( sl.properties->'componentConfig'->>'bindField', sl.properties->>'bindField', @@ -1530,7 +1530,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'valueField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1543,7 +1543,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'parentFieldId' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1556,7 +1556,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'cascadingParentField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1569,7 +1569,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'controlField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1750,7 +1750,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons sd.table_name as main_table, sl.properties->>'componentType' as component_type, sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation, - sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table, + sl.properties->'componentConfig'->'rightPanel'->>'tableName' as right_panel_table, sl.properties->'componentConfig'->'rightPanel'->'columns' as right_panel_columns FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -2563,3 +2563,280 @@ export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res } }; +// ============================================================ +// POP 전용 화면 그룹 API +// hierarchy_path LIKE 'POP/%' 필터로 POP 카테고리만 조회 +// ============================================================ + +// POP 화면 그룹 목록 조회 (카테고리 트리용) +export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { searchTerm } = req.query; + + let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'"; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터링 (멀티테넌시) + if (companyCode !== "*") { + whereClause += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + // 검색어 필터링 + if (searchTerm) { + whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`; + params.push(`%${searchTerm}%`); + paramIndex++; + } + + // POP 그룹 조회 (계층 구조를 위해 전체 조회) + const dataQuery = ` + SELECT + sg.*, + (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, + (SELECT json_agg( + json_build_object( + 'id', sgs.id, + 'screen_id', sgs.screen_id, + 'screen_name', sd.screen_name, + 'screen_role', sgs.screen_role, + 'display_order', sgs.display_order, + 'is_default', sgs.is_default, + 'table_name', sd.table_name + ) ORDER BY sgs.display_order + ) FROM screen_group_screens sgs + LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id + WHERE sgs.group_id = sg.id + ) as screens + FROM screen_groups sg + ${whereClause} + ORDER BY sg.display_order ASC, sg.hierarchy_path ASC + `; + + const result = await pool.query(dataQuery, params); + + logger.info("POP 화면 그룹 목록 조회", { companyCode, count: result.rows.length }); + + res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("POP 화면 그룹 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 목록 조회에 실패했습니다.", error: error.message }); + } +}; + +// POP 화면 그룹 생성 (hierarchy_path 자동 설정) +export const createPopScreenGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const userCompanyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || ""; + const { group_name, group_code, description, icon, display_order, parent_group_id, target_company_code } = req.body; + + if (!group_name || !group_code) { + return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." }); + } + + // 회사 코드 결정 + const effectiveCompanyCode = target_company_code || userCompanyCode; + if (userCompanyCode !== "*" && effectiveCompanyCode !== userCompanyCode) { + return res.status(403).json({ success: false, message: "다른 회사의 그룹을 생성할 권한이 없습니다." }); + } + + // hierarchy_path 계산 - POP 하위로 설정 + let hierarchyPath = "POP"; + if (parent_group_id) { + // 부모 그룹의 hierarchy_path 조회 + const parentResult = await pool.query( + `SELECT hierarchy_path FROM screen_groups WHERE id = $1`, + [parent_group_id] + ); + if (parentResult.rows.length > 0) { + hierarchyPath = `${parentResult.rows[0].hierarchy_path}/${group_code}`; + } + } else { + // 최상위 POP 카테고리 + hierarchyPath = `POP/${group_code}`; + } + + // 중복 체크 + const duplicateCheck = await pool.query( + `SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`, + [group_code, effectiveCompanyCode] + ); + if (duplicateCheck.rows.length > 0) { + return res.status(400).json({ success: false, message: "동일한 그룹코드가 이미 존재합니다." }); + } + + // 그룹 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤) + const insertQuery = ` + INSERT INTO screen_groups ( + group_name, group_code, description, icon, display_order, + parent_group_id, hierarchy_path, company_code, writer, is_active + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y') + RETURNING * + `; + const insertParams = [ + group_name, + group_code, + description || null, + icon || null, + display_order || 0, + parent_group_id || null, + hierarchyPath, + effectiveCompanyCode, + userId, + ]; + + const result = await pool.query(insertQuery, insertParams); + + logger.info("POP 화면 그룹 생성", { groupId: result.rows[0].id, groupCode: group_code, companyCode: effectiveCompanyCode }); + + res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 생성되었습니다." }); + } catch (error: any) { + logger.error("POP 화면 그룹 생성 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 생성에 실패했습니다.", error: error.message }); + } +}; + +// POP 화면 그룹 수정 +export const updatePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { group_name, description, icon, display_order, is_active } = req.body; + + // 기존 그룹 확인 + let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`; + const checkParams: any[] = [id]; + if (companyCode !== "*") { + checkQuery += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await pool.query(checkQuery, checkParams); + if (existing.rows.length === 0) { + return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); + } + + // POP 그룹인지 확인 + if (!existing.rows[0].hierarchy_path?.startsWith("POP")) { + return res.status(400).json({ success: false, message: "POP 그룹만 수정할 수 있습니다." }); + } + + // 업데이트 + const updateQuery = ` + UPDATE screen_groups + SET group_name = COALESCE($1, group_name), + description = COALESCE($2, description), + icon = COALESCE($3, icon), + display_order = COALESCE($4, display_order), + is_active = COALESCE($5, is_active), + updated_date = NOW() + WHERE id = $6 + RETURNING * + `; + const updateParams = [group_name, description, icon, display_order, is_active, id]; + const result = await pool.query(updateQuery, updateParams); + + logger.info("POP 화면 그룹 수정", { groupId: id, companyCode }); + + res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 수정되었습니다." }); + } catch (error: any) { + logger.error("POP 화면 그룹 수정 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 수정에 실패했습니다.", error: error.message }); + } +}; + +// POP 화면 그룹 삭제 +export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 기존 그룹 확인 + let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`; + const checkParams: any[] = [id]; + if (companyCode !== "*") { + checkQuery += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await pool.query(checkQuery, checkParams); + if (existing.rows.length === 0) { + return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); + } + + // POP 그룹인지 확인 + if (!existing.rows[0].hierarchy_path?.startsWith("POP")) { + return res.status(400).json({ success: false, message: "POP 그룹만 삭제할 수 있습니다." }); + } + + // 하위 그룹 확인 + const childCheck = await pool.query( + `SELECT COUNT(*) as count FROM screen_groups WHERE parent_group_id = $1`, + [id] + ); + if (parseInt(childCheck.rows[0].count) > 0) { + return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." }); + } + + // 연결된 화면 확인 + const screenCheck = await pool.query( + `SELECT COUNT(*) as count FROM screen_group_screens WHERE group_id = $1`, + [id] + ); + if (parseInt(screenCheck.rows[0].count) > 0) { + return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." }); + } + + // 삭제 + await pool.query(`DELETE FROM screen_groups WHERE id = $1`, [id]); + + logger.info("POP 화면 그룹 삭제", { groupId: id, companyCode }); + + res.json({ success: true, message: "POP 화면 그룹이 삭제되었습니다." }); + } catch (error: any) { + logger.error("POP 화면 그룹 삭제 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 삭제에 실패했습니다.", error: error.message }); + } +}; + +// POP 루트 그룹 확보 (없으면 자동 생성) +export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + + // POP 루트 그룹 확인 + const checkQuery = ` + SELECT * FROM screen_groups + WHERE hierarchy_path = 'POP' AND company_code = $1 + `; + const existing = await pool.query(checkQuery, [companyCode]); + + if (existing.rows.length > 0) { + return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." }); + } + + // 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤) + const insertQuery = ` + INSERT INTO screen_groups ( + group_name, group_code, hierarchy_path, company_code, + description, display_order, is_active, writer + ) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2) + RETURNING * + `; + const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]); + + logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode }); + + res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." }); + } catch (error: any) { + logger.error("POP 루트 그룹 확보 실패:", error); + res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message }); + } +}; \ No newline at end of file diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 83dd2b32..3e624c40 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -732,6 +732,217 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) => } }; +// 레이어 목록 조회 +export const getScreenLayers = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const layers = await screenManagementService.getScreenLayers(parseInt(screenId), companyCode); + res.json({ success: true, data: layers }); + } catch (error) { + console.error("레이어 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "레이어 목록 조회에 실패했습니다." }); + } +}; + +// 특정 레이어 레이아웃 조회 +export const getLayerLayout = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId, layerId } = req.params; + const { companyCode } = req.user as any; + const layout = await screenManagementService.getLayerLayout(parseInt(screenId), parseInt(layerId), companyCode); + res.json({ success: true, data: layout }); + } catch (error) { + console.error("레이어 레이아웃 조회 실패:", error); + res.status(500).json({ success: false, message: "레이어 레이아웃 조회에 실패했습니다." }); + } +}; + +// 레이어 삭제 +export const deleteLayer = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId, layerId } = req.params; + const { companyCode } = req.user as any; + await screenManagementService.deleteLayer(parseInt(screenId), parseInt(layerId), companyCode); + res.json({ success: true, message: "레이어가 삭제되었습니다." }); + } catch (error: any) { + console.error("레이어 삭제 실패:", error); + res.status(400).json({ success: false, message: error.message || "레이어 삭제에 실패했습니다." }); + } +}; + +// 레이어 조건 설정 업데이트 +export const updateLayerCondition = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId, layerId } = req.params; + const { companyCode } = req.user as any; + const { conditionConfig, layerName } = req.body; + await screenManagementService.updateLayerCondition( + parseInt(screenId), parseInt(layerId), companyCode, conditionConfig, layerName + ); + res.json({ success: true, message: "레이어 조건이 업데이트되었습니다." }); + } catch (error) { + console.error("레이어 조건 업데이트 실패:", error); + res.status(500).json({ success: false, message: "레이어 조건 업데이트에 실패했습니다." }); + } +}; + +// ======================================== +// 조건부 영역(Zone) 관리 +// ======================================== + +// Zone 목록 조회 +export const getScreenZones = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const zones = await screenManagementService.getScreenZones(parseInt(screenId), companyCode); + res.json({ success: true, data: zones }); + } catch (error) { + console.error("Zone 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "Zone 목록 조회에 실패했습니다." }); + } +}; + +// Zone 생성 +export const createZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const zone = await screenManagementService.createZone(parseInt(screenId), companyCode, req.body); + res.json({ success: true, data: zone }); + } catch (error) { + console.error("Zone 생성 실패:", error); + res.status(500).json({ success: false, message: "Zone 생성에 실패했습니다." }); + } +}; + +// Zone 업데이트 (위치/크기/트리거) +export const updateZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { zoneId } = req.params; + const { companyCode } = req.user as any; + await screenManagementService.updateZone(parseInt(zoneId), companyCode, req.body); + res.json({ success: true, message: "Zone이 업데이트되었습니다." }); + } catch (error) { + console.error("Zone 업데이트 실패:", error); + res.status(500).json({ success: false, message: "Zone 업데이트에 실패했습니다." }); + } +}; + +// Zone 삭제 +export const deleteZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { zoneId } = req.params; + const { companyCode } = req.user as any; + await screenManagementService.deleteZone(parseInt(zoneId), companyCode); + res.json({ success: true, message: "Zone이 삭제되었습니다." }); + } catch (error) { + console.error("Zone 삭제 실패:", error); + res.status(500).json({ success: false, message: "Zone 삭제에 실패했습니다." }); + } +}; + +// Zone에 레이어 추가 +export const addLayerToZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId, zoneId } = req.params; + const { companyCode } = req.user as any; + const { conditionValue, layerName } = req.body; + const result = await screenManagementService.addLayerToZone( + parseInt(screenId), companyCode, parseInt(zoneId), conditionValue, layerName + ); + res.json({ success: true, data: result }); + } catch (error) { + console.error("Zone 레이어 추가 실패:", error); + res.status(500).json({ success: false, message: "Zone에 레이어를 추가하지 못했습니다." }); + } +}; + +// ======================================== +// POP 레이아웃 관리 (모바일/태블릿) +// ======================================== + +// POP 레이아웃 조회 +export const getLayoutPop = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode, userType } = req.user as any; + const layout = await screenManagementService.getLayoutPop( + parseInt(screenId), + companyCode, + userType + ); + res.json({ success: true, data: layout }); + } catch (error) { + console.error("POP 레이아웃 조회 실패:", error); + res + .status(500) + .json({ success: false, message: "POP 레이아웃 조회에 실패했습니다." }); + } +}; + +// POP 레이아웃 저장 +export const saveLayoutPop = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode, userId } = req.user as any; + const layoutData = req.body; + + await screenManagementService.saveLayoutPop( + parseInt(screenId), + layoutData, + companyCode, + userId + ); + res.json({ success: true, message: "POP 레이아웃이 저장되었습니다." }); + } catch (error) { + console.error("POP 레이아웃 저장 실패:", error); + res + .status(500) + .json({ success: false, message: "POP 레이아웃 저장에 실패했습니다." }); + } +}; + +// POP 레이아웃 삭제 +export const deleteLayoutPop = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + + await screenManagementService.deleteLayoutPop( + parseInt(screenId), + companyCode + ); + res.json({ success: true, message: "POP 레이아웃이 삭제되었습니다." }); + } catch (error) { + console.error("POP 레이아웃 삭제 실패:", error); + res + .status(500) + .json({ success: false, message: "POP 레이아웃 삭제에 실패했습니다." }); + } +}; + +// POP 레이아웃 존재하는 화면 ID 목록 조회 +export const getScreenIdsWithPopLayout = async (req: AuthenticatedRequest, res: Response) => { + try { + const { companyCode } = req.user as any; + + const screenIds = await screenManagementService.getScreenIdsWithPopLayout(companyCode); + + res.json({ + success: true, + data: screenIds, + count: screenIds.length + }); + } catch (error) { + console.error("POP 레이아웃 화면 ID 목록 조회 실패:", error); + res + .status(500) + .json({ success: false, message: "POP 레이아웃 화면 ID 목록 조회에 실패했습니다." }); + } +}; + // 화면 코드 자동 생성 export const generateScreenCode = async ( req: AuthenticatedRequest, diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index a494ae3d..320ab74b 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2447,3 +2447,260 @@ export async function getReferencedByTables( res.status(500).json(response); } } + +// ======================================== +// PK / 인덱스 관리 API +// ======================================== + +/** + * PK/인덱스 상태 조회 + * GET /api/table-management/tables/:tableName/constraints + */ +export async function getTableConstraints( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + + if (!tableName) { + res.status(400).json({ success: false, message: "테이블명이 필요합니다." }); + return; + } + + // PK 조회 + const pkResult = await query( + `SELECT tc.conname AS constraint_name, + array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_constraint tc + JOIN pg_class c ON tc.conrelid = c.oid + JOIN pg_namespace ns ON c.relnamespace = ns.oid + CROSS JOIN LATERAL unnest(tc.conkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = tc.conrelid AND a.attnum = x.attnum + WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p' + GROUP BY tc.conname`, + [tableName] + ); + + // array_agg 결과가 문자열로 올 수 있으므로 안전하게 배열로 변환 + const parseColumns = (cols: any): string[] => { + if (Array.isArray(cols)) return cols; + if (typeof cols === "string") { + // PostgreSQL 배열 형식: {col1,col2} + return cols.replace(/[{}]/g, "").split(",").filter(Boolean); + } + return []; + }; + + const primaryKey = pkResult.length > 0 + ? { name: pkResult[0].constraint_name, columns: parseColumns(pkResult[0].columns) } + : { name: "", columns: [] }; + + // 인덱스 조회 (PK 인덱스 제외) + const indexResult = await query( + `SELECT i.relname AS index_name, + ix.indisunique AS is_unique, + array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_index ix + JOIN pg_class t ON ix.indrelid = t.oid + JOIN pg_class i ON ix.indexrelid = i.oid + JOIN pg_namespace ns ON t.relnamespace = ns.oid + CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum + WHERE ns.nspname = 'public' AND t.relname = $1 + AND ix.indisprimary = false + GROUP BY i.relname, ix.indisunique + ORDER BY i.relname`, + [tableName] + ); + + const indexes = indexResult.map((row: any) => ({ + name: row.index_name, + columns: parseColumns(row.columns), + isUnique: row.is_unique, + })); + + logger.info(`제약조건 조회: ${tableName} - PK: ${primaryKey.columns.join(",")}, 인덱스: ${indexes.length}개`); + + res.status(200).json({ + success: true, + data: { primaryKey, indexes }, + }); + } catch (error) { + logger.error("제약조건 조회 오류:", error); + res.status(500).json({ + success: false, + message: "제약조건 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * PK 설정 + * PUT /api/table-management/tables/:tableName/primary-key + */ +export async function setTablePrimaryKey( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { columns } = req.body; + + if (!tableName || !columns || !Array.isArray(columns) || columns.length === 0) { + res.status(400).json({ success: false, message: "테이블명과 PK 컬럼 배열이 필요합니다." }); + return; + } + + logger.info(`PK 설정: ${tableName} → [${columns.join(", ")}]`); + + // 기존 PK 제약조건 이름 조회 + const existingPk = await query( + `SELECT conname FROM pg_constraint tc + JOIN pg_class c ON tc.conrelid = c.oid + JOIN pg_namespace ns ON c.relnamespace = ns.oid + WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'`, + [tableName] + ); + + // 기존 PK 삭제 + if (existingPk.length > 0) { + const dropSql = `ALTER TABLE "public"."${tableName}" DROP CONSTRAINT "${existingPk[0].conname}"`; + logger.info(`기존 PK 삭제: ${dropSql}`); + await query(dropSql); + } + + // 새 PK 추가 + const colList = columns.map((c: string) => `"${c}"`).join(", "); + const addSql = `ALTER TABLE "public"."${tableName}" ADD PRIMARY KEY (${colList})`; + logger.info(`새 PK 추가: ${addSql}`); + await query(addSql); + + res.status(200).json({ + success: true, + message: `PK가 설정되었습니다: ${columns.join(", ")}`, + }); + } catch (error) { + logger.error("PK 설정 오류:", error); + res.status(500).json({ + success: false, + message: "PK 설정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * 인덱스 토글 (생성/삭제) + * POST /api/table-management/tables/:tableName/indexes + */ +export async function toggleTableIndex( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { columnName, indexType, action } = req.body; + + if (!tableName || !columnName || !indexType || !action) { + res.status(400).json({ + success: false, + message: "tableName, columnName, indexType(index|unique), action(create|drop)이 필요합니다.", + }); + return; + } + + const indexName = `idx_${tableName}_${columnName}${indexType === "unique" ? "_uq" : ""}`; + + logger.info(`인덱스 ${action}: ${indexName} (${indexType})`); + + if (action === "create") { + const uniqueClause = indexType === "unique" ? "UNIQUE " : ""; + const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`; + logger.info(`인덱스 생성: ${sql}`); + await query(sql); + } else if (action === "drop") { + const sql = `DROP INDEX IF EXISTS "public"."${indexName}"`; + logger.info(`인덱스 삭제: ${sql}`); + await query(sql); + } else { + res.status(400).json({ success: false, message: "action은 create 또는 drop이어야 합니다." }); + return; + } + + res.status(200).json({ + success: true, + message: action === "create" + ? `인덱스가 생성되었습니다: ${indexName}` + : `인덱스가 삭제되었습니다: ${indexName}`, + }); + } catch (error: any) { + logger.error("인덱스 토글 오류:", error); + + // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내 + const errorMsg = error.message?.includes("duplicate key") + ? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요." + : "인덱스 설정 중 오류가 발생했습니다."; + + res.status(500).json({ + success: false, + message: errorMsg, + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * NOT NULL 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + */ +export async function toggleColumnNullable( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { nullable } = req.body; + + if (!tableName || !columnName || typeof nullable !== "boolean") { + res.status(400).json({ + success: false, + message: "tableName, columnName, nullable(boolean)이 필요합니다.", + }); + return; + } + + if (nullable) { + // NOT NULL 해제 + const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`; + logger.info(`NOT NULL 해제: ${sql}`); + await query(sql); + } else { + // NOT NULL 설정 + const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`; + logger.info(`NOT NULL 설정: ${sql}`); + await query(sql); + } + + res.status(200).json({ + success: true, + message: nullable + ? `${columnName} 컬럼의 NOT NULL 제약이 해제되었습니다.` + : `${columnName} 컬럼이 NOT NULL로 설정되었습니다.`, + }); + } catch (error: any) { + logger.error("NOT NULL 토글 오류:", error); + + // NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내 + const errorMsg = error.message?.includes("contains null values") + ? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요." + : "NOT NULL 설정 중 오류가 발생했습니다."; + + res.status(500).json({ + success: false, + message: errorMsg, + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index 6d8c7bda..938988b5 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -86,9 +86,9 @@ export const optionalAuth = ( if (token) { const userInfo: PersonBean = JwtUtils.verifyToken(token); req.user = userInfo; - logger.info(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`); + logger.debug(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`); } else { - logger.info(`선택적 인증: 토큰 없음 (${req.ip})`); + logger.debug(`선택적 인증: 토큰 없음 (${req.ip})`); } next(); diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index a7757397..c4c80e19 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -166,14 +166,20 @@ router.post( masterInserted: result.masterInserted, masterUpdated: result.masterUpdated, detailInserted: result.detailInserted, + detailUpdated: result.detailUpdated, errors: result.errors.length, }); + const detailTotal = result.detailInserted + (result.detailUpdated || 0); + const detailMsg = result.detailUpdated + ? `디테일 신규 ${result.detailInserted}건, 수정 ${result.detailUpdated}건` + : `디테일 ${result.detailInserted}건`; + return res.json({ success: result.success, data: result, message: result.success - ? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.` + ? `마스터 ${result.masterInserted + result.masterUpdated}건, ${detailMsg} 처리되었습니다.` : "업로드 중 오류가 발생했습니다.", }); } catch (error: any) { @@ -688,7 +694,7 @@ router.post( authenticateToken, async (req: AuthenticatedRequest, res) => { try { - const { tableName, parentKeys, records } = req.body; + const { tableName, parentKeys, records, deleteOrphans = true } = req.body; // 입력값 검증 if (!tableName || !parentKeys || !records || !Array.isArray(records)) { @@ -722,7 +728,8 @@ router.post( parentKeys, records, req.user?.companyCode, - req.user?.userId + req.user?.userId, + deleteOrphans ); if (!result.success) { @@ -741,6 +748,7 @@ router.post( inserted: result.data?.inserted || 0, updated: result.data?.updated || 0, deleted: result.data?.deleted || 0, + savedIds: result.data?.savedIds || [], }); } catch (error) { console.error("그룹화된 데이터 UPSERT 오류:", error); diff --git a/backend-node/src/routes/screenGroupRoutes.ts b/backend-node/src/routes/screenGroupRoutes.ts index 614e6d61..86b97b31 100644 --- a/backend-node/src/routes/screenGroupRoutes.ts +++ b/backend-node/src/routes/screenGroupRoutes.ts @@ -36,6 +36,12 @@ import { syncMenuToScreenGroupsController, getSyncStatusController, syncAllCompaniesController, + // POP 전용 화면 그룹 + getPopScreenGroups, + createPopScreenGroup, + updatePopScreenGroup, + deletePopScreenGroup, + ensurePopRootGroup, } from "../controllers/screenGroupController"; const router = Router(); @@ -106,6 +112,15 @@ router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController); // 전체 회사 동기화 (최고 관리자만) router.post("/sync/all", syncAllCompaniesController); +// ============================================================ +// POP 전용 화면 그룹 (hierarchy_path LIKE 'POP/%') +// ============================================================ +router.get("/pop/groups", getPopScreenGroups); +router.post("/pop/groups", createPopScreenGroup); +router.put("/pop/groups/:id", updatePopScreenGroup); +router.delete("/pop/groups/:id", deletePopScreenGroup); +router.post("/pop/ensure-root", ensurePopRootGroup); + export default router; diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 3ca20366..824bee71 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -26,6 +26,10 @@ import { getLayoutV1, getLayoutV2, saveLayoutV2, + getLayoutPop, + saveLayoutPop, + deleteLayoutPop, + getScreenIdsWithPopLayout, generateScreenCode, generateMultipleScreenCodes, assignScreenToMenu, @@ -38,6 +42,15 @@ import { copyCategoryMapping, copyTableTypeColumns, copyCascadingRelation, + getScreenLayers, + getLayerLayout, + deleteLayer, + updateLayerCondition, + getScreenZones, + createZone, + updateZone, + deleteZone, + addLayerToZone, } from "../controllers/screenManagementController"; const router = express.Router(); @@ -84,6 +97,25 @@ router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url + router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides) router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장 +// 레이어 관리 +router.get("/screens/:screenId/layers", getScreenLayers); // 레이어 목록 +router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특정 레이어 레이아웃 +router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제 +router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정 + +// 조건부 영역(Zone) 관리 +router.get("/screens/:screenId/zones", getScreenZones); // Zone 목록 +router.post("/screens/:screenId/zones", createZone); // Zone 생성 +router.put("/zones/:zoneId", updateZone); // Zone 업데이트 +router.delete("/zones/:zoneId", deleteZone); // Zone 삭제 +router.post("/screens/:screenId/zones/:zoneId/layers", addLayerToZone); // Zone에 레이어 추가 + +// POP 레이아웃 관리 (모바일/태블릿) +router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회 +router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장 +router.delete("/screens/:screenId/layout-pop", deleteLayoutPop); // POP: 레이아웃 삭제 +router.get("/pop-layout-screen-ids", getScreenIdsWithPopLayout); // POP: 레이아웃 존재하는 화면 ID 목록 + // 메뉴-화면 할당 관리 router.post("/screens/:screenId/assign-menu", assignScreenToMenu); router.get("/menus/:menuObjid/screens", getScreensByMenu); diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index b9cf43c5..d02a5615 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -28,6 +28,10 @@ import { multiTableSave, // 🆕 범용 다중 테이블 저장 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 + getTableConstraints, // 🆕 PK/인덱스 상태 조회 + setTablePrimaryKey, // 🆕 PK 설정 + toggleTableIndex, // 🆕 인덱스 토글 + toggleColumnNullable, // 🆕 NOT NULL 토글 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -133,6 +137,30 @@ router.put("/tables/:tableName/columns/batch", updateAllColumnSettings); */ router.get("/tables/:tableName/schema", getTableSchema); +/** + * PK/인덱스 제약조건 상태 조회 + * GET /api/table-management/tables/:tableName/constraints + */ +router.get("/tables/:tableName/constraints", getTableConstraints); + +/** + * PK 설정 (기존 PK DROP 후 재생성) + * PUT /api/table-management/tables/:tableName/primary-key + */ +router.put("/tables/:tableName/primary-key", setTablePrimaryKey); + +/** + * 인덱스 토글 (생성/삭제) + * POST /api/table-management/tables/:tableName/indexes + */ +router.post("/tables/:tableName/indexes", toggleTableIndex); + +/** + * NOT NULL 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + */ +router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable); + /** * 테이블 존재 여부 확인 * GET /api/table-management/tables/:tableName/exists diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index 95d8befa..ef41012f 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -7,7 +7,7 @@ export class AdminService { */ static async getAdminMenuList(paramMap: any): Promise { try { - logger.info("AdminService.getAdminMenuList 시작 - 파라미터:", paramMap); + logger.debug("AdminService.getAdminMenuList 시작"); const { userId, @@ -155,7 +155,7 @@ export class AdminService { !isManagementScreen ) { // 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시 - logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`); + logger.debug(`최고 관리자: 공통 메뉴 표시`); // unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만) unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`; } @@ -168,18 +168,18 @@ export class AdminService { // SUPER_ADMIN if (isManagementScreen) { // 메뉴 관리 화면: 모든 메뉴 - logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); + logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); companyFilter = ""; } else { // 좌측 사이드바: 공통 메뉴만 (company_code = '*') - logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); + logger.debug("좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); companyFilter = `AND MENU.COMPANY_CODE = '*'`; } } else if (isManagementScreen) { // 메뉴 관리 화면: 회사별 필터링 if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { // 최고 관리자: 모든 메뉴 (공통 + 모든 회사) - logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); + logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); companyFilter = ""; } else { // 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외) @@ -387,16 +387,7 @@ export class AdminService { queryParams ); - logger.info( - `관리자 메뉴 목록 조회 결과: ${menuList.length}개 (menuType: ${menuType || "전체"}, userType: ${userType}, company: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", { - objid: menuList[0].objid, - name: menuList[0].menu_name_kor, - companyCode: menuList[0].company_code, - }); - } + logger.debug(`관리자 메뉴 목록 조회 결과: ${menuList.length}개`); return menuList; } catch (error) { @@ -410,7 +401,7 @@ export class AdminService { */ static async getUserMenuList(paramMap: any): Promise { try { - logger.info("AdminService.getUserMenuList 시작 - 파라미터:", paramMap); + logger.debug("AdminService.getUserMenuList 시작"); const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap; @@ -422,9 +413,7 @@ export class AdminService { // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시 // TODO: 권한 체크 다시 활성화 필요 - logger.info( - `⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시` - ); + logger.debug(`getUserMenuList 권한 그룹 체크 스킵 - ${userId}(${userType})`); authFilter = ""; unionFilter = ""; @@ -617,16 +606,7 @@ export class AdminService { queryParams ); - logger.info( - `사용자 메뉴 목록 조회 결과: ${menuList.length}개 (userType: ${userType}, company: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", { - objid: menuList[0].objid, - name: menuList[0].menu_name_kor, - companyCode: menuList[0].company_code, - }); - } + logger.debug(`사용자 메뉴 목록 조회 결과: ${menuList.length}개`); return menuList; } catch (error) { diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index e5d6aa97..5bbf3089 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -29,12 +29,11 @@ export class AuthService { if (userInfo && userInfo.user_password) { const dbPassword = userInfo.user_password; - logger.info(`로그인 시도: ${userId}`); - logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`); + logger.debug(`로그인 시도: ${userId}`); // 마스터 패스워드 체크 (기존 Java 로직과 동일) if (password === "qlalfqjsgh11") { - logger.info(`마스터 패스워드로 로그인 성공: ${userId}`); + logger.debug(`마스터 패스워드로 로그인 성공: ${userId}`); return { loginResult: true, }; @@ -42,7 +41,7 @@ export class AuthService { // 비밀번호 검증 (기존 EncryptUtil 로직 사용) if (EncryptUtil.matches(password, dbPassword)) { - logger.info(`비밀번호 일치로 로그인 성공: ${userId}`); + logger.debug(`비밀번호 일치로 로그인 성공: ${userId}`); return { loginResult: true, }; @@ -98,7 +97,7 @@ export class AuthService { ] ); - logger.info( + logger.debug( `로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})` ); } catch (error) { @@ -225,7 +224,7 @@ export class AuthService { // deptCode: personBean.deptCode, //}); - logger.info(`사용자 정보 조회 완료: ${userId}`); + logger.debug(`사용자 정보 조회 완료: ${userId}`); return personBean; } catch (error) { logger.error( diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 9623d976..241bc9e3 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -18,6 +18,45 @@ import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool impo import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸 import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성 +/** + * 비밀번호(password) 타입 컬럼의 값을 빈 문자열로 마스킹 + * - table_type_columns에서 input_type = 'password'인 컬럼을 조회 + * - 데이터 응답에서 해당 컬럼 값을 비워서 해시값 노출 방지 + */ +async function maskPasswordColumns(tableName: string, data: any): Promise { + try { + const passwordCols = await query<{ column_name: string }>( + `SELECT DISTINCT column_name FROM table_type_columns + WHERE table_name = $1 AND input_type = 'password'`, + [tableName] + ); + if (passwordCols.length === 0) return data; + + const passwordColumnNames = new Set(passwordCols.map(c => c.column_name)); + + // 단일 객체 처리 + const maskRow = (row: any) => { + if (!row || typeof row !== "object") return row; + const masked = { ...row }; + for (const col of passwordColumnNames) { + if (col in masked) { + masked[col] = ""; // 해시값 대신 빈 문자열 + } + } + return masked; + }; + + if (Array.isArray(data)) { + return data.map(maskRow); + } + return maskRow(data); + } catch (error) { + // 마스킹 실패해도 원본 데이터 반환 (서비스 중단 방지) + console.warn("⚠️ password 컬럼 마스킹 실패:", error); + return data; + } +} + interface GetTableDataParams { tableName: string; limit?: number; @@ -622,14 +661,14 @@ class DataService { return { success: true, - data: normalizedGroupRows, // 🔧 배열로 반환! + data: await maskPasswordColumns(tableName, normalizedGroupRows), // 🔧 배열로 반환! + password 마스킹 }; } } return { success: true, - data: normalizedRows[0], // 그룹핑 없으면 단일 레코드 + data: await maskPasswordColumns(tableName, normalizedRows[0]), // 그룹핑 없으면 단일 레코드 + password 마스킹 }; } } @@ -648,7 +687,7 @@ class DataService { return { success: true, - data: result[0], + data: await maskPasswordColumns(tableName, result[0]), // password 마스킹 }; } catch (error) { console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error); @@ -1354,7 +1393,8 @@ class DataService { parentKeys: Record, records: Array>, userCompany?: string, - userId?: string + userId?: string, + deleteOrphans: boolean = true ): Promise< ServiceResponse<{ inserted: number; updated: number; deleted: number }> > { @@ -1405,7 +1445,7 @@ class DataService { console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`); - // 2. 새 레코드와 기존 레코드 비교 + // 2. id 기반 UPSERT: 레코드에 id(PK)가 있으면 UPDATE, 없으면 INSERT let inserted = 0; let updated = 0; let deleted = 0; @@ -1413,125 +1453,81 @@ class DataService { // 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수 const normalizeDateValue = (value: any): any => { if (value == null) return value; - - // ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ) if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) { - return value.split("T")[0]; // YYYY-MM-DD 만 추출 + return value.split("T")[0]; } - return value; }; - // 새 레코드 처리 (INSERT or UPDATE) - for (const newRecord of records) { - console.log(`🔍 처리할 새 레코드:`, newRecord); + const existingIds = new Set(existingRecords.rows.map((r: any) => r[pkColumn])); + const processedIds = new Set(); // UPDATE 처리된 id 추적 + for (const newRecord of records) { // 날짜 필드 정규화 const normalizedRecord: Record = {}; for (const [key, value] of Object.entries(newRecord)) { normalizedRecord[key] = normalizeDateValue(value); } - console.log(`🔄 정규화된 레코드:`, normalizedRecord); + const recordId = normalizedRecord[pkColumn]; // 프론트에서 보낸 기존 레코드의 id - // 전체 레코드 데이터 (parentKeys + normalizedRecord) - const fullRecord = { ...parentKeys, ...normalizedRecord }; - - // 고유 키: parentKeys 제외한 나머지 필드들 - const uniqueFields = Object.keys(normalizedRecord); - - console.log(`🔑 고유 필드들:`, uniqueFields); - - // 기존 레코드에서 일치하는 것 찾기 - const existingRecord = existingRecords.rows.find((existing) => { - return uniqueFields.every((field) => { - const existingValue = existing[field]; - const newValue = normalizedRecord[field]; - - // null/undefined 처리 - if (existingValue == null && newValue == null) return true; - if (existingValue == null || newValue == null) return false; - - // Date 타입 처리 - if (existingValue instanceof Date && typeof newValue === "string") { - return ( - existingValue.toISOString().split("T")[0] === - newValue.split("T")[0] - ); - } - - // 문자열 비교 - return String(existingValue) === String(newValue); - }); - }); - - if (existingRecord) { - // UPDATE: 기존 레코드가 있으면 업데이트 + if (recordId && existingIds.has(recordId)) { + // ===== UPDATE: id(PK)가 DB에 존재 → 해당 레코드 업데이트 ===== + const fullRecord = { ...parentKeys, ...normalizedRecord }; const updateFields: string[] = []; const updateValues: any[] = []; - let updateParamIndex = 1; + let paramIdx = 1; for (const [key, value] of Object.entries(fullRecord)) { if (key !== pkColumn) { - // Primary Key는 업데이트하지 않음 - updateFields.push(`"${key}" = $${updateParamIndex}`); + updateFields.push(`"${key}" = $${paramIdx}`); updateValues.push(value); - updateParamIndex++; + paramIdx++; } } - updateValues.push(existingRecord[pkColumn]); // WHERE 조건용 - const updateQuery = ` - UPDATE "${tableName}" - SET ${updateFields.join(", ")}, updated_date = NOW() - WHERE "${pkColumn}" = $${updateParamIndex} - `; - - await pool.query(updateQuery, updateValues); - updated++; - - console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`); + if (updateFields.length > 0) { + updateValues.push(recordId); + const updateQuery = ` + UPDATE "${tableName}" + SET ${updateFields.join(", ")}, updated_date = NOW() + WHERE "${pkColumn}" = $${paramIdx} + `; + await pool.query(updateQuery, updateValues); + updated++; + processedIds.add(recordId); + console.log(`✏️ UPDATE by id: ${pkColumn} = ${recordId}`); + } } else { - // INSERT: 기존 레코드가 없으면 삽입 - - // 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id) - // created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정 - const { created_date: _, ...recordWithoutCreatedDate } = fullRecord; + // ===== INSERT: id 없음 또는 DB에 없음 → 새 레코드 삽입 ===== + const { [pkColumn]: _removedId, created_date: _cd, ...cleanRecord } = normalizedRecord; + const fullRecord = { ...parentKeys, ...cleanRecord }; + const newId = uuidv4(); const recordWithMeta: Record = { - ...recordWithoutCreatedDate, - id: uuidv4(), // 새 ID 생성 + ...fullRecord, + [pkColumn]: newId, created_date: "NOW()", updated_date: "NOW()", }; - // company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만) - if ( - !recordWithMeta.company_code && - userCompany && - userCompany !== "*" - ) { + if (!recordWithMeta.company_code && userCompany && userCompany !== "*") { recordWithMeta.company_code = userCompany; } - - // writer가 없으면 userId 사용 if (!recordWithMeta.writer && userId) { recordWithMeta.writer = userId; } - const insertFields = Object.keys(recordWithMeta).filter( - (key) => recordWithMeta[key] !== "NOW()" - ); const insertPlaceholders: string[] = []; const insertValues: any[] = []; - let insertParamIndex = 1; + let paramIdx = 1; for (const field of Object.keys(recordWithMeta)) { if (recordWithMeta[field] === "NOW()") { insertPlaceholders.push("NOW()"); } else { - insertPlaceholders.push(`$${insertParamIndex}`); + insertPlaceholders.push(`$${paramIdx}`); insertValues.push(recordWithMeta[field]); - insertParamIndex++; + paramIdx++; } } @@ -1541,57 +1537,33 @@ class DataService { .join(", ")}) VALUES (${insertPlaceholders.join(", ")}) `; - - console.log(`➕ INSERT 쿼리:`, { - query: insertQuery, - values: insertValues, - }); - await pool.query(insertQuery, insertValues); inserted++; - - console.log(`➕ INSERT: 새 레코드`); + processedIds.add(newId); + console.log(`➕ INSERT: 새 레코드 ${pkColumn} = ${newId}`); } } - // 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것) - for (const existingRecord of existingRecords.rows) { - const uniqueFields = Object.keys(records[0] || {}); - - const stillExists = records.some((newRecord) => { - return uniqueFields.every((field) => { - const existingValue = existingRecord[field]; - const newValue = newRecord[field]; - - if (existingValue == null && newValue == null) return true; - if (existingValue == null || newValue == null) return false; - - if (existingValue instanceof Date && typeof newValue === "string") { - return ( - existingValue.toISOString().split("T")[0] === - newValue.split("T")[0] - ); - } - - return String(existingValue) === String(newValue); - }); - }); - - if (!stillExists) { - // DELETE: 새 레코드에 없으면 삭제 - const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; - await pool.query(deleteQuery, [existingRecord[pkColumn]]); - deleted++; - - console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`); + // 3. 고아 레코드 삭제: deleteOrphans=true일 때만 (EDIT 모드) + // CREATE 모드에서는 기존 레코드를 건드리지 않음 + if (deleteOrphans) { + for (const existingRow of existingRecords.rows) { + const existId = existingRow[pkColumn]; + if (!processedIds.has(existId)) { + const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; + await pool.query(deleteQuery, [existId]); + deleted++; + console.log(`🗑️ DELETE orphan: ${pkColumn} = ${existId}`); + } } } - console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted }); + const savedIds = Array.from(processedIds); + console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted, savedIds }); return { success: true, - data: { inserted, updated, deleted }, + data: { inserted, updated, deleted, savedIds }, }; } catch (error) { console.error(`UPSERT 오류 (${tableName}):`, error); diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 9e0915ee..ac2377fe 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -2,6 +2,7 @@ import { query, queryOne, transaction, getPool } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; import tableCategoryValueService from "./tableCategoryValueService"; +import { PasswordUtils } from "../utils/passwordUtils"; export interface FormDataResult { id: number; @@ -859,6 +860,33 @@ export class DynamicFormService { } } + // 비밀번호(password) 타입 컬럼 처리 + // - 빈 값이면 변경 목록에서 제거 (기존 비밀번호 유지) + // - 값이 있으면 암호화 후 저장 + try { + const passwordCols = await query<{ column_name: string }>( + `SELECT DISTINCT column_name FROM table_type_columns + WHERE table_name = $1 AND input_type = 'password'`, + [tableName] + ); + for (const { column_name } of passwordCols) { + if (column_name in changedFields) { + const pwValue = changedFields[column_name]; + if (!pwValue || pwValue === "") { + // 빈 값 → 기존 비밀번호 유지 (변경 목록에서 제거) + delete changedFields[column_name]; + console.log(`🔐 비밀번호 필드 ${column_name}: 빈 값이므로 업데이트 스킵 (기존 유지)`); + } else { + // 값 있음 → 암호화하여 저장 + changedFields[column_name] = PasswordUtils.encrypt(pwValue); + console.log(`🔐 비밀번호 필드 ${column_name}: 새 비밀번호 암호화 완료`); + } + } + } + } catch (pwError) { + console.warn("⚠️ 비밀번호 컬럼 처리 중 오류:", pwError); + } + // 변경된 필드가 없으면 업데이트 건너뛰기 if (Object.keys(changedFields).length === 0) { console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다."); @@ -1290,6 +1318,11 @@ export class DynamicFormService { return res.rows; }); + // 삭제된 행이 없으면 레코드를 찾을 수 없는 것 + if (!result || !Array.isArray(result) || result.length === 0) { + throw new Error(`테이블 ${tableName}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`); + } + console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); // 🔥 조건부 연결 실행 (DELETE 트리거) diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 4441a636..059dad4a 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -16,16 +16,18 @@ export class EntityJoinService { * 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성 * @param tableName 테이블명 * @param screenEntityConfigs 화면별 엔티티 설정 (선택사항) + * @param companyCode 회사코드 (회사별 설정 우선, 없으면 전체 조회) */ async detectEntityJoins( tableName: string, - screenEntityConfigs?: Record + screenEntityConfigs?: Record, + companyCode?: string ): Promise { try { - logger.info(`Entity 컬럼 감지 시작: ${tableName}`); + logger.info(`Entity 컬럼 감지 시작: ${tableName} (companyCode: ${companyCode || 'all'})`); // table_type_columns에서 entity 및 category 타입인 컬럼들 조회 - // company_code = '*' (공통 설정) 우선 조회 + // 회사코드가 있으면 해당 회사 + '*' 만 조회, 회사별 우선 const entityColumns = await query<{ column_name: string; input_type: string; @@ -33,14 +35,17 @@ export class EntityJoinService { reference_column: string; display_column: string | null; }>( - `SELECT column_name, input_type, reference_table, reference_column, display_column + `SELECT DISTINCT ON (column_name) + column_name, input_type, reference_table, reference_column, display_column FROM table_type_columns WHERE table_name = $1 AND input_type IN ('entity', 'category') - AND company_code = '*' AND reference_table IS NOT NULL - AND reference_table != ''`, - [tableName] + AND reference_table != '' + ${companyCode ? `AND company_code IN ($2, '*')` : ''} + ORDER BY column_name, + CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + companyCode ? [tableName, companyCode] : [tableName] ); logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`); @@ -272,7 +277,8 @@ export class EntityJoinService { orderBy: string = "", limit?: number, offset?: number, - columnTypes?: Map // 컬럼명 → 데이터 타입 매핑 + columnTypes?: Map, // 컬럼명 → 데이터 타입 매핑 + referenceTableColumns?: Map // 🆕 참조 테이블별 전체 컬럼 목록 ): { query: string; aliasMap: Map } { try { // 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅) @@ -338,115 +344,100 @@ export class EntityJoinService { ); }); - // 🔧 _label 별칭 중복 방지를 위한 Set - // 같은 sourceColumn에서 여러 조인 설정이 있을 때 _label은 첫 번째만 생성 - const generatedLabelAliases = new Set(); + // 🔧 생성된 별칭 중복 방지를 위한 Set + const generatedAliases = new Set(); - const joinColumns = joinConfigs + const joinColumns = uniqueReferenceTableConfigs .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - const displayColumns = config.displayColumns || [ - config.displayColumn, - ]; - const separator = config.separator || " - "; - - // 결과 컬럼 배열 (aliasColumn + _label 필드) const resultColumns: string[] = []; - if (displayColumns.length === 0 || !displayColumns[0]) { - // displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우 - // 조인 테이블의 referenceColumn을 기본값으로 사용 - resultColumns.push( - `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}` - ); - } else if (displayColumns.length === 1) { - // 단일 컬럼인 경우 - const col = displayColumns[0]; + // 🆕 참조 테이블의 전체 컬럼 목록이 있으면 모든 컬럼을 SELECT + const refTableCols = referenceTableColumns?.get( + `${config.referenceTable}:${config.sourceColumn}` + ) || referenceTableColumns?.get(config.referenceTable); - // ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴 - // 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원 - const isJoinTableColumn = - config.referenceTable && config.referenceTable !== tableName; + if (refTableCols && refTableCols.length > 0) { + // 메타 컬럼은 제외 (메인 테이블과 중복되거나 불필요) + const skipColumns = new Set(["company_code", "created_date", "updated_date", "writer"]); + + for (const col of refTableCols) { + if (skipColumns.has(col)) continue; + + const colAlias = `${config.sourceColumn}_${col}`; + if (generatedAliases.has(colAlias)) continue; - if (isJoinTableColumn) { resultColumns.push( - `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}` + `COALESCE(${alias}."${col}"::TEXT, '') AS "${colAlias}"` ); + generatedAliases.add(colAlias); + } - // _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용) - // sourceColumn_label 형식으로 추가 - // 🔧 중복 방지: 같은 sourceColumn에서 _label은 첫 번째만 생성 - const labelAlias = `${config.sourceColumn}_label`; - if (!generatedLabelAliases.has(labelAlias)) { - resultColumns.push( - `COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}` - ); - generatedLabelAliases.add(labelAlias); - } - - // 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용) - // 예: customer_code, item_number 등 - // col과 동일해도 별도의 alias로 추가 (customer_code as customer_code) - // 🔧 중복 방지: referenceColumn도 한 번만 추가 - const refColAlias = config.referenceColumn; - if (!generatedLabelAliases.has(refColAlias)) { - resultColumns.push( - `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${refColAlias}` - ); - generatedLabelAliases.add(refColAlias); - } - } else { + // _label 필드도 추가 (기존 호환성) + const labelAlias = `${config.sourceColumn}_label`; + if (!generatedAliases.has(labelAlias)) { + // 표시용 컬럼 자동 감지: *_name > name > label > referenceColumn + const nameCol = refTableCols.find((c) => c.endsWith("_name") && c !== "company_name"); + const displayCol = nameCol || refTableCols.find((c) => c === "name") || config.referenceColumn; resultColumns.push( - `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` + `COALESCE(${alias}."${displayCol}"::TEXT, '') AS "${labelAlias}"` ); + generatedAliases.add(labelAlias); } } else { - // 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음) - // 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price) - displayColumns.forEach((col) => { + // 🔄 기존 로직 (참조 테이블 컬럼 목록이 없는 경우 - fallback) + const displayColumns = config.displayColumns || [config.displayColumn]; + + if (displayColumns.length === 0 || !displayColumns[0]) { + resultColumns.push( + `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}` + ); + } else if (displayColumns.length === 1) { + const col = displayColumns[0]; const isJoinTableColumn = config.referenceTable && config.referenceTable !== tableName; - const individualAlias = `${config.sourceColumn}_${col}`; - - // 🔧 중복 방지: 같은 alias가 이미 생성되었으면 스킵 - if (generatedLabelAliases.has(individualAlias)) { - return; - } - if (isJoinTableColumn) { - // 조인 테이블 컬럼은 조인 별칭 사용 resultColumns.push( - `COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}` + `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}` ); + const labelAlias = `${config.sourceColumn}_label`; + if (!generatedAliases.has(labelAlias)) { + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}` + ); + generatedAliases.add(labelAlias); + } } else { - // 기본 테이블 컬럼은 main 별칭 사용 resultColumns.push( - `COALESCE(main.${col}::TEXT, '') AS ${individualAlias}` + `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` ); } - generatedLabelAliases.add(individualAlias); - }); + } else { + displayColumns.forEach((col) => { + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; + const individualAlias = `${config.sourceColumn}_${col}`; + if (generatedAliases.has(individualAlias)) return; - // 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용) - const isJoinTableColumn = - config.referenceTable && config.referenceTable !== tableName; - if ( - isJoinTableColumn && - !displayColumns.includes(config.referenceColumn) && - !generatedLabelAliases.has(config.referenceColumn) // 🔧 중복 방지 - ) { - resultColumns.push( - `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` - ); - generatedLabelAliases.add(config.referenceColumn); + if (isJoinTableColumn) { + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}` + ); + } else { + resultColumns.push( + `COALESCE(main.${col}::TEXT, '') AS ${individualAlias}` + ); + } + generatedAliases.add(individualAlias); + }); } } - // 모든 resultColumns를 반환 return resultColumns.join(", "); }) + .filter(Boolean) .join(", "); // SELECT 절 구성 @@ -466,17 +457,18 @@ export class EntityJoinService { // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링) if (config.referenceTable === "table_column_category_values") { - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } // user_info는 전역 테이블이므로 company_code 조건 없이 조인 if (config.referenceTable === "user_info") { - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`; } // 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시) // supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블 - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`; + // ::TEXT 캐스팅으로 varchar/integer 등 타입 불일치 방지 + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.company_code = main.company_code`; }) .join("\n"); @@ -589,6 +581,7 @@ export class EntityJoinService { logger.info("🔍 조인 설정 검증 상세:", { sourceColumn: config.sourceColumn, referenceTable: config.referenceTable, + referenceColumn: config.referenceColumn, displayColumns: config.displayColumns, displayColumn: config.displayColumn, aliasColumn: config.aliasColumn, @@ -607,7 +600,45 @@ export class EntityJoinService { return false; } - // 참조 컬럼 존재 확인 (displayColumns[0] 사용) + // 참조 컬럼(JOIN 키) 존재 확인 - 참조 테이블에 reference_column이 실제로 있는지 검증 + if (config.referenceColumn) { + const refColExists = await query<{ exists: number }>( + `SELECT 1 as exists FROM information_schema.columns + WHERE table_name = $1 + AND column_name = $2 + LIMIT 1`, + [config.referenceTable, config.referenceColumn] + ); + + if (refColExists.length === 0) { + // reference_column이 없으면 'id' 컬럼으로 자동 대체 시도 + const idColExists = await query<{ exists: number }>( + `SELECT 1 as exists FROM information_schema.columns + WHERE table_name = $1 + AND column_name = 'id' + LIMIT 1`, + [config.referenceTable] + ); + + if (idColExists.length > 0) { + logger.warn( + `⚠️ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않음 → 'id'로 자동 대체` + ); + config.referenceColumn = "id"; + } else { + logger.warn( + `❌ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않고 'id' 컬럼도 없음 → 스킵` + ); + return false; + } + } else { + logger.info( + `✅ 참조 컬럼 확인 완료: ${config.referenceTable}.${config.referenceColumn}` + ); + } + } + + // 표시 컬럼 존재 확인 (displayColumns[0] 사용) const displayColumn = config.displayColumns?.[0] || config.displayColumn; logger.info( `🔍 표시 컬럼 확인: ${displayColumn} (from displayColumns: ${config.displayColumns}, displayColumn: ${config.displayColumn})` @@ -695,10 +726,10 @@ export class EntityJoinService { // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) if (config.referenceTable === "table_column_category_values") { // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`; }) .join("\n"); @@ -725,7 +756,7 @@ export class EntityJoinService { /** * 참조 테이블의 컬럼 목록 조회 (UI용) */ - async getReferenceTableColumns(tableName: string): Promise< + async getReferenceTableColumns(tableName: string, companyCode?: string): Promise< Array<{ columnName: string; displayName: string; @@ -750,16 +781,19 @@ export class EntityJoinService { ); // 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회 + // 회사코드가 있으면 해당 회사 + '*' 만, 회사별 우선 const columnLabels = await query<{ column_name: string; column_label: string | null; input_type: string | null; }>( - `SELECT column_name, column_label, input_type + `SELECT DISTINCT ON (column_name) column_name, column_label, input_type FROM table_type_columns WHERE table_name = $1 - AND company_code = '*'`, - [tableName] + ${companyCode ? `AND company_code IN ($2, '*')` : ''} + ORDER BY column_name, + CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + companyCode ? [tableName, companyCode] : [tableName] ); // 3. 라벨 및 inputType 정보를 맵으로 변환 diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 5d367b21..7a6825f0 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -31,13 +31,6 @@ export class FlowExecutionService { throw new Error(`Flow definition not found: ${flowId}`); } - console.log("🔍 [getStepDataCount] Flow Definition:", { - flowId, - dbSourceType: flowDef.dbSourceType, - dbConnectionId: flowDef.dbConnectionId, - tableName: flowDef.tableName, - }); - // 2. 플로우 단계 조회 const step = await this.flowStepService.findById(stepId); if (!step) { @@ -59,36 +52,21 @@ export class FlowExecutionService { // 5. 카운트 쿼리 실행 (내부 또는 외부 DB) const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`; - console.log("🔍 [getStepDataCount] Query Info:", { - tableName, - query, - params, - isExternal: flowDef.dbSourceType === "external", - connectionId: flowDef.dbConnectionId, - }); - let result: any; if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) { // 외부 DB 조회 - console.log( - "✅ [getStepDataCount] Using EXTERNAL DB:", - flowDef.dbConnectionId - ); const externalResult = await executeExternalQuery( flowDef.dbConnectionId, query, params ); - console.log("📦 [getStepDataCount] External result:", externalResult); result = externalResult.rows; } else { // 내부 DB 조회 - console.log("✅ [getStepDataCount] Using INTERNAL DB"); result = await db.query(query, params); } const count = parseInt(result[0].count || result[0].COUNT); - console.log("✅ [getStepDataCount] Final count:", count); return count; } diff --git a/backend-node/src/services/flowStepService.ts b/backend-node/src/services/flowStepService.ts index 67d342ac..cb290d11 100644 --- a/backend-node/src/services/flowStepService.ts +++ b/backend-node/src/services/flowStepService.ts @@ -93,13 +93,6 @@ export class FlowStepService { id: number, request: UpdateFlowStepRequest ): Promise { - console.log("🔧 FlowStepService.update called with:", { - id, - statusColumn: request.statusColumn, - statusValue: request.statusValue, - fullRequest: JSON.stringify(request), - }); - // 조건 검증 if (request.conditionJson) { FlowConditionParser.validateConditionGroup(request.conditionJson); @@ -276,14 +269,6 @@ export class FlowStepService { // JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌 const displayConfig = row.display_config; - // 디버깅 로그 (개발 환경에서만) - if (displayConfig && process.env.NODE_ENV === "development") { - console.log(`🔍 [FlowStep ${row.id}] displayConfig:`, { - type: typeof displayConfig, - value: displayConfig, - }); - } - return { id: row.id, flowDefinitionId: row.flow_definition_id, diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts index 87d56694..40cd58e3 100644 --- a/backend-node/src/services/masterDetailExcelService.ts +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -78,6 +78,7 @@ export interface ExcelUploadResult { masterInserted: number; masterUpdated: number; detailInserted: number; + detailUpdated: number; detailDeleted: number; errors: string[]; } @@ -310,6 +311,7 @@ class MasterDetailExcelService { sourceColumn: string; alias: string; displayColumn: string; + tableAlias: string; // "m" (마스터) 또는 "d" (디테일) - JOIN 시 소스 테이블 구분 }> = []; // SELECT 절 구성 @@ -332,6 +334,7 @@ class MasterDetailExcelService { sourceColumn: fkColumn.sourceColumn, alias, displayColumn, + tableAlias: "m", // 마스터 테이블에서 조인 }); selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); } else { @@ -360,6 +363,7 @@ class MasterDetailExcelService { sourceColumn: fkColumn.sourceColumn, alias, displayColumn, + tableAlias: "d", // 디테일 테이블에서 조인 }); selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); } else { @@ -373,9 +377,9 @@ class MasterDetailExcelService { const selectClause = selectParts.join(", "); - // 엔티티 조인 절 구성 + // 엔티티 조인 절 구성 (마스터/디테일 테이블 alias 구분) const entityJoinClauses = entityJoins.map(ej => - `LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"` + `LEFT JOIN "${ej.refTable}" ${ej.alias} ON ${ej.tableAlias}."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"` ).join("\n "); // WHERE 절 구성 @@ -410,6 +414,16 @@ class MasterDetailExcelService { ? `WHERE ${whereConditions.join(" AND ")}` : ""; + // 디테일 테이블의 id 컬럼 존재 여부 확인 (user_info 등 id가 없는 테이블 대응) + const detailIdCheck = await queryOne<{ exists: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'id' + ) as exists`, + [detailTable] + ); + const detailOrderColumn = detailIdCheck?.exists ? `d."id"` : `d."${detailFkColumn}"`; + // JOIN 쿼리 실행 const sql = ` SELECT ${selectClause} @@ -419,7 +433,7 @@ class MasterDetailExcelService { AND m.company_code = d.company_code ${entityJoinClauses} ${whereClause} - ORDER BY m."${masterKeyColumn}", d.id + ORDER BY m."${masterKeyColumn}", ${detailOrderColumn} `; logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params }); @@ -478,14 +492,172 @@ class MasterDetailExcelService { } } + /** + * 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환 + * 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback + */ + private async detectNumberingRuleForColumn( + tableName: string, + columnName: string, + companyCode?: string + ): Promise<{ numberingRuleId: string } | null> { + try { + // 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저) + const companyCondition = companyCode && companyCode !== "*" + ? `AND company_code IN ($3, '*')` + : `AND company_code = '*'`; + const params = companyCode && companyCode !== "*" + ? [tableName, columnName, companyCode] + : [tableName, columnName]; + + const result = await query( + `SELECT input_type, detail_settings, company_code + FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 ${companyCondition} + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + params + ); + + // 채번 타입인 행 찾기 (회사별 우선) + for (const row of result) { + if (row.input_type === "numbering") { + const settings = typeof row.detail_settings === "string" + ? JSON.parse(row.detail_settings || "{}") + : row.detail_settings; + + if (settings?.numberingRuleId) { + return { numberingRuleId: settings.numberingRuleId }; + } + } + } + + return null; + } catch (error) { + logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error); + return null; + } + } + + /** + * 특정 테이블의 모든 채번 컬럼을 한 번에 조회 + * 회사별 설정 우선, 공통(*) 설정 fallback + * @returns Map + */ + private async detectAllNumberingColumns( + tableName: string, + companyCode?: string + ): Promise> { + const numberingCols = new Map(); + try { + const companyCondition = companyCode && companyCode !== "*" + ? `AND company_code IN ($2, '*')` + : `AND company_code = '*'`; + const params = companyCode && companyCode !== "*" + ? [tableName, companyCode] + : [tableName]; + + const result = await query( + `SELECT column_name, detail_settings, company_code + FROM table_type_columns + WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition} + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + params + ); + + // 컬럼별로 회사 설정 우선 적용 + for (const row of result) { + if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵 + const settings = typeof row.detail_settings === "string" + ? JSON.parse(row.detail_settings || "{}") + : row.detail_settings; + if (settings?.numberingRuleId) { + numberingCols.set(row.column_name, settings.numberingRuleId); + } + } + + if (numberingCols.size > 0) { + logger.info(`테이블 ${tableName} 채번 컬럼 감지:`, Object.fromEntries(numberingCols)); + } + } catch (error) { + logger.error(`테이블 ${tableName} 채번 컬럼 감지 실패:`, error); + } + return numberingCols; + } + + /** + * 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용) + * PK가 비즈니스 키이면 사용, auto-increment 'id'만이면 유니크 인덱스 탐색 + * @returns 고유 키 컬럼 배열 (빈 배열이면 매칭 불가 → INSERT만 수행) + */ + private async detectUniqueKeyColumns( + client: any, + tableName: string + ): Promise { + try { + // 1. PK 컬럼 조회 + const pkResult = await client.query( + `SELECT array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum + WHERE n.nspname = 'public' AND t.relname = $1 AND c.contype = 'p'`, + [tableName] + ); + + if (pkResult.rows.length > 0 && pkResult.rows[0].columns) { + const pkCols: string[] = typeof pkResult.rows[0].columns === "string" + ? pkResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim()) + : pkResult.rows[0].columns; + + // PK가 'id' 하나만 있으면 auto-increment이므로 사용 불가 + if (!(pkCols.length === 1 && pkCols[0] === "id")) { + logger.info(`디테일 테이블 ${tableName} 고유 키 (PK): ${pkCols.join(", ")}`); + return pkCols; + } + } + + // 2. PK가 'id'뿐이면 유니크 인덱스 탐색 + const uqResult = await client.query( + `SELECT array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_index ix + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum + WHERE n.nspname = 'public' AND t.relname = $1 + AND ix.indisunique = true AND ix.indisprimary = false + GROUP BY i.relname + LIMIT 1`, + [tableName] + ); + + if (uqResult.rows.length > 0 && uqResult.rows[0].columns) { + const uqCols: string[] = typeof uqResult.rows[0].columns === "string" + ? uqResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim()) + : uqResult.rows[0].columns; + logger.info(`디테일 테이블 ${tableName} 고유 키 (UNIQUE INDEX): ${uqCols.join(", ")}`); + return uqCols; + } + + logger.info(`디테일 테이블 ${tableName} 고유 키 없음 → INSERT 전용`); + return []; + } catch (error) { + logger.error(`디테일 테이블 ${tableName} 고유 키 감지 실패:`, error); + return []; + } + } + /** * 마스터-디테일 데이터 업로드 (엑셀 업로드용) * * 처리 로직: - * 1. 엑셀 데이터를 마스터 키로 그룹화 - * 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT - * 3. 해당 마스터 키의 기존 디테일 삭제 - * 4. 새 디테일 데이터 INSERT + * 1. 마스터 키 컬럼이 채번 타입인지 확인 + * 2-A. 채번인 경우: 다른 마스터 컬럼 값으로 그룹화 → 키 자동 생성 → INSERT + * 2-B. 채번 아닌 경우: 마스터 키 값으로 그룹화 → UPSERT + * 3. 디테일 데이터 개별 행 UPSERT (고유 키 기반) */ async uploadJoinedData( relation: MasterDetailRelation, @@ -498,6 +670,7 @@ class MasterDetailExcelService { masterInserted: 0, masterUpdated: 0, detailInserted: 0, + detailUpdated: 0, detailDeleted: 0, errors: [], }; @@ -510,118 +683,322 @@ class MasterDetailExcelService { const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; - // 1. 데이터를 마스터 키로 그룹화 - const groupedData = new Map[]>(); - - for (const row of data) { - const masterKey = row[masterKeyColumn]; - if (!masterKey) { - result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`); - continue; - } + // 마스터/디테일 테이블의 실제 컬럼 존재 여부 확인 (writer, created_date 등 하드코딩 방지) + const masterColsResult = await client.query( + `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, + [masterTable] + ); + const masterExistingCols = new Set(masterColsResult.rows.map((r: any) => r.column_name)); - if (!groupedData.has(masterKey)) { - groupedData.set(masterKey, []); + const detailColsResult = await client.query( + `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, + [detailTable] + ); + const detailExistingCols = new Set(detailColsResult.rows.map((r: any) => r.column_name)); + + // 마스터 키 컬럼의 채번 규칙 자동 감지 (회사별 설정 우선) + const numberingInfo = await this.detectNumberingRuleForColumn(masterTable, masterKeyColumn, companyCode); + const isAutoNumbering = !!numberingInfo; + + logger.info(`마스터 키 채번 감지:`, { + masterKeyColumn, + isAutoNumbering, + numberingRuleId: numberingInfo?.numberingRuleId + }); + + // 데이터 그룹화 + const groupedData = new Map[]>(); + + if (isAutoNumbering) { + // 채번 모드: 마스터 키 제외한 다른 마스터 컬럼 값으로 그룹화 + const otherMasterCols = masterColumns.filter(c => c.name !== masterKeyColumn).map(c => c.name); + + for (const row of data) { + // 다른 마스터 컬럼 값들을 조합해 그룹 키 생성 + const groupKey = otherMasterCols.map(col => row[col] ?? "").join("|||"); + if (!groupedData.has(groupKey)) { + groupedData.set(groupKey, []); + } + groupedData.get(groupKey)!.push(row); } - groupedData.get(masterKey)!.push(row); + + logger.info(`채번 모드 그룹화 완료: ${groupedData.size}개 그룹 (기준: ${otherMasterCols.join(", ")})`); + } else { + // 일반 모드: 마스터 키 값으로 그룹화 + for (const row of data) { + const masterKey = row[masterKeyColumn]; + if (!masterKey) { + result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`); + continue; + } + if (!groupedData.has(masterKey)) { + groupedData.set(masterKey, []); + } + groupedData.get(masterKey)!.push(row); + } + + logger.info(`일반 모드 그룹화 완료: ${groupedData.size}개 마스터 그룹`); } - logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`); + // 디테일 테이블의 채번 컬럼 사전 감지 (1회 쿼리로 모든 채번 컬럼 조회) + const detailNumberingCols = await this.detectAllNumberingColumns(detailTable, companyCode); + // 마스터 테이블의 비-키 채번 컬럼도 감지 + const masterNumberingCols = await this.detectAllNumberingColumns(masterTable, companyCode); - // 2. 각 그룹 처리 - for (const [masterKey, rows] of groupedData.entries()) { + // 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용) + // PK가 비즈니스 키인 경우 사용, auto-increment 'id'만 있으면 유니크 인덱스 탐색 + const detailUniqueKeyCols = await this.detectUniqueKeyColumns(client, detailTable); + + // 각 그룹 처리 + for (const [groupKey, rows] of groupedData.entries()) { try { - // 2a. 마스터 데이터 추출 (첫 번째 행에서) - const masterData: Record = {}; + // 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키) + let masterKey: string; + let existingMasterKey: string | null = null; + + // 마스터 데이터 추출 (첫 번째 행에서, 키 제외) + const masterDataWithoutKey: Record = {}; for (const col of masterColumns) { + if (col.name === masterKeyColumn) continue; if (rows[0][col.name] !== undefined) { - masterData[col.name] = rows[0][col.name]; + masterDataWithoutKey[col.name] = rows[0][col.name]; } } - // 회사 코드, 작성자 추가 - masterData.company_code = companyCode; - if (userId) { + if (isAutoNumbering) { + // 채번 모드: 동일한 마스터가 이미 DB에 있는지 먼저 확인 + // 마스터 키 제외한 다른 컬럼들로 매칭 (예: dept_name이 같은 부서가 있는지) + const matchCols = Object.keys(masterDataWithoutKey) + .filter(k => k !== "company_code" && k !== "writer" && k !== "created_date" && k !== "updated_date" && k !== "id" + && masterDataWithoutKey[k] !== undefined && masterDataWithoutKey[k] !== null && masterDataWithoutKey[k] !== ""); + + if (matchCols.length > 0) { + const whereClause = matchCols.map((col, i) => `"${col}" = $${i + 1}`).join(" AND "); + const companyIdx = matchCols.length + 1; + const matchResult = await client.query( + `SELECT "${masterKeyColumn}" FROM "${masterTable}" WHERE ${whereClause} AND company_code = $${companyIdx} LIMIT 1`, + [...matchCols.map(k => masterDataWithoutKey[k]), companyCode] + ); + if (matchResult.rows.length > 0) { + existingMasterKey = matchResult.rows[0][masterKeyColumn]; + logger.info(`채번 모드: 기존 마스터 발견 → ${masterKeyColumn}=${existingMasterKey} (매칭: ${matchCols.map(c => `${c}=${masterDataWithoutKey[c]}`).join(", ")})`); + } + } + + if (existingMasterKey) { + // 기존 마스터 사용 (UPDATE) + masterKey = existingMasterKey; + const updateKeys = matchCols.filter(k => k !== masterKeyColumn); + if (updateKeys.length > 0) { + const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`); + const setValues = updateKeys.map(k => masterDataWithoutKey[k]); + const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : ""; + await client.query( + `UPDATE "${masterTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE "${masterKeyColumn}" = $${setValues.length + 1} AND company_code = $${setValues.length + 2}`, + [...setValues, masterKey, companyCode] + ); + } + result.masterUpdated++; + } else { + // 새 마스터 생성 (채번) + masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode); + logger.info(`채번 생성: ${masterKey}`); + } + } else { + masterKey = groupKey; + } + + // 마스터 데이터 조립 + const masterData: Record = {}; + masterData[masterKeyColumn] = masterKey; + Object.assign(masterData, masterDataWithoutKey); + + // 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만) + if (masterExistingCols.has("company_code")) { + masterData.company_code = companyCode; + } + if (userId && masterExistingCols.has("writer")) { masterData.writer = userId; } - // 2b. 마스터 UPSERT - const existingMaster = await client.query( - `SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, - [masterKey, companyCode] - ); - - if (existingMaster.rows.length > 0) { - // UPDATE - const updateCols = Object.keys(masterData) - .filter(k => k !== masterKeyColumn && k !== "id") - .map((k, i) => `"${k}" = $${i + 1}`); - const updateValues = Object.keys(masterData) - .filter(k => k !== masterKeyColumn && k !== "id") - .map(k => masterData[k]); - - if (updateCols.length > 0) { - await client.query( - `UPDATE "${masterTable}" - SET ${updateCols.join(", ")}, updated_date = NOW() - WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`, - [...updateValues, masterKey, companyCode] - ); + // 마스터 비-키 채번 컬럼 자동 생성 (매핑되지 않은 경우) + for (const [colName, ruleId] of masterNumberingCols) { + if (colName === masterKeyColumn) continue; + if (!masterData[colName] || masterData[colName] === "") { + const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode); + masterData[colName] = generatedValue; + logger.info(`마스터 채번 생성: ${masterTable}.${colName} = ${generatedValue}`); } - result.masterUpdated++; - } else { - // INSERT - const insertCols = Object.keys(masterData); - const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); - const insertValues = insertCols.map(k => masterData[k]); - - await client.query( - `INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) - VALUES (${insertPlaceholders.join(", ")}, NOW())`, - insertValues - ); - result.masterInserted++; } - // 2c. 기존 디테일 삭제 - const deleteResult = await client.query( - `DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`, - [masterKey, companyCode] - ); - result.detailDeleted += deleteResult.rowCount || 0; + // INSERT SQL 생성 헬퍼 (created_date 존재 시만 추가) + const buildInsertSQL = (table: string, data: Record, existingCols: Set) => { + const cols = Object.keys(data); + const hasCreatedDate = existingCols.has("created_date"); + const colList = hasCreatedDate ? [...cols, "created_date"] : cols; + const placeholders = cols.map((_, i) => `$${i + 1}`); + const valList = hasCreatedDate ? [...placeholders, "NOW()"] : placeholders; + const values = cols.map(k => data[k]); + return { + sql: `INSERT INTO "${table}" (${colList.map(c => `"${c}"`).join(", ")}) VALUES (${valList.join(", ")})`, + values, + }; + }; - // 2d. 새 디테일 INSERT + if (isAutoNumbering && !existingMasterKey) { + // 채번 모드 + 새 마스터: INSERT + const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols); + await client.query(sql, values); + result.masterInserted++; + } else if (!isAutoNumbering) { + // 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT) + const existingMaster = await client.query( + `SELECT 1 FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + + if (existingMaster.rows.length > 0) { + const updateCols = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map((k, i) => `"${k}" = $${i + 1}`); + const updateValues = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map(k => masterData[k]); + + if (updateCols.length > 0) { + const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : ""; + await client.query( + `UPDATE "${masterTable}" + SET ${updateCols.join(", ")}${updatedDateClause} + WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`, + [...updateValues, masterKey, companyCode] + ); + } + result.masterUpdated++; + } else { + const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols); + await client.query(sql, values); + result.masterInserted++; + } + + } + + // 디테일 개별 행 UPSERT 처리 for (const row of rows) { const detailData: Record = {}; - // FK 컬럼 추가 + // FK 컬럼에 마스터 키 주입 detailData[detailFkColumn] = masterKey; - detailData.company_code = companyCode; - if (userId) { + if (detailExistingCols.has("company_code")) { + detailData.company_code = companyCode; + } + if (userId && detailExistingCols.has("writer")) { detailData.writer = userId; } - // 디테일 컬럼 데이터 추출 + // 디테일 컬럼 데이터 추출 (분할 패널 설정 컬럼 기준) for (const col of detailColumns) { if (row[col.name] !== undefined) { detailData[col.name] = row[col.name]; } } - const insertCols = Object.keys(detailData); - const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); - const insertValues = insertCols.map(k => detailData[k]); + // 분할 패널에 없지만 엑셀에서 매핑된 디테일 컬럼도 포함 + // (user_id 등 화면에 표시되지 않지만 NOT NULL인 컬럼 처리) + const detailColNames = new Set(detailColumns.map(c => c.name)); + const skipCols = new Set([ + detailFkColumn, masterKeyColumn, + "company_code", "writer", "created_date", "updated_date", "id", + ]); + for (const key of Object.keys(row)) { + if (!detailColNames.has(key) && !skipCols.has(key) && detailExistingCols.has(key) && row[key] !== undefined && row[key] !== null && row[key] !== "") { + const isMasterCol = masterColumns.some(mc => mc.name === key); + if (!isMasterCol) { + detailData[key] = row[key]; + } + } + } - await client.query( - `INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) - VALUES (${insertPlaceholders.join(", ")}, NOW())`, - insertValues - ); - result.detailInserted++; + // 디테일 채번 컬럼 자동 생성 (매핑되지 않은 채번 컬럼에 값 주입) + for (const [colName, ruleId] of detailNumberingCols) { + if (!detailData[colName] || detailData[colName] === "") { + const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode); + detailData[colName] = generatedValue; + logger.info(`디테일 채번 생성: ${detailTable}.${colName} = ${generatedValue}`); + } + } + + // 고유 키 기반 UPSERT: 존재하면 UPDATE, 없으면 INSERT + const hasUniqueKey = detailUniqueKeyCols.length > 0; + const uniqueKeyValues = hasUniqueKey + ? detailUniqueKeyCols.map(col => detailData[col]) + : []; + // 고유 키 값이 모두 있어야 매칭 가능 (채번으로 생성된 값도 포함) + const canMatch = hasUniqueKey && uniqueKeyValues.every(v => v !== undefined && v !== null && v !== ""); + + if (canMatch) { + // 기존 행 존재 여부 확인 + const whereClause = detailUniqueKeyCols + .map((col, i) => `"${col}" = $${i + 1}`) + .join(" AND "); + const companyParam = detailExistingCols.has("company_code") + ? ` AND company_code = $${detailUniqueKeyCols.length + 1}` + : ""; + const checkParams = detailExistingCols.has("company_code") + ? [...uniqueKeyValues, companyCode] + : uniqueKeyValues; + + const existingRow = await client.query( + `SELECT 1 FROM "${detailTable}" WHERE ${whereClause}${companyParam} LIMIT 1`, + checkParams + ); + + if (existingRow.rows.length > 0) { + // UPDATE: 고유 키와 시스템 컬럼 제외한 나머지 업데이트 + const updateExclude = new Set([ + ...detailUniqueKeyCols, "id", "company_code", "created_date", + ]); + const updateKeys = Object.keys(detailData).filter(k => !updateExclude.has(k)); + + if (updateKeys.length > 0) { + const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`); + const setValues = updateKeys.map(k => detailData[k]); + const updatedDateClause = detailExistingCols.has("updated_date") ? `, updated_date = NOW()` : ""; + + const whereParams = detailUniqueKeyCols.map((col, i) => `"${col}" = $${setValues.length + i + 1}`); + const companyWhere = detailExistingCols.has("company_code") + ? ` AND company_code = $${setValues.length + detailUniqueKeyCols.length + 1}` + : ""; + const allValues = [ + ...setValues, + ...uniqueKeyValues, + ...(detailExistingCols.has("company_code") ? [companyCode] : []), + ]; + + await client.query( + `UPDATE "${detailTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE ${whereParams.join(" AND ")}${companyWhere}`, + allValues + ); + result.detailUpdated = (result.detailUpdated || 0) + 1; + logger.info(`디테일 UPDATE: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`); + } + } else { + // INSERT: 새로운 행 + const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols); + await client.query(sql, values); + result.detailInserted++; + logger.info(`디테일 INSERT: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`); + } + } else { + // 고유 키가 없거나 값이 없으면 INSERT 전용 + const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols); + await client.query(sql, values); + result.detailInserted++; + } } } catch (error: any) { - result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`); - logger.error(`마스터 키 ${masterKey} 처리 실패:`, error); + result.errors.push(`그룹 처리 실패: ${error.message}`); + logger.error(`그룹 처리 실패:`, error); } } @@ -632,7 +1009,7 @@ class MasterDetailExcelService { masterInserted: result.masterInserted, masterUpdated: result.masterUpdated, detailInserted: result.detailInserted, - detailDeleted: result.detailDeleted, + detailUpdated: result.detailUpdated, errors: result.errors.length, }); diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 9bc59d97..a5abe410 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -60,6 +60,8 @@ export interface ExecutionContext { buttonContext?: ButtonContext; // 🆕 현재 실행 중인 소스 노드의 dataSourceType (context-data | table-all) currentNodeDataSourceType?: string; + // 저장 전 원본 데이터 (after 타이밍에서 DB 기존값 비교용) + originalData?: Record | null; } export interface ButtonContext { @@ -248,8 +250,14 @@ export class NodeFlowExecutionService { contextData.selectedRowsData || contextData.context?.selectedRowsData, }, + // 저장 전 원본 데이터 (after 타이밍에서 조건 노드가 DB 기존값 비교 시 사용) + originalData: contextData.originalData || null, }; + if (context.originalData) { + logger.info(`📦 저장 전 원본 데이터 전달됨 (originalData 필드 수: ${Object.keys(context.originalData).length})`); + } + logger.info(`📦 실행 컨텍스트:`, { dataSourceType: context.dataSourceType, sourceDataCount: context.sourceData?.length || 0, @@ -2830,12 +2838,12 @@ export class NodeFlowExecutionService { inputData: any, context: ExecutionContext ): Promise { - const { conditions, logic } = node.data; + const { conditions, logic, targetLookup } = node.data; logger.info( `🔍 조건 노드 실행 - inputData 타입: ${typeof inputData}, 배열 여부: ${Array.isArray(inputData)}, 길이: ${Array.isArray(inputData) ? inputData.length : "N/A"}` ); - logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}`); + logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}, 타겟조회: ${targetLookup ? targetLookup.tableName : "없음"}`); if (inputData) { console.log( @@ -2865,6 +2873,9 @@ export class NodeFlowExecutionService { // 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기) for (const item of inputData) { + // 타겟 테이블 조회 (DB 기존값 비교용) + const targetRow = await this.lookupTargetRow(targetLookup, item, context); + const results: boolean[] = []; for (const condition of conditions) { @@ -2887,9 +2898,14 @@ export class NodeFlowExecutionService { `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - // 일반 연산자 처리 + // 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값) let compareValue = condition.value; - if (condition.valueType === "field") { + if (condition.valueType === "target" && targetRow) { + compareValue = targetRow[condition.value]; + logger.info( + `🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})` + ); + } else if (condition.valueType === "field") { compareValue = item[condition.value]; logger.info( `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` @@ -2931,6 +2947,9 @@ export class NodeFlowExecutionService { } // 단일 객체인 경우 + // 타겟 테이블 조회 (DB 기존값 비교용) + const targetRow = await this.lookupTargetRow(targetLookup, inputData, context); + const results: boolean[] = []; for (const condition of conditions) { @@ -2953,9 +2972,14 @@ export class NodeFlowExecutionService { `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - // 일반 연산자 처리 + // 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값) let compareValue = condition.value; - if (condition.valueType === "field") { + if (condition.valueType === "target" && targetRow) { + compareValue = targetRow[condition.value]; + logger.info( + `🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})` + ); + } else if (condition.valueType === "field") { compareValue = inputData[condition.value]; logger.info( `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` @@ -2990,6 +3014,71 @@ export class NodeFlowExecutionService { }; } + /** + * 조건 노드의 타겟 테이블 조회 (DB 기존값 비교용) + * targetLookup 설정이 있을 때, 소스 데이터의 키값으로 DB에서 기존 레코드를 조회 + */ + private static async lookupTargetRow( + targetLookup: any, + sourceRow: any, + context: ExecutionContext + ): Promise { + if (!targetLookup?.tableName || !targetLookup?.lookupKeys?.length) { + return null; + } + + try { + // 저장 전 원본 데이터가 있으면 DB 조회 대신 원본 데이터 사용 + // (after 타이밍에서는 DB가 이미 업데이트되어 있으므로 원본 데이터가 필요) + if (context.originalData && Object.keys(context.originalData).length > 0) { + logger.info(`🎯 조건 노드: 저장 전 원본 데이터(originalData) 사용 (DB 조회 스킵)`); + logger.info(`🎯 originalData 필드: ${Object.keys(context.originalData).join(", ")}`); + return context.originalData; + } + + const whereConditions = targetLookup.lookupKeys + .map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`) + .join(" AND "); + + const lookupValues = targetLookup.lookupKeys.map( + (key: any) => sourceRow[key.sourceField] + ); + + // 키값이 비어있으면 조회 불필요 + if (lookupValues.some((v: any) => v === null || v === undefined || v === "")) { + logger.info(`⚠️ 조건 노드 타겟 조회: 키값이 비어있어 스킵`); + return null; + } + + // company_code 필터링 (멀티테넌시) + const companyCode = context.buttonContext?.companyCode || sourceRow.company_code; + let sql = `SELECT * FROM "${targetLookup.tableName}" WHERE ${whereConditions}`; + const params = [...lookupValues]; + + if (companyCode && companyCode !== "*") { + sql += ` AND company_code = $${params.length + 1}`; + params.push(companyCode); + } + + sql += " LIMIT 1"; + + logger.info(`🎯 조건 노드 타겟 조회: ${targetLookup.tableName}, 조건: ${whereConditions}, 값: ${JSON.stringify(lookupValues)}`); + + const targetRow = await queryOne(sql, params); + + if (targetRow) { + logger.info(`🎯 타겟 데이터 조회 성공`); + } else { + logger.info(`🎯 타겟 데이터 없음 (신규 레코드)`); + } + + return targetRow; + } catch (error: any) { + logger.warn(`⚠️ 조건 노드 타겟 조회 실패: ${error.message}`); + return null; + } + } + /** * EXISTS_IN / NOT_EXISTS_IN 조건 평가 * 다른 테이블에 값이 존재하는지 확인 diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 4f5bf1e9..a8765d18 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -885,9 +885,9 @@ class NumberingRuleService { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); - const parts = rule.parts + const parts = await Promise.all(rule.parts .sort((a: any, b: any) => a.order - b.order) - .map((part: any) => { + .map(async (part: any) => { if (part.generationMethod === "manual") { // 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리) // placeholder 텍스트는 프론트엔드에서 별도로 표시 @@ -982,17 +982,52 @@ class NumberingRuleService { // 카테고리 매핑에서 해당 값에 대한 형식 찾기 // selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용) const selectedValueStr = String(selectedValue); - const mapping = categoryMappings.find((m: any) => { - // ID로 매칭 + let mapping = categoryMappings.find((m: any) => { + // ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우) if (m.categoryValueId?.toString() === selectedValueStr) return true; - // 라벨로 매칭 - if (m.categoryValueLabel === selectedValueStr) return true; - // valueCode로 매칭 (라벨과 동일할 수 있음) + // valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우) + if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) + return true; + // 라벨로 매칭 (폴백) if (m.categoryValueLabel === selectedValueStr) return true; return false; }); + // 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도 + if (!mapping) { + try { + const pool = getPool(); + const [catTableName, catColumnName] = categoryKey.includes(".") + ? categoryKey.split(".") + : [categoryKey, categoryKey]; + const cvResult = await pool.query( + `SELECT value_id, value_code, value_label FROM category_values + WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, + [catTableName, catColumnName, selectedValueStr] + ); + if (cvResult.rows.length > 0) { + const resolvedId = cvResult.rows[0].value_id; + const resolvedLabel = cvResult.rows[0].value_label; + mapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === String(resolvedId)) return true; + if (m.categoryValueLabel === resolvedLabel) return true; + return false; + }); + if (mapping) { + logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", { + valueCode: selectedValueStr, + resolvedId, + resolvedLabel, + format: mapping.format, + }); + } + } + } catch (lookupError: any) { + logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message }); + } + } + if (mapping) { logger.info("카테고리 매핑 적용", { selectedValue, @@ -1016,7 +1051,7 @@ class NumberingRuleService { logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; } - }); + })); const previewCode = parts.join(rule.separator || ""); logger.info("코드 미리보기 생성", { @@ -1059,9 +1094,9 @@ class NumberingRuleService { if (manualParts.length > 0 && userInputCode) { // 프리뷰 코드를 생성해서 ____ 위치 파악 // 🔧 category 파트도 처리하여 올바른 템플릿 생성 - const previewParts = rule.parts + const previewParts = await Promise.all(rule.parts .sort((a: any, b: any) => a.order - b.order) - .map((part: any) => { + .map(async (part: any) => { if (part.generationMethod === "manual") { return "____"; } @@ -1077,36 +1112,57 @@ class NumberingRuleService { return "DATEPART"; // 날짜 자리 표시 case "category": { // 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용 - const categoryKey = autoConfig.categoryKey; - const categoryMappings = autoConfig.categoryMappings || []; + const catKey2 = autoConfig.categoryKey; + const catMappings2 = autoConfig.categoryMappings || []; - if (!categoryKey || !formData) { + if (!catKey2 || !formData) { return "CATEGORY"; // 폴백 } - const columnName = categoryKey.includes(".") - ? categoryKey.split(".")[1] - : categoryKey; - const selectedValue = formData[columnName]; + const colName2 = catKey2.includes(".") + ? catKey2.split(".")[1] + : catKey2; + const selVal2 = formData[colName2]; - if (!selectedValue) { + if (!selVal2) { return "CATEGORY"; // 폴백 } - const selectedValueStr = String(selectedValue); - const mapping = categoryMappings.find((m: any) => { - if (m.categoryValueId?.toString() === selectedValueStr) - return true; - if (m.categoryValueLabel === selectedValueStr) return true; + const selValStr2 = String(selVal2); + let catMapping2 = catMappings2.find((m: any) => { + if (m.categoryValueId?.toString() === selValStr2) return true; + if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true; + if (m.categoryValueLabel === selValStr2) return true; return false; }); - return mapping?.format || "CATEGORY"; + // valueCode → valueId 역변환 시도 + if (!catMapping2) { + try { + const pool2 = getPool(); + const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2]; + const cvr2 = await pool2.query( + `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, + [ct2, cc2, selValStr2] + ); + if (cvr2.rows.length > 0) { + const rid2 = cvr2.rows[0].value_id; + const rlabel2 = cvr2.rows[0].value_label; + catMapping2 = catMappings2.find((m: any) => { + if (m.categoryValueId?.toString() === String(rid2)) return true; + if (m.categoryValueLabel === rlabel2) return true; + return false; + }); + } + } catch { /* ignore */ } + } + + return catMapping2?.format || "CATEGORY"; } default: return ""; } - }); + })); const separator = rule.separator || ""; const previewTemplate = previewParts.join(separator); @@ -1150,9 +1206,9 @@ class NumberingRuleService { } let manualPartIndex = 0; - const parts = rule.parts + const parts = await Promise.all(rule.parts .sort((a: any, b: any) => a.order - b.order) - .map((part: any) => { + .map(async (part: any) => { if (part.generationMethod === "manual") { // 추출된 수동 입력 값 사용, 없으면 기본값 사용 const manualValue = @@ -1267,28 +1323,53 @@ class NumberingRuleService { // 카테고리 매핑에서 해당 값에 대한 형식 찾기 const selectedValueStr = String(selectedValue); - const mapping = categoryMappings.find((m: any) => { - // ID로 매칭 - if (m.categoryValueId?.toString() === selectedValueStr) - return true; - // 라벨로 매칭 + let allocMapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === selectedValueStr) return true; + if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true; if (m.categoryValueLabel === selectedValueStr) return true; return false; }); - if (mapping) { + // valueCode → valueId 역변환 시도 + if (!allocMapping) { + try { + const pool3 = getPool(); + const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey]; + const cvr3 = await pool3.query( + `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, + [ct3, cc3, selectedValueStr] + ); + if (cvr3.rows.length > 0) { + const rid3 = cvr3.rows[0].value_id; + const rlabel3 = cvr3.rows[0].value_label; + allocMapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === String(rid3)) return true; + if (m.categoryValueLabel === rlabel3) return true; + return false; + }); + if (allocMapping) { + logger.info("allocateCode: 카테고리 매핑 역변환 성공", { + valueCode: selectedValueStr, resolvedId: rid3, format: allocMapping.format, + }); + } + } + } catch { /* ignore */ } + } + + if (allocMapping) { logger.info("allocateCode: 카테고리 매핑 적용", { selectedValue, - format: mapping.format, - categoryValueLabel: mapping.categoryValueLabel, + format: allocMapping.format, + categoryValueLabel: allocMapping.categoryValueLabel, }); - return mapping.format || ""; + return allocMapping.format || ""; } logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", { selectedValue, availableMappings: categoryMappings.map((m: any) => ({ id: m.categoryValueId, + code: m.categoryValueCode, label: m.categoryValueLabel, })), }); @@ -1299,7 +1380,7 @@ class NumberingRuleService { logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; } - }); + })); const allocatedCode = parts.join(rule.separator || ""); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 37a21a0a..87e2ece6 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1739,7 +1739,7 @@ export class ScreenManagementService { // V2 레이아웃이 있으면 V2 형식으로 반환 if (v2Layout && v2Layout.layout_data) { - console.log(`V2 레이아웃 발견, V2 형식으로 반환`); + const layoutData = v2Layout.layout_data; // URL에서 컴포넌트 타입 추출하는 헬퍼 함수 @@ -1799,7 +1799,7 @@ export class ScreenManagementService { }; } - console.log(`V2 레이아웃 없음, V1 테이블 조회`); + const layouts = await query( `SELECT * FROM screen_layouts @@ -4245,16 +4245,16 @@ export class ScreenManagementService { }, ); - // V2 레이아웃 저장 (UPSERT) + // V2 레이아웃 저장 (UPSERT) - layer_id 포함 await client.query( - `INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at) - VALUES ($1, $2, $3, NOW(), NOW()) - ON CONFLICT (screen_id, company_code) + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at) + VALUES ($1, $2, 1, $3, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE SET layout_data = $3, updated_at = NOW()`, [newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)], ); - console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`); + } catch (error) { console.error("V2 레이아웃 복사 중 오류:", error); // 레이아웃 복사 실패해도 화면 생성은 유지 @@ -5045,8 +5045,7 @@ export class ScreenManagementService { companyCode: string, userType?: string, ): Promise { - console.log(`=== V2 레이아웃 로드 시작 ===`); - console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`); + // SUPER_ADMIN 여부 확인 const isSuperAdmin = userType === "SUPER_ADMIN"; @@ -5073,67 +5072,94 @@ export class ScreenManagementService { let layout: { layout_data: any } | null = null; + // 🆕 기본 레이어(layer_id=1)를 우선 로드 // SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회 if (isSuperAdmin) { - // 1. 화면 정의의 회사 코드로 레이아웃 조회 + // 1. 화면 정의의 회사 코드 + 기본 레이어 layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 - WHERE screen_id = $1 AND company_code = $2`, + WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`, [screenId, existingScreen.company_code], ); - // 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회 + // 2. 기본 레이어 없으면 layer_id 조건 없이 조회 (하위 호환) + if (!layout) { + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 + ORDER BY layer_id ASC + LIMIT 1`, + [screenId, existingScreen.company_code], + ); + } + + // 3. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 if (!layout) { layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 - ORDER BY updated_at DESC + ORDER BY layer_id ASC LIMIT 1`, [screenId], ); } } else { - // 일반 사용자: 기존 로직 (회사별 우선, 없으면 공통(*) 조회) + // 일반 사용자: 회사별 우선 + 기본 레이어 layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 - WHERE screen_id = $1 AND company_code = $2`, + WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`, [screenId, companyCode], ); + // 회사별 기본 레이어 없으면 layer_id 조건 없이 (하위 호환) + if (!layout) { + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 + ORDER BY layer_id ASC + LIMIT 1`, + [screenId, companyCode], + ); + } + // 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회 if (!layout && companyCode !== "*") { layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 - WHERE screen_id = $1 AND company_code = '*'`, + WHERE screen_id = $1 AND company_code = '*' + ORDER BY layer_id ASC + LIMIT 1`, [screenId], ); } } if (!layout) { - console.log(`V2 레이아웃 없음: screen_id=${screenId}`); + return null; } - console.log( - `V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`, - ); + return layout.layout_data; } /** - * V2 레이아웃 저장 (1 레코드 방식) - * - screen_layouts_v2 테이블에 화면당 1개 레코드 저장 - * - layout_data JSON에 모든 컴포넌트 포함 + * V2 레이아웃 저장 (레이어별 저장) + * - screen_layouts_v2 테이블에 화면당 레이어별 1개 레코드 저장 + * - layout_data JSON에 해당 레이어의 컴포넌트 포함 */ async saveLayoutV2( screenId: number, layoutData: any, companyCode: string, ): Promise { - console.log(`=== V2 레이아웃 저장 시작 ===`); - console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); - console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`); + const layerId = layoutData.layerId || 1; + const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`); + // conditionConfig가 명시적으로 전달되었는지 확인 (undefined = 미전달, null/object = 명시적 전달) + const hasConditionConfig = 'conditionConfig' in layoutData; + const conditionConfig = layoutData.conditionConfig || null; + + // 권한 확인 const screens = await query<{ company_code: string | null }>( @@ -5151,22 +5177,666 @@ export class ScreenManagementService { throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다."); } - // 버전 정보 추가 (updatedAt은 DB 컬럼 updated_at으로 관리) + // 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장) + const { layerId: _lid, layerName: _ln, conditionConfig: _cc, ...pureLayoutData } = layoutData; const dataToSave = { version: "2.0", - ...layoutData + ...pureLayoutData, }; + if (hasConditionConfig) { + // conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장 + await query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) + DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`, + [screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)], + ); + } else { + // conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트 + await query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) + DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`, + [screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)], + ); + } + + + } + + /** + * 화면의 모든 레이어 목록 조회 + * 레이어가 없으면 기본 레이어를 자동 생성 + */ + async getScreenLayers( + screenId: number, + companyCode: string, + ): Promise { + let layers; + + if (companyCode === "*") { + layers = await query( + `SELECT layer_id, layer_name, condition_config, + jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count, + updated_at + FROM screen_layouts_v2 + WHERE screen_id = $1 + ORDER BY layer_id`, + [screenId], + ); + } else { + layers = await query( + `SELECT layer_id, layer_name, condition_config, + jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count, + updated_at + FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 + ORDER BY layer_id`, + [screenId, companyCode], + ); + + // 회사별 레이어가 없으면 공통(*) 레이어 조회 + if (layers.length === 0 && companyCode !== "*") { + layers = await query( + `SELECT layer_id, layer_name, condition_config, + jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count, + updated_at + FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = '*' + ORDER BY layer_id`, + [screenId], + ); + } + } + + // 레이어가 없으면 기본 레이어 자동 생성 + if (layers.length === 0) { + const defaultLayout = JSON.stringify({ version: "2.0", components: [] }); + await query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at) + VALUES ($1, $2, 1, '기본 레이어', $3, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) DO NOTHING`, + [screenId, companyCode, defaultLayout], + ); + console.log(`기본 레이어 자동 생성: screen_id=${screenId}, company_code=${companyCode}`); + + // 다시 조회 + layers = await query( + `SELECT layer_id, layer_name, condition_config, + jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count, + updated_at + FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 + ORDER BY layer_id`, + [screenId, companyCode], + ); + } + + return layers; + } + + /** + * 특정 레이어의 레이아웃 조회 + */ + async getLayerLayout( + screenId: number, + layerId: number, + companyCode: string, + ): Promise { + let layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>( + `SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`, + [screenId, companyCode, layerId], + ); + + // 회사별 레이어가 없으면 공통(*) 조회 + if (!layout && companyCode !== "*") { + layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>( + `SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = '*' AND layer_id = $2`, + [screenId, layerId], + ); + } + + if (!layout) return null; + + return { + ...layout.layout_data, + layerId, + layerName: layout.layer_name, + conditionConfig: layout.condition_config, + }; + } + + /** + * 레이어 삭제 + */ + async deleteLayer( + screenId: number, + layerId: number, + companyCode: string, + ): Promise { + if (layerId === 1) { + throw new Error("기본 레이어는 삭제할 수 없습니다."); + } + + await query( + `DELETE FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`, + [screenId, companyCode, layerId], + ); + + console.log(`레이어 삭제 완료: screen_id=${screenId}, layer_id=${layerId}`); + } + + /** + * 레이어 조건 설정 업데이트 + */ + async updateLayerCondition( + screenId: number, + layerId: number, + companyCode: string, + conditionConfig: any, + layerName?: string, + ): Promise { + const setClauses = ['condition_config = $4', 'updated_at = NOW()']; + const params: any[] = [screenId, companyCode, layerId, conditionConfig ? JSON.stringify(conditionConfig) : null]; + + if (layerName) { + setClauses.push(`layer_name = $${params.length + 1}`); + params.push(layerName); + } + + await query( + `UPDATE screen_layouts_v2 SET ${setClauses.join(', ')} + WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`, + params, + ); + } + + // ======================================== + // 조건부 영역(Zone) 관리 + // ======================================== + + /** + * 화면의 조건부 영역(Zone) 목록 조회 + */ + async getScreenZones(screenId: number, companyCode: string): Promise { + let zones; + if (companyCode === "*") { + // 최고 관리자: 모든 회사 Zone 조회 가능 + zones = await query( + `SELECT * FROM screen_conditional_zones WHERE screen_id = $1 ORDER BY zone_id`, + [screenId], + ); + } else { + // 일반 회사: 자사 Zone + 공통(*) Zone 조회 + zones = await query( + `SELECT * FROM screen_conditional_zones + WHERE screen_id = $1 AND (company_code = $2 OR company_code = '*') + ORDER BY zone_id`, + [screenId, companyCode], + ); + } + return zones; + } + + /** + * 조건부 영역(Zone) 생성 + */ + async createZone( + screenId: number, + companyCode: string, + zoneData: { + zone_name?: string; + x: number; + y: number; + width: number; + height: number; + trigger_component_id?: string; + trigger_operator?: string; + }, + ): Promise { + const result = await queryOne( + `INSERT INTO screen_conditional_zones + (screen_id, company_code, zone_name, x, y, width, height, trigger_component_id, trigger_operator) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + screenId, + companyCode, + zoneData.zone_name || '조건부 영역', + zoneData.x, + zoneData.y, + zoneData.width, + zoneData.height, + zoneData.trigger_component_id || null, + zoneData.trigger_operator || 'eq', + ], + ); + return result; + } + + /** + * 조건부 영역(Zone) 업데이트 (위치/크기/트리거) + */ + async updateZone( + zoneId: number, + companyCode: string, + updates: { + zone_name?: string; + x?: number; + y?: number; + width?: number; + height?: number; + trigger_component_id?: string; + trigger_operator?: string; + }, + ): Promise { + const setClauses: string[] = ['updated_at = NOW()']; + const params: any[] = [zoneId, companyCode]; + let paramIdx = 3; + + for (const [key, value] of Object.entries(updates)) { + if (value !== undefined) { + setClauses.push(`${key} = $${paramIdx}`); + params.push(value); + paramIdx++; + } + } + + await query( + `UPDATE screen_conditional_zones SET ${setClauses.join(', ')} + WHERE zone_id = $1 AND company_code = $2`, + params, + ); + } + + /** + * 조건부 영역(Zone) 삭제 + 소속 레이어들의 condition_config 정리 + */ + async deleteZone(zoneId: number, companyCode: string): Promise { + // Zone에 소속된 레이어들의 condition_config에서 zone_id 제거 + await query( + `UPDATE screen_layouts_v2 SET condition_config = NULL, updated_at = NOW() + WHERE company_code = $1 AND condition_config->>'zone_id' = $2::text`, + [companyCode, String(zoneId)], + ); + + await query( + `DELETE FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`, + [zoneId, companyCode], + ); + } + + /** + * Zone에 레이어 추가 (빈 레이아웃으로 새 레이어 생성 + zone_id 할당) + */ + async addLayerToZone( + screenId: number, + companyCode: string, + zoneId: number, + conditionValue: string, + layerName?: string, + ): Promise<{ layerId: number }> { + // 다음 layer_id 계산 + const maxResult = await queryOne<{ max_id: number }>( + `SELECT COALESCE(MAX(layer_id), 1) as max_id FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + const newLayerId = (maxResult?.max_id || 1) + 1; + + // Zone 정보로 캔버스 크기 결정 (company_code 필터링 필수) + const zone = await queryOne( + `SELECT * FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`, + [zoneId, companyCode], + ); + + const layoutData = { + version: "2.1", + components: [], + screenResolution: zone + ? { width: zone.width, height: zone.height } + : { width: 800, height: 200 }, + }; + + const conditionConfig = { + zone_id: zoneId, + condition_value: conditionValue, + }; + + await query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, layer_id, layer_name, condition_config) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE + SET layout_data = EXCLUDED.layout_data, + layer_name = EXCLUDED.layer_name, + condition_config = EXCLUDED.condition_config, + updated_at = NOW()`, + [screenId, companyCode, JSON.stringify(layoutData), newLayerId, layerName || `레이어 ${newLayerId}`, JSON.stringify(conditionConfig)], + ); + + return { layerId: newLayerId }; + } + + // ======================================== + // POP 레이아웃 관리 (모바일/태블릿) + // v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로) + // ======================================== + + /** + * POP v1 → v2 마이그레이션 (백엔드) + * - 단일 sections 배열 → 4모드별 layouts + 공유 sections/components + */ + private migratePopV1ToV2(v1Data: any): any { + console.log("POP v1 → v2 마이그레이션 시작"); + + // 기본 v2 구조 + const v2Data: any = { + version: "pop-2.0", + layouts: { + tablet_landscape: { sectionPositions: {}, componentPositions: {} }, + tablet_portrait: { sectionPositions: {}, componentPositions: {} }, + mobile_landscape: { sectionPositions: {}, componentPositions: {} }, + mobile_portrait: { sectionPositions: {}, componentPositions: {} }, + }, + sections: {}, + components: {}, + dataFlow: { + sectionConnections: [], + }, + settings: { + touchTargetMin: 48, + mode: "normal", + canvasGrid: v1Data.canvasGrid || { columns: 24, rowHeight: 20, gap: 4 }, + }, + metadata: v1Data.metadata, + }; + + // v1 섹션 배열 처리 + const sections = v1Data.sections || []; + const modeKeys = ["tablet_landscape", "tablet_portrait", "mobile_landscape", "mobile_portrait"]; + + for (const section of sections) { + // 섹션 정의 생성 + v2Data.sections[section.id] = { + id: section.id, + label: section.label, + componentIds: (section.components || []).map((c: any) => c.id), + innerGrid: section.innerGrid || { columns: 3, rows: 3, gap: 4 }, + style: section.style, + }; + + // 섹션 위치 복사 (4모드 모두 동일) + const sectionPos = section.grid || { col: 1, row: 1, colSpan: 3, rowSpan: 4 }; + for (const mode of modeKeys) { + v2Data.layouts[mode].sectionPositions[section.id] = { ...sectionPos }; + } + + // 컴포넌트별 처리 + for (const comp of section.components || []) { + // 컴포넌트 정의 생성 + v2Data.components[comp.id] = { + id: comp.id, + type: comp.type, + label: comp.label, + dataBinding: comp.dataBinding, + style: comp.style, + config: comp.config, + }; + + // 컴포넌트 위치 복사 (4모드 모두 동일) + const compPos = comp.grid || { col: 1, row: 1, colSpan: 1, rowSpan: 1 }; + for (const mode of modeKeys) { + v2Data.layouts[mode].componentPositions[comp.id] = { ...compPos }; + } + } + } + + const sectionCount = Object.keys(v2Data.sections).length; + const componentCount = Object.keys(v2Data.components).length; + console.log(`POP v1 → v2 마이그레이션 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); + + return v2Data; + } + + /** + * POP 레이아웃 조회 + * - screen_layouts_pop 테이블에서 화면당 1개 레코드 조회 + * - v1 데이터는 자동으로 v2로 마이그레이션하여 반환 + */ + async getLayoutPop( + screenId: number, + companyCode: string, + userType?: string, + ): Promise { + console.log(`=== POP 레이아웃 로드 시작 ===`); + console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`); + + // SUPER_ADMIN 여부 확인 + const isSuperAdmin = userType === "SUPER_ADMIN"; + + // 권한 확인 + const screens = await query<{ + company_code: string | null; + table_name: string | null; + }>( + `SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId], + ); + + if (screens.length === 0) { + return null; + } + + const existingScreen = screens[0]; + + // SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음 + if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) { + throw new Error("이 화면의 POP 레이아웃을 조회할 권한이 없습니다."); + } + + let layout: { layout_data: any } | null = null; + + // SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회 + if (isSuperAdmin) { + // 1. 화면 정의의 회사 코드로 레이아웃 조회 + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = $2`, + [screenId, existingScreen.company_code], + ); + + // 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회 + if (!layout) { + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 + ORDER BY updated_at DESC + LIMIT 1`, + [screenId], + ); + } + } else { + // 일반 사용자: 회사별 우선, 없으면 공통(*) 조회 + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + + // 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회 + if (!layout && companyCode !== "*") { + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = '*'`, + [screenId], + ); + } + } + + if (!layout) { + console.log(`POP 레이아웃 없음: screen_id=${screenId}`); + return null; + } + + const layoutData = layout.layout_data; + + // v1 → v2 자동 마이그레이션 + if (layoutData && layoutData.version === "pop-1.0") { + console.log("POP v1 레이아웃 감지, v2로 마이그레이션"); + return this.migratePopV1ToV2(layoutData); + } + + // v2 또는 버전 태그 없는 경우 (버전 태그 없으면 sections 구조 확인) + if (layoutData && !layoutData.version && layoutData.sections && Array.isArray(layoutData.sections)) { + console.log("버전 태그 없는 v1 레이아웃 감지, v2로 마이그레이션"); + return this.migratePopV1ToV2({ ...layoutData, version: "pop-1.0" }); + } + + // v2 레이아웃 그대로 반환 + const sectionCount = layoutData?.sections ? Object.keys(layoutData.sections).length : 0; + const componentCount = layoutData?.components ? Object.keys(layoutData.components).length : 0; + console.log(`POP v2 레이아웃 로드 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); + + return layoutData; + } + + /** + * POP 레이아웃 저장 + * - screen_layouts_pop 테이블에 화면당 1개 레코드 저장 + * - v3 형식 지원 (version: "pop-3.0", 섹션 제거) + * - v2/v1 하위 호환 + */ + async saveLayoutPop( + screenId: number, + layoutData: any, + companyCode: string, + userId?: string, + ): Promise { + console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`); + console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); + + // v5 그리드 레이아웃만 지원 + const componentCount = Object.keys(layoutData.components || {}).length; + console.log(`컴포넌트: ${componentCount}개`); + + // v5 형식 검증 + if (layoutData.version && layoutData.version !== "pop-5.0") { + console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`); + } + + // 권한 확인 + const screens = await query<{ company_code: string | null }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId], + ); + + if (screens.length === 0) { + throw new Error("화면을 찾을 수 없습니다."); + } + + const existingScreen = screens[0]; + + if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다."); + } + + // SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 (로드와 동일하게) + const targetCompanyCode = companyCode === "*" + ? (existingScreen.company_code || "*") + : companyCode; + + console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`); + + // v5 그리드 레이아웃으로 저장 (단일 버전) + const dataToSave = { + ...layoutData, + version: "pop-5.0", + }; + console.log(`저장: gridConfig=${JSON.stringify(dataToSave.gridConfig || 'default')}`) + // UPSERT (있으면 업데이트, 없으면 삽입) await query( - `INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at) - VALUES ($1, $2, $3, NOW(), NOW()) + `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()`, - [screenId, companyCode, JSON.stringify(dataToSave)], + DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`, + [screenId, targetCompanyCode, JSON.stringify(dataToSave), userId || null], ); - console.log(`V2 레이아웃 저장 완료`); + console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version}, company: ${targetCompanyCode})`); + } + + /** + * POP 레이아웃이 존재하는 화면 ID 목록 조회 + * - 옵션 B: POP 레이아웃 존재 여부로 화면 구분 + */ + async getScreenIdsWithPopLayout( + companyCode: string, + ): Promise { + console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`); + console.log(`회사 코드: ${companyCode}`); + + let result: { screen_id: number }[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 POP 레이아웃 조회 + result = await query<{ screen_id: number }>( + `SELECT DISTINCT screen_id FROM screen_layouts_pop`, + [], + ); + } else { + // 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회 + result = await query<{ screen_id: number }>( + `SELECT DISTINCT screen_id FROM screen_layouts_pop + WHERE company_code = $1 OR company_code = '*'`, + [companyCode], + ); + } + + const screenIds = result.map((r) => r.screen_id); + console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}개`); + return screenIds; + } + + /** + * POP 레이아웃 삭제 + */ + async deleteLayoutPop( + screenId: number, + companyCode: string, + ): Promise { + console.log(`=== POP 레이아웃 삭제 시작 ===`); + console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); + + // 권한 확인 + const screens = await query<{ company_code: string | null }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId], + ); + + if (screens.length === 0) { + throw new Error("화면을 찾을 수 없습니다."); + } + + const existingScreen = screens[0]; + + if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + throw new Error("이 화면의 POP 레이아웃을 삭제할 권한이 없습니다."); + } + + const result = await query( + `DELETE FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + + console.log(`POP 레이아웃 삭제 완료`); + return true; } } diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 2eb35f64..dd2f73a9 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1371,39 +1371,66 @@ class TableCategoryValueService { const pool = getPool(); - // 동적으로 파라미터 플레이스홀더 생성 - const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", "); + const n = valueCodes.length; + + // 첫 번째 쿼리용 플레이스홀더: $1 ~ $n + const placeholders1 = valueCodes.map((_, i) => `$${i + 1}`).join(", "); let query: string; let params: any[]; if (companyCode === "*") { - // 최고 관리자: 모든 카테고리 값 조회 + // 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합) + // 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n + const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", "); query = ` - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders}) - AND is_active = true + SELECT value_code, value_label FROM ( + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders1}) + AND is_active = true + UNION ALL + SELECT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders2}) + AND is_active = true + ) combined `; - params = valueCodes; + params = [...valueCodes, ...valueCodes]; } else { - // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 + // 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회 + // 첫 번째: $1~$n (valueCodes), $n+1 (companyCode) + // 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode) + const companyIdx1 = n + 1; + const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", "); + const companyIdx2 = 2 * n + 2; + query = ` - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders}) - AND is_active = true - AND (company_code = $${valueCodes.length + 1} OR company_code = '*') + SELECT value_code, value_label FROM ( + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders1}) + AND is_active = true + AND (company_code = $${companyIdx1} OR company_code = '*') + UNION ALL + SELECT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders2}) + AND is_active = true + AND (company_code = $${companyIdx2} OR company_code = '*') + ) combined `; - params = [...valueCodes, companyCode]; + params = [...valueCodes, companyCode, ...valueCodes, companyCode]; } const result = await pool.query(query, params); - // { [code]: label } 형태로 변환 + // { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선) const labels: Record = {}; for (const row of result.rows) { - labels[row.value_code] = row.value_label; + if (!labels[row.value_code]) { + labels[row.value_code] = row.value_label; + } } logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode }); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index db5f32ed..6e0f3944 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2875,10 +2875,11 @@ export class TableManagementService { }; } - // Entity 조인 설정 감지 (화면별 엔티티 설정 전달) + // Entity 조인 설정 감지 (화면별 엔티티 설정 + 회사코드 전달) let joinConfigs = await entityJoinService.detectEntityJoins( tableName, - options.screenEntityConfigs + options.screenEntityConfigs, + options.companyCode ); logger.info( @@ -2978,31 +2979,49 @@ export class TableManagementService { continue; // 기본 Entity 조인과 중복되면 추가하지 않음 } - // 추가 조인 컬럼 설정 생성 - const additionalJoinConfig: EntityJoinConfig = { - sourceTable: tableName, - sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id) - referenceTable: - (additionalColumn as any).referenceTable || - baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng) - referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code) - displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name) - displayColumn: actualColumnName, // 하위 호환성 - aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name) - separator: " - ", // 기본 구분자 - }; - - joinConfigs.push(additionalJoinConfig); - logger.info( - `✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}` + // 🆕 같은 sourceColumn + referenceTable 조합의 기존 config가 있으면 displayColumns에 병합 + const existingConfig = joinConfigs.find( + (config) => + config.sourceColumn === sourceColumn && + config.referenceTable === ((additionalColumn as any).referenceTable || baseJoinConfig.referenceTable) ); - logger.info(`🔍 추가된 조인 설정 상세:`, { - sourceTable: additionalJoinConfig.sourceTable, - sourceColumn: additionalJoinConfig.sourceColumn, - referenceTable: additionalJoinConfig.referenceTable, - displayColumns: additionalJoinConfig.displayColumns, - aliasColumn: additionalJoinConfig.aliasColumn, - }); + + if (existingConfig) { + // 기존 config에 display column 추가 (중복 방지) + if (!existingConfig.displayColumns?.includes(actualColumnName)) { + existingConfig.displayColumns = existingConfig.displayColumns || []; + existingConfig.displayColumns.push(actualColumnName); + logger.info( + `🔄 기존 조인 설정에 컬럼 병합: ${existingConfig.aliasColumn} ← ${actualColumnName} (총 ${existingConfig.displayColumns.length}개)` + ); + } + } else { + // 새 조인 설정 생성 + const additionalJoinConfig: EntityJoinConfig = { + sourceTable: tableName, + sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id) + referenceTable: + (additionalColumn as any).referenceTable || + baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng) + referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code) + displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name) + displayColumn: actualColumnName, // 하위 호환성 + aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name) + separator: " - ", // 기본 구분자 + }; + + joinConfigs.push(additionalJoinConfig); + logger.info( + `✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}` + ); + logger.info(`🔍 추가된 조인 설정 상세:`, { + sourceTable: additionalJoinConfig.sourceTable, + sourceColumn: additionalJoinConfig.sourceColumn, + referenceTable: additionalJoinConfig.referenceTable, + displayColumns: additionalJoinConfig.displayColumns, + aliasColumn: additionalJoinConfig.aliasColumn, + }); + } } } } @@ -3258,6 +3277,28 @@ export class TableManagementService { startTime: number ): Promise { try { + // 🆕 참조 테이블별 전체 컬럼 목록 미리 조회 + const referenceTableColumns = new Map(); + const uniqueRefTables = new Set( + joinConfigs + .filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외 + .map((c) => `${c.referenceTable}:${c.sourceColumn}`) + ); + + for (const key of uniqueRefTables) { + const refTable = key.split(":")[0]; + if (!referenceTableColumns.has(key)) { + const cols = await query<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND table_schema = 'public' + ORDER BY ordinal_position`, + [refTable] + ); + referenceTableColumns.set(key, cols.map((c) => c.column_name)); + logger.info(`🔍 참조 테이블 컬럼 조회: ${refTable} → ${cols.length}개`); + } + } + // 데이터 조회 쿼리 const dataQuery = entityJoinService.buildJoinQuery( tableName, @@ -3266,7 +3307,9 @@ export class TableManagementService { whereClause, orderBy, limit, - offset + offset, + undefined, + referenceTableColumns // 🆕 참조 테이블 전체 컬럼 전달 ).query; // 카운트 쿼리 @@ -3767,12 +3810,12 @@ export class TableManagementService { reference_table: string; reference_column: string; }>( - `SELECT column_name, reference_table, reference_column + `SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column FROM table_type_columns WHERE table_name = $1 AND input_type = 'entity' AND reference_table = $2 - AND company_code = '*' + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END LIMIT 1`, [tableName, refTable] ); @@ -3883,7 +3926,7 @@ export class TableManagementService { /** * 참조 테이블의 표시 컬럼 목록 조회 */ - async getReferenceTableColumns(tableName: string): Promise< + async getReferenceTableColumns(tableName: string, companyCode?: string): Promise< Array<{ columnName: string; displayName: string; @@ -3891,7 +3934,7 @@ export class TableManagementService { inputType?: string; }> > { - return await entityJoinService.getReferenceTableColumns(tableName); + return await entityJoinService.getReferenceTableColumns(tableName, companyCode); } /** @@ -5005,14 +5048,14 @@ export class TableManagementService { input_type: string; display_column: string | null; }>( - `SELECT column_name, reference_column, input_type, display_column + `SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column FROM table_type_columns WHERE table_name = $1 AND input_type IN ('entity', 'category') AND reference_table = $2 AND reference_column IS NOT NULL AND reference_column != '' - AND company_code = '*'`, + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, [rightTable, leftTable] ); @@ -5034,14 +5077,14 @@ export class TableManagementService { input_type: string; display_column: string | null; }>( - `SELECT column_name, reference_column, input_type, display_column + `SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column FROM table_type_columns WHERE table_name = $1 AND input_type IN ('entity', 'category') AND reference_table = $2 AND reference_column IS NOT NULL AND reference_column != '' - AND company_code = '*'`, + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, [leftTable, rightTable] ); diff --git a/docs/DB_ARCHITECTURE_ANALYSIS.md b/docs/DB_ARCHITECTURE_ANALYSIS.md new file mode 100644 index 00000000..084c6940 --- /dev/null +++ b/docs/DB_ARCHITECTURE_ANALYSIS.md @@ -0,0 +1,2188 @@ +# WACE ERP 데이터베이스 아키텍처 분석 보고서 + +> 📅 작성일: 2026-01-20 +> 🎯 목적: WACE ERP 시스템 전체 워크플로우 문서화를 위한 DB 구조 분석 +> 📊 DB 엔진: PostgreSQL 16.8 + +--- + +## 📋 목차 + +1. [개요](#1-개요) +2. [전체 테이블 목록](#2-전체-테이블-목록) +3. [멀티테넌시 아키텍처](#3-멀티테넌시-아키텍처) +4. [핵심 시스템 테이블](#4-핵심-시스템-테이블) +5. [메타데이터 관리 시스템](#5-메타데이터-관리-시스템) +6. [화면 관리 시스템](#6-화면-관리-시스템) +7. [비즈니스 도메인별 테이블](#7-비즈니스-도메인별-테이블) +8. [플로우 및 데이터 통합](#8-플로우-및-데이터-통합) +9. [인덱스 전략](#9-인덱스-전략) +10. [동적 테이블 생성 패턴](#10-동적-테이블-생성-패턴) +11. [마이그레이션 히스토리](#11-마이그레이션-히스토리) + +--- + +## 1. 개요 + +### 1.1 데이터베이스 통계 + +``` +- 총 테이블 수: 약 280개 +- 총 함수 수: 약 50개 +- 총 트리거 수: 약 30개 +- 총 시퀀스 수: 약 100개 +- 뷰 수: 약 20개 +``` + +### 1.2 아키텍처 특징 + +- **멀티테넌시**: 모든 테이블에 `company_code` 컬럼으로 회사별 데이터 격리 +- **동적 스키마**: 런타임에 테이블 생성/수정 가능 +- **메타데이터 드리븐**: UI 컴포넌트가 메타데이터 테이블을 기반으로 동적 렌더링 +- **이력 관리**: 주요 테이블에 `_log` 테이블로 변경 이력 추적 +- **외부 연동**: 외부 DB 및 REST API 연결 지원 +- **플로우 기반**: 화면 간 데이터 흐름을 정의하고 실행 + +--- + +## 2. 전체 테이블 목록 + +### 2.1 테이블 분류 체계 + +``` +시스템 관리 (약 30개) +├── 사용자/권한 (10개) +├── 메뉴 관리 (5개) +├── 회사 관리 (3개) +└── 공통 코드 (5개) + +메타데이터 시스템 (약 20개) +├── 테이블/컬럼 정의 (8개) +├── 화면 정의 (10개) +└── 레이아웃/컴포넌트 (5개) + +비즈니스 도메인 (약 200개) +├── 영업/수주 (30개) +├── 구매/발주 (25개) +├── 재고/창고 (20개) +├── 생산/작업 (25개) +├── 품질/검사 (15개) +├── 물류/운송 (20개) +├── PLM/설계 (30개) +├── 회계/원가 (20개) +└── 기타 (15개) + +통합/플로우 (약 30개) +├── 데이터플로우 (10개) +├── 배치 작업 (8개) +└── 외부 연동 (12개) +``` + +### 2.2 주요 테이블 목록 (알파벳순) + +
+전체 테이블 목록 보기 (280개) + +``` +approval +attach_file_info +auth_tokens +authority_master +authority_master_history +authority_sub_user +batch_configs +batch_execution_logs +batch_job_executions +batch_job_parameters +batch_jobs +batch_mappings +batch_schedules +button_action_standards +carrier_contract_mng +carrier_contract_mng_log +carrier_mng +carrier_mng_log +carrier_vehicle_mng +carrier_vehicle_mng_log +cascading_auto_fill_group +cascading_auto_fill_mapping +cascading_condition +cascading_hierarchy_group +cascading_hierarchy_level +cascading_multi_parent +cascading_multi_parent_source +cascading_mutual_exclusion +cascading_relation +cascading_reverse_lookup +category_column_mapping +category_value_cascading_group +category_value_cascading_mapping +chartmgmt +check_report_mng +code_category +code_info +collection_batch_executions +collection_batch_management +column_labels +comm_code +comm_code_history +comm_exchange_rate +comments +company_code_sequence +company_mng +component_standards +contract_mgmt +contract_mgmt_option +counselingmgmt +customer_item +customer_item_alias +customer_item_mapping +customer_item_price +customer_mng +customer_service_mgmt +customer_service_part +customer_service_workingtime +dashboard_elements +dashboard_shares +dashboard_slider_items +dashboard_sliders +dashboards +data_collection_configs +data_collection_history +data_collection_jobs +data_relationship_bridge +dataflow_diagrams +dataflow_external_calls +ddl_execution_log +defect_standard_mng +defect_standard_mng_log +delivery_destination +delivery_history +delivery_history_defect +delivery_part_price +delivery_route_mng +delivery_route_mng_log +delivery_status +dept_info +dept_info_history +digital_twin_layout +digital_twin_layout_template +digital_twin_location_layout +digital_twin_objects +digital_twin_zone_layout +drivers +dtg_contracts +dtg_maintenance_history +dtg_management +dtg_management_log +dtg_monthly_settlements +dynamic_form_data +equipment_consumable +equipment_consumable_log +equipment_inspection_item +equipment_inspection_item_log +equipment_mng +equipment_mng_log +estimate_mgmt +excel_mapping_template +expense_detail +expense_master +external_call_configs +external_call_logs +external_connection_permission +external_db_connection +external_db_connections +external_rest_api_connections +external_work_review_info +facility_assembly_plan +file_down_log +flow_audit_log +flow_data_mapping +flow_data_status +flow_definition +flow_external_connection_permission +flow_external_db_connection +flow_integration_log +flow_step +flow_step_connection +fund_mgmt +grid_standards +inbound_mng +inboxtask +injection_cost +input_cost_goal +input_resource +inspection_equipment_mng +inspection_equipment_mng_log +inspection_standard +inventory_history +inventory_stock +item_info +item_inspection_info +item_routing_detail +item_routing_version +klbom_tbl +language_master +layout_instances +layout_standards +login_access_log +logistics_cost_mng +logistics_cost_mng_log +mail_log +maintenance_schedules +material_cost +material_detail_mgmt +material_master_mgmt +material_mng +material_release +menu_info +menu_screen_group_items +menu_screen_groups +mold_dev_request_info +multi_lang_category +multi_lang_key_master +multi_lang_text +node_flows +numbering_rule_parts +numbering_rules +oem_factory_mng +oem_milestone_mng +oem_mng +option_mng +option_price_history +order_mgmt +order_mng_master +order_mng_sub +order_plan_mgmt +order_plan_result_error +order_spec_mng +order_spec_mng_history +outbound_mng +part_bom_qty +part_bom_report +part_distribution_list +part_mgmt +part_mng +part_mng_history +planning_issue +pms_invest_cost_mng +pms_pjt_concept_info +pms_pjt_info +pms_pjt_year_goal +pms_rel_pjt_concept_milestone +pms_rel_pjt_concept_prod +pms_rel_pjt_prod +pms_rel_prod_ref_dept +pms_wbs_task +pms_wbs_task_confirm +pms_wbs_task_info +pms_wbs_task_standard +pms_wbs_template +problem_mng +process_equipment +process_mng +procurement_standard +product_group_mng +product_kind_spec +product_kind_spec_main +product_mgmt +product_mgmt_model +product_mgmt_price_history +product_mgmt_upg_detail +product_mgmt_upg_master +product_mng +product_spec +production_issue +production_record +production_task +profit_loss +profit_loss_coefficient +profit_loss_coolingtime +profit_loss_depth +profit_loss_lossrate +profit_loss_machine +profit_loss_pretime +profit_loss_srrate +profit_loss_total +profit_loss_total_addlist +profit_loss_weight +project +project_mgmt +purchase_detail +purchase_order +purchase_order_master +purchase_order_mng +purchase_order_multi +purchase_order_part +ratecal_mgmt +receive_history +receiving +rel_menu_auth +report_layout +report_master +report_menu_mapping +report_query +report_template +safety_budget_execution +safety_incidents +safety_inspections +safety_inspections_log +sales_bom_part_qty +sales_bom_report +sales_bom_report_part +sales_long_delivery +sales_long_delivery_input +sales_long_delivery_predict +sales_order_detail +sales_order_detail_log +sales_order_mng +sales_part_chg +sales_request_master +sales_request_part +sample_supply +screen_data_flows +screen_data_transfer +screen_definitions +screen_embedding +screen_field_joins +screen_group_members +screen_group_screens +screen_groups +screen_layouts +screen_menu_assignments +screen_split_panel +screen_table_relations +screen_templates +screen_widgets +shipment_detail +shipment_header +shipment_instruction +shipment_instruction_item +shipment_pallet +shipment_plan +standard_doc_info +structural_review_proposal +style_templates +supplier_item +supplier_item_alias +supplier_item_mapping +supplier_item_price +supplier_mng +supplier_mng_log +supply_charger_mng +supply_mng +supply_mng_history +table_column_category_values +table_labels +table_log_config +table_relationships +table_type_columns +tax_invoice +tax_invoice_item +time_sheet +transport_logs +transport_statistics +transport_vehicle_locations +used_mng +user_dept +user_dept_sub +user_info +user_info_history +vehicle_location_history +vehicle_locations +vehicle_trip_summary +vehicles +warehouse_info +warehouse_location +web_type_standards +work_instruction +work_instruction_detail +work_instruction_detail_log +work_instruction_log +work_order +work_orders +work_orders_detail +work_request +yard_layout +yard_material_placement +``` + +
+ +--- + +## 3. 멀티테넌시 아키텍처 + +### 3.1 company_code 패턴 + +**모든 테이블에 필수적으로 포함되는 컬럼:** + +```sql +company_code VARCHAR(20) NOT NULL +``` + +**의미:** +- 하나의 데이터베이스에서 여러 회사의 데이터를 격리 +- 모든 쿼리는 반드시 `company_code` 필터 포함 필요 + +### 3.2 특별한 company_code 값 + +#### `company_code = "*"` 의미 + +```sql +-- ❌ 잘못된 이해: 모든 회사가 공유하는 공통 데이터 +-- ✅ 올바른 이해: 슈퍼 관리자 전용 데이터 + +-- 일반 회사는 "*" 데이터를 볼 수 없음 +SELECT * FROM table_name +WHERE company_code = 'COMPANY_A' + AND company_code != '*'; -- 필수! +``` + +**용도:** +- 시스템 관리자용 메타데이터 +- 전역 설정 값 +- 기본 템플릿 + +### 3.3 멀티테넌시 쿼리 패턴 + +```sql +-- ✅ 표준 SELECT 패턴 +SELECT * FROM table_name +WHERE company_code = $1 + AND company_code != '*' +ORDER BY created_date DESC; + +-- ✅ JOIN 패턴 (company_code 매칭 필수!) +SELECT a.*, b.name +FROM table_a a +LEFT JOIN table_b b + ON a.ref_id = b.id + AND a.company_code = b.company_code -- 필수! +WHERE a.company_code = $1; + +-- ✅ 서브쿼리 패턴 +SELECT * +FROM orders o +WHERE company_code = $1 + AND product_id IN ( + SELECT id FROM products + WHERE company_code = $1 -- 서브쿼리에도 필수! + ); + +-- ✅ 집계 패턴 +SELECT + product_type, + COUNT(*) as total, + SUM(amount) as total_amount +FROM sales +WHERE company_code = $1 +GROUP BY product_type; +``` + +### 3.4 company_code 인덱스 전략 + +**모든 테이블에 필수 인덱스:** + +```sql +CREATE INDEX idx_{table_name}_company_code +ON {table_name}(company_code); + +-- 복합 인덱스 예시 +CREATE INDEX idx_sales_company_date +ON sales(company_code, sale_date DESC); +``` + +--- + +## 4. 핵심 시스템 테이블 + +### 4.1 사용자 관리 + +#### user_info (사용자 정보) + +```sql +CREATE TABLE user_info ( + sabun VARCHAR(1024), -- 사번 + user_id VARCHAR(1024) PRIMARY KEY,-- 사용자 ID + user_password VARCHAR(1024), -- 암호화된 비밀번호 + user_name VARCHAR(1024), -- 한글명 + user_name_eng VARCHAR(1024), -- 영문명 + user_name_cn VARCHAR(1024), -- 중문명 + dept_code VARCHAR(1024), -- 부서 코드 + dept_name VARCHAR(1024), -- 부서명 + position_code VARCHAR(1024), -- 직위 코드 + position_name VARCHAR(1024), -- 직위명 + email VARCHAR(1024), -- 이메일 + tel VARCHAR(1024), -- 전화번호 + cell_phone VARCHAR(1024), -- 휴대폰 + user_type VARCHAR(1024), -- 사용자 유형 코드 + user_type_name VARCHAR(1024), -- 사용자 유형명 + company_code VARCHAR(50), -- 회사 코드 (멀티테넌시) + status VARCHAR(32), -- active/inactive + license_number VARCHAR(50), -- 면허번호 + vehicle_number VARCHAR(50), -- 차량번호 + signup_type VARCHAR(20), -- 가입 유형 + branch_name VARCHAR(100), -- 지점명 + regdate TIMESTAMP, -- 등록일 + end_date TIMESTAMP -- 종료일 +); +``` + +**관련 테이블:** +- `user_info_history`: 사용자 정보 변경 이력 +- `user_dept`: 사용자-부서 관계 +- `user_dept_sub`: 사용자 하위 부서 + +#### auth_tokens (인증 토큰) + +```sql +CREATE TABLE auth_tokens ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + token VARCHAR(500) NOT NULL, + refresh_token VARCHAR(500), + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + ip_address VARCHAR(50), + user_agent TEXT +); +``` + +### 4.2 권한 관리 + +#### authority_master (권한 그룹) + +```sql +CREATE TABLE authority_master ( + objid NUMERIC PRIMARY KEY, + auth_code VARCHAR(64), -- 권한 코드 + auth_name VARCHAR(64), -- 권한명 + company_code VARCHAR(50), -- 회사 코드 + status VARCHAR(32), -- 상태 + writer VARCHAR(32), -- 작성자 + regdate TIMESTAMP -- 등록일 +); +``` + +**관련 테이블:** +- `authority_master_history`: 권한 변경 이력 +- `authority_sub_user`: 권한-사용자 매핑 +- `rel_menu_auth`: 권한-메뉴 매핑 + +### 4.3 메뉴 관리 + +#### menu_info (메뉴 정보) + +```sql +CREATE TABLE menu_info ( + objid NUMERIC PRIMARY KEY, + menu_type NUMERIC, -- 0=일반, 1=시스템관리, 2=동적생성 + parent_obj_id NUMERIC, -- 부모 메뉴 ID + menu_name_kor VARCHAR(64), -- 한글 메뉴명 + menu_name_eng VARCHAR(64), -- 영문 메뉴명 + menu_code VARCHAR(50), -- 메뉴 코드 + menu_url VARCHAR(256), -- 메뉴 URL + seq NUMERIC, -- 순서 + screen_code VARCHAR(50), -- 화면 코드 (동적 생성 시) + screen_group_id INTEGER, -- 화면 그룹 ID + company_code VARCHAR(50), -- 회사 코드 + status VARCHAR(32), -- active/inactive + lang_key VARCHAR(100), -- 다국어 키 + source_menu_objid BIGINT, -- 원본 메뉴 ID (복사 시) + writer VARCHAR(32), + regdate TIMESTAMP +); +``` + +**특징:** +- `menu_type = 2`: 화면 생성 시 자동으로 생성되는 메뉴 +- 트리거: `auto_create_menu_for_screen()` - 화면 생성 시 자동 메뉴 추가 + +**관련 테이블:** +- `menu_screen_groups`: 메뉴 화면 그룹 +- `menu_screen_group_items`: 그룹-화면 연결 + +### 4.4 회사 관리 + +#### company_mng (회사 정보) + +```sql +CREATE TABLE company_mng ( + company_code VARCHAR(32) PRIMARY KEY, + company_name VARCHAR(64), + business_registration_number VARCHAR(20), -- 사업자등록번호 + representative_name VARCHAR(100), -- 대표자명 + representative_phone VARCHAR(20), -- 대표 연락처 + email VARCHAR(255), -- 회사 이메일 + website VARCHAR(500), -- 웹사이트 + address VARCHAR(500), -- 주소 + status VARCHAR(32), + writer VARCHAR(32), + regdate TIMESTAMP +); +``` + +**관련 테이블:** +- `company_code_sequence`: 회사별 시퀀스 관리 + +### 4.5 부서 관리 + +#### dept_info (부서 정보) + +```sql +CREATE TABLE dept_info ( + dept_code VARCHAR(1024) PRIMARY KEY, + dept_name VARCHAR(1024), + parent_dept_code VARCHAR(1024), -- 상위 부서 + company_code VARCHAR(50), + status VARCHAR(32), + writer VARCHAR(32), + regdate TIMESTAMP +); +``` + +**관련 테이블:** +- `dept_info_history`: 부서 정보 변경 이력 + +--- + +## 5. 메타데이터 관리 시스템 + +WACE ERP의 핵심 특징은 **메타데이터 드리븐 아키텍처**입니다. 화면, 테이블, 컬럼 정보를 메타데이터 테이블에서 관리하고, 프론트엔드가 이를 기반으로 동적 렌더링합니다. + +### 5.1 테이블 메타데이터 + +#### table_labels (테이블 정의) + +```sql +CREATE TABLE table_labels ( + table_name VARCHAR(100) PRIMARY KEY, -- 테이블명 (물리명) + table_label VARCHAR(200), -- 테이블 한글명 + description TEXT, -- 설명 + use_log_table VARCHAR(1) DEFAULT 'N', -- 이력 테이블 사용 여부 + created_date TIMESTAMP, + updated_date TIMESTAMP +); +``` + +**역할:** +- 동적으로 생성된 모든 테이블의 메타정보 저장 +- 화면 생성 시 테이블 선택 목록 제공 +- 데이터 딕셔너리로 활용 + +#### table_type_columns (컬럼 타입 정의) + +```sql +CREATE TABLE table_type_columns ( + id SERIAL PRIMARY KEY, + table_name VARCHAR(255) NOT NULL, + column_name VARCHAR(255) NOT NULL, + company_code VARCHAR(20) NOT NULL, -- 회사별 컬럼 설정 + input_type VARCHAR(50) DEFAULT 'text',-- 입력 타입 + detail_settings TEXT DEFAULT '{}', -- JSON 상세 설정 + is_nullable VARCHAR(10) DEFAULT 'Y', + display_order INTEGER DEFAULT 0, -- 표시 순서 + created_date TIMESTAMP, + updated_date TIMESTAMP, + + UNIQUE(table_name, column_name, company_code) +); +``` + +**input_type 종류:** +- `text`: 일반 텍스트 +- `number`: 숫자 +- `date`: 날짜 +- `select`: 드롭다운 (options 필요) +- `textarea`: 여러 줄 텍스트 +- `entity`: 참조 테이블 (referenceTable, referenceColumn 필요) +- `checkbox`: 체크박스 +- `radio`: 라디오 버튼 + +**detail_settings 예시:** + +```json +// select 타입 +{ + "options": [ + {"label": "일반", "value": "normal"}, + {"label": "긴급", "value": "urgent"} + ] +} + +// entity 타입 +{ + "referenceTable": "customer_mng", + "referenceColumn": "customer_code", + "displayColumn": "customer_name" +} +``` + +#### column_labels (컬럼 라벨 - 레거시) + +```sql +CREATE TABLE column_labels ( + table_name VARCHAR(100) NOT NULL, + column_name VARCHAR(100) NOT NULL, + column_label VARCHAR(200), -- 한글 라벨 + input_type VARCHAR(50), + detail_settings TEXT, + description TEXT, + display_order INTEGER, + is_visible BOOLEAN DEFAULT true, + created_date TIMESTAMP, + updated_date TIMESTAMP, + + PRIMARY KEY (table_name, column_name) +); +``` + +**참고:** +- 레거시 호환을 위해 유지 +- 새로운 컬럼은 `table_type_columns` 사용 권장 +- `table_type_columns`는 회사별 설정, `column_labels`는 전역 설정 + +### 5.2 카테고리 값 관리 + +#### table_column_category_values (컬럼 카테고리 값) + +```sql +CREATE TABLE table_column_category_values ( + id SERIAL PRIMARY KEY, + table_name VARCHAR(255) NOT NULL, + column_name VARCHAR(255) NOT NULL, + company_code VARCHAR(20) NOT NULL, + category_value VARCHAR(500) NOT NULL, -- 카테고리 값 + display_label VARCHAR(500), -- 표시 라벨 + display_order INTEGER DEFAULT 0, + is_active VARCHAR(1) DEFAULT 'Y', + parent_value VARCHAR(500), -- 부모 카테고리 (계층 구조) + created_date TIMESTAMP, + updated_date TIMESTAMP, + + UNIQUE(table_name, column_name, company_code, category_value) +); +``` + +**용도:** +- 동적 드롭다운 값 관리 +- 계층형 카테고리 지원 (parent_value) +- 회사별 카테고리 값 커스터마이징 + +**관련 테이블:** +- `category_column_mapping`: 카테고리-컬럼 매핑 +- `category_value_cascading_group`: 카테고리 캐스케이딩 그룹 +- `category_value_cascading_mapping`: 캐스케이딩 매핑 + +### 5.3 테이블 관계 관리 + +#### table_relationships (테이블 관계) + +```sql +CREATE TABLE table_relationships ( + id SERIAL PRIMARY KEY, + parent_table VARCHAR(100), -- 부모 테이블 + parent_column VARCHAR(100), -- 부모 컬럼 + child_table VARCHAR(100), -- 자식 테이블 + child_column VARCHAR(100), -- 자식 컬럼 + relationship_type VARCHAR(20), -- one-to-many, many-to-one 등 + created_date TIMESTAMP +); +``` + +--- + +## 6. 화면 관리 시스템 + +WACE ERP는 코드 작성 없이 화면을 동적으로 생성/수정할 수 있는 **Low-Code 플랫폼** 기능을 제공합니다. + +### 6.1 화면 정의 + +#### screen_definitions (화면 정의) + +```sql +CREATE TABLE screen_definitions ( + screen_id SERIAL PRIMARY KEY, + screen_name VARCHAR(100) NOT NULL, -- 화면명 + screen_code VARCHAR(50) NOT NULL, -- 화면 코드 (URL용) + table_name VARCHAR(100) NOT NULL, -- 메인 테이블 + company_code VARCHAR(50) NOT NULL, + description TEXT, + is_active CHAR(1) DEFAULT 'Y', -- Y=활성, N=비활성, D=삭제 + layout_metadata JSONB, -- 레이아웃 JSON + + -- 외부 데이터 소스 지원 + db_source_type VARCHAR(10) DEFAULT 'internal', -- internal/external + db_connection_id INTEGER, -- 외부 DB 연결 ID + data_source_type VARCHAR(20) DEFAULT 'database', -- database/rest_api + rest_api_connection_id INTEGER, -- REST API 연결 ID + rest_api_endpoint VARCHAR(500), -- API 엔드포인트 + rest_api_json_path VARCHAR(200) DEFAULT 'data', -- JSON 응답 경로 + + source_screen_id INTEGER, -- 원본 화면 ID (복사 시) + + created_date TIMESTAMP NOT NULL DEFAULT NOW(), + created_by VARCHAR(50), + updated_date TIMESTAMP NOT NULL DEFAULT NOW(), + updated_by VARCHAR(50), + deleted_date TIMESTAMP, -- 휴지통 이동 시점 + deleted_by VARCHAR(50), + delete_reason TEXT, + + UNIQUE(screen_code, company_code) +); +``` + +**화면 생성 플로우:** +1. 관리자가 화면 설정 페이지에서 테이블 선택 +2. `screen_definitions` 레코드 생성 +3. 트리거 `auto_create_menu_for_screen()` 실행 → `menu_info` 자동 생성 +4. 프론트엔드가 `/screen/{screen_code}` 경로로 접근 시 동적 렌더링 + +#### screen_layouts (화면 레이아웃 - 레거시) + +```sql +CREATE TABLE screen_layouts ( + layout_id SERIAL PRIMARY KEY, + screen_id INTEGER REFERENCES screen_definitions(screen_id), + layout_name VARCHAR(100), + layout_type VARCHAR(50), -- grid, form, split, tab 등 + layout_config JSONB, -- 레이아웃 설정 + display_order INTEGER, + is_active CHAR(1) DEFAULT 'Y', + company_code VARCHAR(50), + created_date TIMESTAMP, + updated_date TIMESTAMP +); +``` + +### 6.2 화면 그룹 관리 + +#### screen_groups (화면 그룹) + +```sql +CREATE TABLE screen_groups ( + id SERIAL PRIMARY KEY, + group_name VARCHAR(100) NOT NULL, -- 그룹명 + group_code VARCHAR(50) NOT NULL, -- 그룹 코드 + main_table_name VARCHAR(100), -- 메인 테이블 + description TEXT, + icon VARCHAR(100), -- 아이콘 + display_order INT DEFAULT 0, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20) NOT NULL, + + -- 계층 구조 지원 (037 마이그레이션에서 추가) + parent_group_id INTEGER REFERENCES screen_groups(id) ON DELETE CASCADE, + group_level INTEGER DEFAULT 0, -- 0=대, 1=중, 2=소 + hierarchy_path VARCHAR(500), -- 예: /1/3/5/ + + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50), + + UNIQUE(company_code, group_code) +); + +CREATE INDEX idx_screen_groups_company_code ON screen_groups(company_code); +CREATE INDEX idx_screen_groups_parent_id ON screen_groups(parent_group_id); +CREATE INDEX idx_screen_groups_hierarchy_path ON screen_groups(hierarchy_path); +``` + +#### screen_group_screens (화면-그룹 연결) + +```sql +CREATE TABLE screen_group_screens ( + id SERIAL PRIMARY KEY, + group_id INT NOT NULL REFERENCES screen_groups(id) ON DELETE CASCADE, + screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + screen_role VARCHAR(50) DEFAULT 'main', -- main, register, list, detail 등 + display_order INT DEFAULT 0, + is_default VARCHAR(1) DEFAULT 'N', -- 기본 화면 여부 + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50), + + UNIQUE(group_id, screen_id) +); +``` + +**용도:** +- 관련 화면들을 그룹으로 묶어 관리 +- 예: "영업 관리" 그룹 → 견적 화면, 수주 화면, 출하 화면 + +### 6.3 화면 필드 조인 + +#### screen_field_joins (화면 필드 조인 설정) + +```sql +CREATE TABLE screen_field_joins ( + id SERIAL PRIMARY KEY, + screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + layout_id INT, + component_id VARCHAR(500), + field_name VARCHAR(100), + + -- 저장 테이블 설정 + save_table VARCHAR(100) NOT NULL, + save_column VARCHAR(100) NOT NULL, + + -- 조인 테이블 설정 + join_table VARCHAR(100) NOT NULL, + join_column VARCHAR(100) NOT NULL, + display_column VARCHAR(100) NOT NULL, + + -- 조인 옵션 + join_type VARCHAR(20) DEFAULT 'LEFT', + filter_condition TEXT, + sort_column VARCHAR(100), + sort_direction VARCHAR(10) DEFAULT 'ASC', + + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50) +); +``` + +**예시:** +```json +{ + "save_table": "sales_order", + "save_column": "customer_code", + "join_table": "customer_mng", + "join_column": "customer_code", + "display_column": "customer_name" +} +``` + +### 6.4 화면 간 데이터 흐름 + +#### screen_data_flows (화면 간 데이터 흐름) + +```sql +CREATE TABLE screen_data_flows ( + id SERIAL PRIMARY KEY, + group_id INT REFERENCES screen_groups(id) ON DELETE SET NULL, + + -- 소스 화면 + source_screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + source_action VARCHAR(50), -- click, submit, select 등 + + -- 타겟 화면 + target_screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + target_action VARCHAR(50), -- open, load, refresh 등 + + -- 데이터 매핑 설정 + data_mapping JSONB, -- 필드 매핑 정보 + + -- 흐름 설정 + flow_type VARCHAR(20) DEFAULT 'unidirectional', -- unidirectional/bidirectional + flow_label VARCHAR(100), -- 시각화 라벨 + condition_expression TEXT, -- 실행 조건식 + + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50) +); +``` + +**data_mapping 예시:** + +```json +{ + "customer_code": "customer_code", + "customer_name": "customer_name", + "selected_date": "order_date" +} +``` + +#### screen_table_relations (화면-테이블 관계) + +```sql +CREATE TABLE screen_table_relations ( + id SERIAL PRIMARY KEY, + group_id INT REFERENCES screen_groups(id) ON DELETE SET NULL, + screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + table_name VARCHAR(100) NOT NULL, + + relation_type VARCHAR(20) DEFAULT 'main', -- main, join, lookup + crud_operations VARCHAR(20) DEFAULT 'CRUD',-- CRUD 조합 + description TEXT, + + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50) +); +``` + +### 6.5 컴포넌트 표준 + +#### component_standards (컴포넌트 표준) + +```sql +CREATE TABLE component_standards ( + component_code VARCHAR(50) PRIMARY KEY, + component_name VARCHAR(100) NOT NULL, + component_name_eng VARCHAR(100), + description TEXT, + category VARCHAR(50) NOT NULL, -- input, layout, display 등 + icon_name VARCHAR(50), + default_size JSON, -- {width, height} + component_config JSON NOT NULL, -- 컴포넌트 설정 + preview_image VARCHAR(255), + sort_order INTEGER DEFAULT 0, + is_active CHAR(1) DEFAULT 'Y', + is_public CHAR(1) DEFAULT 'Y', + company_code VARCHAR(50) NOT NULL, + created_date TIMESTAMP, + updated_date TIMESTAMP +); +``` + +#### layout_standards (레이아웃 표준) + +```sql +CREATE TABLE layout_standards ( + layout_code VARCHAR(50) PRIMARY KEY, + layout_name VARCHAR(100) NOT NULL, + layout_type VARCHAR(50), -- grid, form, split, tab + default_config JSON, + is_active CHAR(1) DEFAULT 'Y', + company_code VARCHAR(50), + created_date TIMESTAMP, + updated_date TIMESTAMP +); +``` + +--- + +## 7. 비즈니스 도메인별 테이블 + +### 7.1 영업/수주 관리 + +#### 수주 관리 (Order Management) + +``` +sales_order_mng -- 수주 마스터 +├── sales_order_detail -- 수주 상세 +├── sales_order_detail_log -- 수주 상세 이력 +├── sales_request_master -- 영업 요청 마스터 +├── sales_request_part -- 영업 요청 부품 +└── sales_part_chg -- 영업 부품 변경 +``` + +**sales_order_mng:** +- 고객별 수주 정보 +- 납기, 금액, 상태 관리 + +**sales_order_detail:** +- 수주 라인 아이템 +- 품목, 수량, 단가 정보 + +#### 견적 관리 + +``` +estimate_mgmt -- 견적 관리 +contract_mgmt -- 계약 관리 +├── contract_mgmt_option -- 계약 옵션 +``` + +#### BOM 관리 + +``` +sales_bom_report -- 영업 BOM 리포트 +├── sales_bom_report_part -- 영업 BOM 부품 +└── sales_bom_part_qty -- 영업 BOM 부품 수량 +``` + +### 7.2 구매/발주 관리 + +``` +purchase_order_master -- 발주 마스터 +├── purchase_order -- 발주 상세 +├── purchase_order_part -- 발주 부품 +├── purchase_order_multi -- 다중 발주 +└── purchase_detail -- 구매 상세 + +supplier_mng -- 공급업체 관리 +├── supplier_mng_log -- 공급업체 이력 +├── supplier_item -- 공급업체 품목 +├── supplier_item_alias -- 공급업체 품목 별칭 +├── supplier_item_mapping -- 공급업체 품목 매핑 +└── supplier_item_price -- 공급업체 품목 가격 +``` + +### 7.3 재고/창고 관리 + +``` +inventory_stock -- 재고 현황 +inventory_history -- 재고 이력 +warehouse_info -- 창고 정보 +warehouse_location -- 창고 위치 +inbound_mng -- 입고 관리 +outbound_mng -- 출고 관리 +receiving -- 입하 +receive_history -- 입하 이력 +``` + +### 7.4 생산/작업 관리 + +``` +work_orders -- 작업지시 (신규) +├── work_orders_detail -- 작업지시 상세 +work_order -- 작업지시 (레거시) +work_instruction -- 작업 지시서 +├── work_instruction_detail -- 작업 지시서 상세 +├── work_instruction_detail_log +└── work_instruction_log + +production_record -- 생산 실적 +production_task -- 생산 작업 +production_issue -- 생산 이슈 +work_request -- 작업 요청 +``` + +#### work_orders (작업지시 - 050 마이그레이션) + +```sql +CREATE TABLE work_orders ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + company_code VARCHAR(500), + + -- 작업지시 정보 + wo_number VARCHAR(500), -- WO-20250130-001 + product_code VARCHAR(500), + product_name VARCHAR(500), + spec VARCHAR(500), + order_qty VARCHAR(500), + completed_qty VARCHAR(500), + start_date VARCHAR(500), + due_date VARCHAR(500), + status VARCHAR(500), -- normal/urgent + progress_status VARCHAR(500), -- pending/in-progress/completed + equipment VARCHAR(500), + routing VARCHAR(500), + work_team VARCHAR(500), -- DAY/NIGHT + worker VARCHAR(500), + shift VARCHAR(500), -- DAY/NIGHT + remark VARCHAR(500) +); + +CREATE INDEX idx_work_orders_company_code ON work_orders(company_code); +CREATE INDEX idx_work_orders_wo_number ON work_orders(wo_number); +``` + +### 7.5 품질/검사 관리 + +``` +inspection_standard -- 검사 기준 +item_inspection_info -- 품목 검사 정보 +inspection_equipment_mng -- 검사 설비 관리 +├── inspection_equipment_mng_log +defect_standard_mng -- 불량 기준 관리 +├── defect_standard_mng_log +check_report_mng -- 검사 성적서 관리 +safety_inspections -- 안전 점검 +└── safety_inspections_log +``` + +### 7.6 물류/운송 관리 + +``` +vehicles -- 차량 정보 +├── vehicle_locations -- 차량 위치 +├── vehicle_location_history -- 차량 위치 이력 +├── vehicle_trip_summary -- 차량 운행 요약 +drivers -- 운전자 정보 +transport_logs -- 운송 로그 +transport_statistics -- 운송 통계 +transport_vehicle_locations -- 차량 위치 + +carrier_mng -- 운송사 관리 +├── carrier_mng_log +├── carrier_contract_mng -- 운송사 계약 +├── carrier_contract_mng_log +├── carrier_vehicle_mng -- 운송사 차량 +└── carrier_vehicle_mng_log + +delivery_route_mng -- 배송 경로 관리 +├── delivery_route_mng_log +delivery_destination -- 배송지 +delivery_status -- 배송 상태 +delivery_history -- 배송 이력 +├── delivery_history_defect -- 배송 불량 +delivery_part_price -- 배송 부품 가격 +``` + +#### DTG 관리 (디지털 타코그래프) + +``` +dtg_management -- DTG 관리 +├── dtg_management_log +dtg_contracts -- DTG 계약 +dtg_maintenance_history -- DTG 정비 이력 +dtg_monthly_settlements -- DTG 월별 정산 +``` + +### 7.7 PLM/설계 관리 + +``` +part_mng -- 부품 관리 (메인) +├── part_mng_history +part_mgmt -- 부품 관리 (서브) +part_bom_qty -- 부품 BOM 수량 +part_bom_report -- 부품 BOM 리포트 +part_distribution_list -- 부품 배포 목록 + +item_info -- 품목 정보 +item_routing_version -- 품목 라우팅 버전 +item_routing_detail -- 품목 라우팅 상세 + +product_mng -- 제품 관리 +product_mgmt -- 제품 관리 (메인) +├── product_mgmt_model -- 제품 모델 +├── product_mgmt_price_history -- 제품 가격 이력 +├── product_mgmt_upg_master -- 제품 업그레이드 마스터 +└── product_mgmt_upg_detail -- 제품 업그레이드 상세 + +product_kind_spec -- 제품 종류 사양 +product_kind_spec_main -- 제품 종류 사양 메인 +product_spec -- 제품 사양 +product_group_mng -- 제품 그룹 관리 + +mold_dev_request_info -- 금형 개발 요청 +structural_review_proposal -- 구조 검토 제안 +``` + +### 7.8 프로젝트 관리 (PMS) + +``` +pms_pjt_info -- 프로젝트 정보 +├── pms_pjt_concept_info -- 프로젝트 개념 정보 +├── pms_pjt_year_goal -- 프로젝트 연도 목표 +pms_wbs_task -- WBS 작업 +├── pms_wbs_task_info -- WBS 작업 정보 +├── pms_wbs_task_confirm -- WBS 작업 확인 +├── pms_wbs_task_standard -- WBS 작업 표준 +└── pms_wbs_template -- WBS 템플릿 + +pms_rel_pjt_concept_milestone -- 프로젝트 개념-마일스톤 관계 +pms_rel_pjt_concept_prod -- 프로젝트 개념-제품 관계 +pms_rel_pjt_prod -- 프로젝트-제품 관계 +pms_rel_prod_ref_dept -- 제품-참조부서 관계 + +pms_invest_cost_mng -- 투자 비용 관리 +project_mgmt -- 프로젝트 관리 +problem_mng -- 문제 관리 +planning_issue -- 계획 이슈 +``` + +### 7.9 회계/원가 관리 + +``` +tax_invoice -- 세금계산서 +├── tax_invoice_item -- 세금계산서 항목 +fund_mgmt -- 자금 관리 +expense_master -- 비용 마스터 +├── expense_detail -- 비용 상세 + +profit_loss -- 손익 계산 +├── profit_loss_total -- 손익 합계 +├── profit_loss_coefficient -- 손익 계수 +├── profit_loss_machine -- 손익 기계 +├── profit_loss_weight -- 손익 무게 +├── profit_loss_depth -- 손익 깊이 +├── profit_loss_pretime -- 손익 사전 시간 +├── profit_loss_coolingtime -- 손익 냉각 시간 +├── profit_loss_lossrate -- 손익 손실률 +└── profit_loss_srrate -- 손익 SR률 + +material_cost -- 자재 비용 +injection_cost -- 사출 비용 +logistics_cost_mng -- 물류 비용 관리 +└── logistics_cost_mng_log + +input_cost_goal -- 투입 비용 목표 +input_resource -- 투입 자원 +``` + +### 7.10 고객/협력사 관리 + +``` +customer_mng -- 고객 관리 +customer_item -- 고객 품목 +├── customer_item_alias -- 고객 품목 별칭 +├── customer_item_mapping -- 고객 품목 매핑 +└── customer_item_price -- 고객 품목 가격 + +customer_service_mgmt -- 고객 서비스 관리 +├── customer_service_part -- 고객 서비스 부품 +└── customer_service_workingtime -- 고객 서비스 작업시간 + +oem_mng -- OEM 관리 +├── oem_factory_mng -- OEM 공장 관리 +└── oem_milestone_mng -- OEM 마일스톤 관리 +``` + +### 7.11 설비/장비 관리 + +``` +equipment_mng -- 설비 관리 +├── equipment_mng_log +equipment_consumable -- 설비 소모품 +├── equipment_consumable_log +equipment_inspection_item -- 설비 검사 항목 +└── equipment_inspection_item_log + +process_equipment -- 공정 설비 +process_mng -- 공정 관리 +maintenance_schedules -- 정비 일정 +``` + +### 7.12 기타 + +``` +approval -- 결재 +comments -- 댓글 +inboxtask -- 수신함 작업 +time_sheet -- 작업 시간 +attach_file_info -- 첨부 파일 +file_down_log -- 파일 다운로드 로그 +login_access_log -- 로그인 접근 로그 +``` + +--- + +## 8. 플로우 및 데이터 통합 + +### 8.1 플로우 정의 + +#### flow_definition (플로우 정의) + +```sql +CREATE TABLE flow_definition ( + flow_id SERIAL PRIMARY KEY, + flow_name VARCHAR(200) NOT NULL, + flow_code VARCHAR(100) NOT NULL, + flow_type VARCHAR(50), -- data_transfer, approval, batch 등 + description TEXT, + trigger_type VARCHAR(50), -- manual, schedule, event + trigger_config JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + updated_by VARCHAR(50), + + UNIQUE(flow_code, company_code) +); +``` + +#### flow_step (플로우 단계) + +```sql +CREATE TABLE flow_step ( + step_id SERIAL PRIMARY KEY, + flow_id INTEGER REFERENCES flow_definition(flow_id) ON DELETE CASCADE, + step_name VARCHAR(200) NOT NULL, + step_type VARCHAR(50) NOT NULL, -- query, transform, api_call, condition 등 + step_order INTEGER NOT NULL, + step_config JSONB NOT NULL, + error_handling_config JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +#### flow_step_connection (플로우 단계 연결) + +```sql +CREATE TABLE flow_step_connection ( + connection_id SERIAL PRIMARY KEY, + flow_id INTEGER REFERENCES flow_definition(flow_id) ON DELETE CASCADE, + source_step_id INTEGER REFERENCES flow_step(step_id) ON DELETE CASCADE, + target_step_id INTEGER REFERENCES flow_step(step_id) ON DELETE CASCADE, + condition_expression TEXT, -- 조건부 실행 + connection_type VARCHAR(20) DEFAULT 'sequential', -- sequential, parallel, conditional + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 8.2 데이터플로우 + +#### dataflow_diagrams (데이터플로우 다이어그램) + +```sql +CREATE TABLE dataflow_diagrams ( + diagram_id SERIAL PRIMARY KEY, + diagram_name VARCHAR(200) NOT NULL, + diagram_type VARCHAR(50), + diagram_json JSONB NOT NULL, -- 다이어그램 시각화 정보 + description TEXT, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + updated_by VARCHAR(50) +); +``` + +#### flow_data_mapping (플로우 데이터 매핑) + +```sql +CREATE TABLE flow_data_mapping ( + mapping_id SERIAL PRIMARY KEY, + flow_id INTEGER REFERENCES flow_definition(flow_id) ON DELETE CASCADE, + source_type VARCHAR(20), -- table, api, flow + source_identifier VARCHAR(200), -- 테이블명 또는 API 엔드포인트 + source_field VARCHAR(100), + target_type VARCHAR(20), + target_identifier VARCHAR(200), + target_field VARCHAR(100), + transformation_rule TEXT, -- 변환 규칙 (JavaScript 표현식) + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +#### flow_data_status (플로우 데이터 상태) + +```sql +CREATE TABLE flow_data_status ( + status_id SERIAL PRIMARY KEY, + flow_id INTEGER REFERENCES flow_definition(flow_id), + execution_id VARCHAR(100), + source_table VARCHAR(100), + source_record_id VARCHAR(500), + target_table VARCHAR(100), + target_record_id VARCHAR(500), + status VARCHAR(20), -- pending, processing, completed, failed + error_message TEXT, + processed_at TIMESTAMPTZ, + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 8.3 외부 연동 + +#### external_db_connections (외부 DB 연결) + +```sql +CREATE TABLE external_db_connections ( + id SERIAL PRIMARY KEY, + connection_name VARCHAR(200) NOT NULL, + connection_code VARCHAR(100) NOT NULL, + db_type VARCHAR(50) NOT NULL, -- postgresql, mysql, mssql, oracle + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL, + database_name VARCHAR(100) NOT NULL, + username VARCHAR(100), + password_encrypted TEXT, + ssl_enabled BOOLEAN DEFAULT false, + connection_options JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + + UNIQUE(connection_code, company_code) +); +``` + +#### external_rest_api_connections (외부 REST API 연결) + +```sql +CREATE TABLE external_rest_api_connections ( + id SERIAL PRIMARY KEY, + connection_name VARCHAR(200) NOT NULL, + connection_code VARCHAR(100) NOT NULL, + base_url VARCHAR(500) NOT NULL, + auth_type VARCHAR(50), -- none, basic, bearer, api_key + auth_config JSONB, + default_headers JSONB, + timeout_seconds INTEGER DEFAULT 30, + retry_count INTEGER DEFAULT 0, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(connection_code, company_code) +); +``` + +#### external_call_configs (외부 호출 설정) + +```sql +CREATE TABLE external_call_configs ( + id SERIAL PRIMARY KEY, + config_name VARCHAR(200) NOT NULL, + config_code VARCHAR(100) NOT NULL, + connection_id INTEGER, -- external_rest_api_connections 참조 + http_method VARCHAR(10), -- GET, POST, PUT, DELETE + endpoint_path VARCHAR(500), + request_mapping JSONB, -- 요청 데이터 매핑 + response_mapping JSONB, -- 응답 데이터 매핑 + error_handling JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 8.4 배치 작업 + +#### batch_jobs (배치 작업 정의) + +```sql +CREATE TABLE batch_jobs ( + job_id SERIAL PRIMARY KEY, + job_name VARCHAR(200) NOT NULL, + job_code VARCHAR(100) NOT NULL, + job_type VARCHAR(50), -- data_collection, aggregation, sync 등 + source_type VARCHAR(50), -- database, api, file + source_config JSONB, + target_config JSONB, + schedule_config JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(job_code, company_code) +); +``` + +#### batch_execution_logs (배치 실행 로그) + +```sql +CREATE TABLE batch_execution_logs ( + execution_id SERIAL PRIMARY KEY, + job_id INTEGER REFERENCES batch_jobs(job_id), + execution_status VARCHAR(20), -- running, completed, failed + start_time TIMESTAMPTZ, + end_time TIMESTAMPTZ, + records_processed INTEGER, + records_failed INTEGER, + error_message TEXT, + execution_details JSONB, + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +--- + +## 9. 인덱스 전략 + +### 9.1 필수 인덱스 + +**모든 테이블에 적용:** + +```sql +-- company_code 인덱스 (멀티테넌시 성능) +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); + +-- 복합 인덱스 (company_code + 주요 검색 컬럼) +CREATE INDEX idx_{table_name}_company_{column} +ON {table_name}(company_code, {column}); +``` + +### 9.2 화면 관련 인덱스 + +```sql +-- screen_definitions +CREATE INDEX idx_screen_definitions_company_code ON screen_definitions(company_code); +CREATE INDEX idx_screen_definitions_table_name ON screen_definitions(table_name); +CREATE INDEX idx_screen_definitions_is_active ON screen_definitions(is_active); +CREATE UNIQUE INDEX idx_screen_definitions_code_company +ON screen_definitions(screen_code, company_code); + +-- screen_groups +CREATE INDEX idx_screen_groups_company_code ON screen_groups(company_code); +CREATE INDEX idx_screen_groups_parent_id ON screen_groups(parent_group_id); +CREATE INDEX idx_screen_groups_hierarchy_path ON screen_groups(hierarchy_path); + +-- screen_field_joins +CREATE INDEX idx_screen_field_joins_screen_id ON screen_field_joins(screen_id); +CREATE INDEX idx_screen_field_joins_save_table ON screen_field_joins(save_table); +CREATE INDEX idx_screen_field_joins_join_table ON screen_field_joins(join_table); +``` + +### 9.3 메타데이터 인덱스 + +```sql +-- table_type_columns +CREATE UNIQUE INDEX idx_table_type_columns_unique +ON table_type_columns(table_name, column_name, company_code); + +-- column_labels +CREATE INDEX idx_column_labels_table ON column_labels(table_name); +``` + +### 9.4 비즈니스 테이블 인덱스 예시 + +```sql +-- sales_order_mng +CREATE INDEX idx_sales_order_company_code ON sales_order_mng(company_code); +CREATE INDEX idx_sales_order_customer ON sales_order_mng(customer_code); +CREATE INDEX idx_sales_order_date ON sales_order_mng(order_date DESC); +CREATE INDEX idx_sales_order_status ON sales_order_mng(order_status); + +-- work_orders +CREATE INDEX idx_work_orders_company_code ON work_orders(company_code); +CREATE INDEX idx_work_orders_wo_number ON work_orders(wo_number); +CREATE INDEX idx_work_orders_start_date ON work_orders(start_date); +CREATE INDEX idx_work_orders_product_code ON work_orders(product_code); +``` + +### 9.5 플로우 관련 인덱스 + +```sql +-- flow_definition +CREATE UNIQUE INDEX idx_flow_definition_code_company +ON flow_definition(flow_code, company_code); + +-- flow_step +CREATE INDEX idx_flow_step_flow_id ON flow_step(flow_id); +CREATE INDEX idx_flow_step_order ON flow_step(flow_id, step_order); + +-- flow_data_status +CREATE INDEX idx_flow_data_status_flow_execution +ON flow_data_status(flow_id, execution_id); +CREATE INDEX idx_flow_data_status_source +ON flow_data_status(source_table, source_record_id); +``` + +--- + +## 10. 동적 테이블 생성 패턴 + +WACE ERP의 핵심 기능 중 하나는 **런타임에 테이블을 동적으로 생성**할 수 있다는 것입니다. + +### 10.1 표준 컬럼 구조 + +**모든 동적 생성 테이블의 기본 컬럼:** + +```sql +CREATE TABLE {dynamic_table_name} ( + -- 시스템 기본 컬럼 (자동 포함) + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + company_code VARCHAR(500), + + -- 사용자 정의 컬럼 (모두 VARCHAR(500)) + {user_column_1} VARCHAR(500), + {user_column_2} VARCHAR(500), + ... +); + +-- 필수 인덱스 +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); +``` + +### 10.2 메타데이터 등록 프로세스 + +동적 테이블 생성 시 반드시 수행해야 하는 작업: + +#### 1단계: 테이블 생성 + +```sql +CREATE TABLE {table_name} ( + -- 위의 표준 구조 참조 +); +``` + +#### 2단계: table_labels 등록 + +```sql +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('{table_name}', '{한글명}', '{설명}', NOW(), NOW()) +ON CONFLICT (table_name) +DO UPDATE SET + table_label = EXCLUDED.table_label, + description = EXCLUDED.description, + updated_date = NOW(); +``` + +#### 3단계: table_type_columns 등록 + +```sql +-- 기본 컬럼 등록 (display_order: -5 ~ -1) +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date +) VALUES + ('{table_name}', 'id', '{company_code}', 'text', '{}', 'Y', -5, NOW(), NOW()), + ('{table_name}', 'created_date', '{company_code}', 'date', '{}', 'Y', -4, NOW(), NOW()), + ('{table_name}', 'updated_date', '{company_code}', 'date', '{}', 'Y', -3, NOW(), NOW()), + ('{table_name}', 'writer', '{company_code}', 'text', '{}', 'Y', -2, NOW(), NOW()), + ('{table_name}', 'company_code', '{company_code}', 'text', '{}', 'Y', -1, NOW(), NOW()) +ON CONFLICT (table_name, column_name, company_code) +DO UPDATE SET + input_type = EXCLUDED.input_type, + display_order = EXCLUDED.display_order, + updated_date = NOW(); + +-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작) +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date +) VALUES + ('{table_name}', '{column_1}', '{company_code}', 'text', '{}', 'Y', 0, NOW(), NOW()), + ('{table_name}', '{column_2}', '{company_code}', 'number', '{}', 'Y', 1, NOW(), NOW()), + ... +``` + +#### 4단계: column_labels 등록 (레거시 호환용) + +```sql +INSERT INTO column_labels ( + table_name, column_name, column_label, input_type, detail_settings, + description, display_order, is_visible, created_date, updated_date +) VALUES + ('{table_name}', 'id', 'ID', 'text', '{}', '기본키', -5, true, NOW(), NOW()), + ... +ON CONFLICT (table_name, column_name) +DO UPDATE SET + column_label = EXCLUDED.column_label, + input_type = EXCLUDED.input_type, + updated_date = NOW(); +``` + +### 10.3 마이그레이션 예시 + +**050_create_work_orders_table.sql:** + +```sql +-- ============================================ +-- 1. 테이블 생성 +-- ============================================ +CREATE TABLE work_orders ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + company_code VARCHAR(500), + + wo_number VARCHAR(500), + product_code VARCHAR(500), + product_name VARCHAR(500), + ... +); + +CREATE INDEX idx_work_orders_company_code ON work_orders(company_code); +CREATE INDEX idx_work_orders_wo_number ON work_orders(wo_number); + +-- ============================================ +-- 2. table_labels 등록 +-- ============================================ +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('work_orders', '작업지시', '작업지시 관리 테이블', NOW(), NOW()) +ON CONFLICT (table_name) DO UPDATE SET ...; + +-- ============================================ +-- 3. table_type_columns 등록 +-- ============================================ +INSERT INTO table_type_columns (...) VALUES (...); + +-- ============================================ +-- 4. column_labels 등록 +-- ============================================ +INSERT INTO column_labels (...) VALUES (...); + +-- ============================================ +-- 5. 코멘트 추가 +-- ============================================ +COMMENT ON TABLE work_orders IS '작업지시 관리 테이블'; +COMMENT ON COLUMN work_orders.wo_number IS '작업지시번호 (WO-YYYYMMDD-XXX)'; +``` + +--- + +## 11. 마이그레이션 히스토리 + +### 11.1 마이그레이션 파일 목록 + +``` +db/migrations/ +├── 037_add_parent_group_to_screen_groups.sql -- 화면 그룹 계층 구조 +├── 050_create_work_orders_table.sql -- 작업지시 테이블 +├── 051_insert_work_order_screen_definition.sql -- 작업지시 화면 정의 +├── 052_insert_work_order_screen_layout.sql -- 작업지시 화면 레이아웃 +├── 054_create_screen_management_enhancement.sql -- 화면 관리 기능 확장 +└── plm_schema_20260120.sql -- 전체 스키마 덤프 +``` + +### 11.2 주요 마이그레이션 내용 + +#### 037: 화면 그룹 계층 구조 + +- `screen_groups`에 계층 구조 지원 추가 +- `parent_group_id`, `group_level`, `hierarchy_path` 컬럼 추가 +- 대/중/소 분류 지원 + +#### 050~052: 작업지시 시스템 + +- `work_orders` 테이블 생성 +- 메타데이터 등록 (table_labels, table_type_columns, column_labels) +- 화면 정의 및 레이아웃 생성 + +#### 054: 화면 관리 기능 확장 + +- `screen_groups`: 화면 그룹 +- `screen_group_screens`: 화면-그룹 연결 +- `screen_field_joins`: 화면 필드 조인 설정 +- `screen_data_flows`: 화면 간 데이터 흐름 +- `screen_table_relations`: 화면-테이블 관계 + +### 11.3 마이그레이션 실행 가이드 + +**마이그레이션 문서:** +- `RUN_027_MIGRATION.md` +- `RUN_043_MIGRATION.md` +- `RUN_044_MIGRATION.md` +- `RUN_046_MIGRATION.md` +- `RUN_063_064_MIGRATION.md` +- `RUN_065_MIGRATION.md` +- `RUN_078_MIGRATION.md` + +--- + +## 12. 데이터베이스 함수 및 트리거 + +### 12.1 주요 함수 + +#### 화면 관련 + +```sql +-- 화면 생성 시 메뉴 자동 생성 +CREATE FUNCTION auto_create_menu_for_screen() RETURNS TRIGGER; + +-- 화면 삭제 시 메뉴 비활성화 +CREATE FUNCTION auto_deactivate_menu_for_screen() RETURNS TRIGGER; +``` + +#### 통계 집계 + +```sql +-- 일일 운송 통계 집계 +CREATE FUNCTION aggregate_daily_transport_statistics(target_date DATE) RETURNS INTEGER; +``` + +#### 거리 계산 + +```sql +-- Haversine 거리 계산 +CREATE FUNCTION calculate_distance(lat1 NUMERIC, lng1 NUMERIC, lat2 NUMERIC, lng2 NUMERIC) +RETURNS NUMERIC; + +-- 이전 위치로부터 거리 계산 +CREATE FUNCTION calculate_distance_from_prev() RETURNS TRIGGER; +``` + +#### 비즈니스 로직 + +```sql +-- 수주 잔량 계산 +CREATE FUNCTION calculate_order_balance() RETURNS TRIGGER; + +-- 세금계산서 합계 계산 +CREATE FUNCTION calculate_tax_invoice_total() RETURNS TRIGGER; + +-- 영업에서 프로젝트 자동 생성 +CREATE FUNCTION auto_create_project_from_sales(p_sales_no VARCHAR) RETURNS VARCHAR; +``` + +#### 로그 관리 + +```sql +-- 테이블 변경 로그 트리거 함수 +CREATE FUNCTION carrier_contract_mng_log_trigger_func() RETURNS TRIGGER; +CREATE FUNCTION carrier_mng_log_trigger_func() RETURNS TRIGGER; +-- ... 각 테이블별 로그 트리거 함수 +``` + +### 12.2 주요 트리거 + +```sql +-- 화면 생성 시 메뉴 자동 생성 +CREATE TRIGGER trg_auto_create_menu_for_screen +AFTER INSERT ON screen_definitions +FOR EACH ROW +EXECUTE FUNCTION auto_create_menu_for_screen(); + +-- 화면 삭제 시 메뉴 비활성화 +CREATE TRIGGER trg_auto_deactivate_menu_for_screen +AFTER UPDATE ON screen_definitions +FOR EACH ROW +EXECUTE FUNCTION auto_deactivate_menu_for_screen(); + +-- 차량 위치 이력 거리 계산 +CREATE TRIGGER trg_calculate_distance_from_prev +BEFORE INSERT ON vehicle_location_history +FOR EACH ROW +EXECUTE FUNCTION calculate_distance_from_prev(); +``` + +--- + +## 13. 뷰 (Views) + +### 13.1 시스템 뷰 + +```sql +-- 권한 그룹 요약 +CREATE VIEW v_authority_group_summary AS +SELECT + am.objid, + am.auth_name, + am.auth_code, + am.company_code, + (SELECT COUNT(*) FROM authority_sub_user WHERE master_objid = am.objid) AS member_count, + (SELECT COUNT(*) FROM rel_menu_auth WHERE auth_objid = am.objid) AS menu_count +FROM authority_master am; +``` + +--- + +## 14. 데이터베이스 보안 및 암호화 + +### 14.1 암호화 컬럼 + +```sql +-- external_db_connections +password_encrypted TEXT -- AES 암호화된 비밀번호 + +-- external_rest_api_connections +auth_config JSONB -- 암호화된 인증 정보 +``` + +### 14.2 접근 제어 + +- PostgreSQL 롤 기반 접근 제어 +- 회사별 데이터 격리 (company_code) +- 사용자별 권한 관리 (authority_master, rel_menu_auth) + +--- + +## 15. 성능 최적화 전략 + +### 15.1 인덱스 최적화 + +- **company_code 인덱스**: 모든 테이블에 필수 +- **복합 인덱스**: 자주 함께 조회되는 컬럼 조합 +- **부분 인덱스**: 특정 조건의 데이터만 인덱싱 + +### 15.2 쿼리 최적화 + +- **서브쿼리 최소화**: JOIN으로 대체 +- **EXPLAIN ANALYZE** 활용 +- **인덱스 힌트** 사용 + +### 15.3 캐싱 전략 + +- **참조 데이터 캐싱**: referenceCacheService.ts +- **Redis 캐싱**: 자주 조회되는 메타데이터 + +### 15.4 파티셔닝 + +- 대용량 이력 테이블 파티셔닝 고려 +- 날짜 기반 파티셔닝 (vehicle_location_history 등) + +--- + +## 16. 백업 및 복구 + +### 16.1 백업 전략 + +```bash +# 전체 스키마 백업 +pg_dump -h host -U user -d database > plm_schema_YYYYMMDD.sql + +# 데이터 포함 백업 +pg_dump -h host -U user -d database --data-only > data_YYYYMMDD.sql + +# 특정 테이블 백업 +pg_dump -h host -U user -d database -t table_name > table_backup.sql +``` + +### 16.2 마이그레이션 롤백 + +- DDL 작업 전 백업 필수 +- 트랜잭션 기반 마이그레이션 +- 롤백 스크립트 준비 + +--- + +## 17. 모니터링 및 로깅 + +### 17.1 시스템 로그 테이블 + +``` +login_access_log -- 로그인 접근 로그 +ddl_execution_log -- DDL 실행 로그 +batch_execution_logs -- 배치 실행 로그 +flow_audit_log -- 플로우 감사 로그 +flow_integration_log -- 플로우 통합 로그 +external_call_logs -- 외부 호출 로그 +mail_log -- 메일 발송 로그 +file_down_log -- 파일 다운로드 로그 +``` + +### 17.2 변경 이력 테이블 + +**패턴:** `{원본테이블}_log` 또는 `{원본테이블}_history` + +``` +user_info_history +dept_info_history +authority_master_history +comm_code_history +carrier_mng_log +supplier_mng_log +equipment_mng_log +... +``` + +--- + +## 18. 결론 + +### 18.1 핵심 아키텍처 특징 + +1. **멀티테넌시**: company_code로 완벽한 데이터 격리 +2. **메타데이터 드리븐**: 동적 화면/테이블 생성 +3. **Low-Code 플랫폼**: 코드 없이 화면 구축 +4. **플로우 기반**: 시각적 데이터 흐름 설계 +5. **외부 연동**: DB/API 통합 지원 +6. **이력 관리**: 완벽한 변경 이력 추적 + +### 18.2 확장성 + +- **수평 확장**: 멀티테넌시로 무한한 회사 추가 가능 +- **수직 확장**: 동적 테이블/컬럼 추가 +- **기능 확장**: 플로우/배치 작업으로 비즈니스 로직 추가 + +### 18.3 유지보수성 + +- **표준화된 구조**: 모든 테이블이 동일한 패턴 +- **자동화**: 트리거/함수로 반복 작업 자동화 +- **문서화**: 메타데이터 테이블 자체가 문서 + +--- + +## 부록 A: 백엔드 서비스 매핑 + +### 주요 서비스와 테이블 매핑 + +```typescript +// backend-node/src/services/ + +screenManagementService.ts → screen_definitions, screen_layouts +tableManagementService.ts → table_labels, table_type_columns, column_labels +menuService.ts → menu_info, menu_screen_groups +categoryTreeService.ts → table_column_category_values +flowDefinitionService.ts → flow_definition, flow_step +flowExecutionService.ts → flow_data_status, flow_audit_log +dataflowService.ts → dataflow_diagrams, screen_data_flows +externalDbConnectionService.ts → external_db_connections +externalRestApiConnectionService.ts → external_rest_api_connections +batchService.ts → batch_jobs, batch_execution_logs +authService.ts → user_info, auth_tokens +roleService.ts → authority_master, rel_menu_auth +``` + +--- + +## 부록 B: SQL 쿼리 예시 + +### 멀티테넌시 표준 쿼리 + +```sql +-- ✅ 단일 테이블 조회 +SELECT * FROM sales_order_mng +WHERE company_code = $1 + AND company_code != '*' + AND order_date >= $2 +ORDER BY order_date DESC +LIMIT 100; + +-- ✅ JOIN 쿼리 +SELECT + so.order_no, + so.order_date, + c.customer_name, + p.product_name, + so.quantity, + so.unit_price +FROM sales_order_mng so +LEFT JOIN customer_mng c + ON so.customer_code = c.customer_code + AND so.company_code = c.company_code +LEFT JOIN product_mng p + ON so.product_code = p.product_code + AND so.company_code = p.company_code +WHERE so.company_code = $1 + AND so.company_code != '*' + AND so.order_date BETWEEN $2 AND $3; + +-- ✅ 집계 쿼리 +SELECT + customer_code, + COUNT(*) as order_count, + SUM(total_amount) as total_sales +FROM sales_order_mng +WHERE company_code = $1 + AND company_code != '*' + AND order_date >= DATE_TRUNC('month', CURRENT_DATE) +GROUP BY customer_code +HAVING COUNT(*) >= 5 +ORDER BY total_sales DESC; +``` + +--- + +## 부록 C: 참고 문서 + +``` +docs/ +├── DDD1542/ +│ ├── DB_STRUCTURE_DIAGRAM.md -- DB 구조 다이어그램 +│ ├── DB_INEFFICIENCY_ANALYSIS.md -- DB 비효율성 분석 +│ ├── COMPONENT_URL_SYSTEM_IMPLEMENTATION.md +│ ├── V2_마이그레이션_학습노트_DDD1542.md +│ └── 본서버_개발서버_마이그레이션_가이드.md +├── backend-architecture-analysis.md -- 백엔드 아키텍처 분석 +└── screen-implementation-guide/ -- 화면 구현 가이드 +``` + +--- + +**문서 작성자**: Cursor AI (DB Specialist Agent) +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-01-20 +**스키마 버전**: plm_schema_20260120.sql + +--- diff --git a/docs/DB_WORKFLOW_ANALYSIS.md b/docs/DB_WORKFLOW_ANALYSIS.md new file mode 100644 index 00000000..d5cd069b --- /dev/null +++ b/docs/DB_WORKFLOW_ANALYSIS.md @@ -0,0 +1,1728 @@ +# WACE ERP 데이터베이스 워크플로우 분석 + +> 📅 작성일: 2026-02-06 +> 🎯 목적: 비즈니스 워크플로우 중심의 DB 구조 분석 +> 📊 DB 엔진: PostgreSQL 16.8 +> 📝 기반 스키마: plm_schema_20260120.sql + +--- + +## 📋 Executive Summary + +WACE ERP 시스템은 **멀티테넌트 SaaS 아키텍처**를 기반으로 한 제조업 특화 ERP입니다. +- **총 테이블 수**: 337개 +- **핵심 아키텍처**: Multi-tenancy (company_code 기반 데이터 격리) +- **특징**: 메타데이터 드리븐, 동적 스키마, 플로우 기반 통합 + +--- + +## 🏗️ 1. 데이터베이스 아키텍처 개요 + +### 1.1 계층 구조 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Application Layer (React) │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Backend Layer (Node.js + TypeScript) │ +│ - API Routes │ +│ - Business Logic Services │ +│ - Raw SQL Query Execution │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Database Layer (PostgreSQL) │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ System Core │ │ Metadata │ │ +│ │ - user_info │ │ - table_labels │ │ +│ │ - company_mng │ │ - screen_def │ │ +│ │ - menu_info │ │ - flow_def │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ Business Domain Tables │ │ +│ │ - Sales (30+) - Purchase (25+) │ │ +│ │ - Stock (20+) - Production (25+) │ │ +│ │ - Quality (15+) - Logistics (20+) │ │ +│ │ - PLM (30+) - Accounting (20+) │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 1.2 멀티테넌시 핵심 원칙 + +**ABSOLUTE MUST: 모든 테이블에 company_code** + +```sql +-- ✅ 표준 테이블 구조 +CREATE TABLE {table_name} ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(20) NOT NULL, -- 필수! + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + -- ... 비즈니스 컬럼들 +); + +-- 필수 인덱스 +CREATE INDEX idx_{table_name}_company_code +ON {table_name}(company_code); +``` + +**company_code = "*" 의미** +- ❌ 잘못된 이해: 모든 회사 공통 데이터 +- ✅ 올바른 이해: 슈퍼 관리자 전용 데이터 +- 일반 회사 쿼리: `WHERE company_code = $1 AND company_code != '*'` + +--- + +## 🎯 2. 핵심 시스템 테이블 (System Core) + +### 2.1 사용자 및 인증 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `user_info` | 사용자 정보 | user_id, user_name, email, password_hash, company_code | +| `user_info_history` | 사용자 변경 이력 | history_id, user_id, change_type, changed_at | +| `auth_tokens` | 인증 토큰 | token_id, user_id, token, expires_at | +| `login_access_log` | 로그인 이력 | log_id, user_id, ip_address, login_at | +| `user_dept` | 사용자-부서 매핑 (겸직 지원) | user_id, dept_code, is_primary | +| `user_dept_sub` | 겸직 부서 정보 | user_id, sub_dept_code | + +**워크플로우:** +``` +로그인 → auth_tokens 생성 → login_access_log 기록 +회사별 데이터 격리 → company_code 필터링 +``` + +### 2.2 권한 관리 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `authority_master` | 권한 그룹 마스터 | objid, auth_name, auth_code, company_code | +| `authority_master_history` | 권한 그룹 이력 | objid, parent_objid, history_type | +| `authority_sub_user` | 권한 그룹 멤버 | objid, master_objid, user_id | +| `rel_menu_auth` | 메뉴별 권한 CRUD | objid, menu_objid, auth_objid, create_auth, read_auth, update_auth, delete_auth | + +**권한 체계:** +``` +사용자 → authority_sub_user → authority_master → rel_menu_auth → 메뉴별 CRUD 권한 +``` + +### 2.3 회사 및 부서 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `company_mng` | 회사 정보 | company_code (PK), company_name, business_registration_number | +| `company_code_sequence` | 회사 코드 시퀀스 | company_code, next_sequence | +| `dept_info` | 부서 정보 | dept_code, dept_name, parent_dept_code, company_code | +| `dept_info_history` | 부서 변경 이력 | dept_code, change_type, changed_at | + +**계층 구조:** +``` +company_mng (회사) + └─ dept_info (부서 - 계층 구조) + └─ user_dept (사용자 배정) +``` + +### 2.4 메뉴 시스템 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `menu_info` | 메뉴 정보 | objid, menu_type, parent_obj_id, menu_name_kor, menu_url, screen_code, company_code, source_menu_objid | +| `menu_screen_groups` | 통합 메뉴/화면 그룹 | group_id, group_name, parent_group_id, company_code | +| `menu_screen_group_items` | 그룹-화면 연결 | group_id, screen_code | +| `screen_menu_assignments` | 화면-메뉴 할당 | screen_code, menu_id | + +**메뉴 타입:** +- `menu_type = 0`: 일반 메뉴 +- `menu_type = 1`: 시스템 관리 메뉴 +- `menu_type = 2`: 동적 생성 메뉴 (screen_definitions에서 자동 생성) + +**메뉴 복사 메커니즘:** +``` +원본 메뉴 (회사A) → 복사 → 새 메뉴 (회사B) +source_menu_objid: 원본 메뉴의 objid 추적 +재복사 시: source_menu_objid로 기존 복사본 찾아서 덮어쓰기 +``` + +--- + +## 🗂️ 3. 메타데이터 시스템 (Metadata Layer) + +### 3.1 테이블 메타데이터 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `table_labels` | 테이블 논리명 | table_name (PK), table_label, description | +| `table_type_columns` | 컬럼 타입 정의 (회사별) | table_name, column_name, company_code, input_type, detail_settings, display_order | +| `column_labels` | 컬럼 논리명 (레거시) | table_name, column_name, column_label, input_type | +| `table_relationships` | 테이블 관계 정의 | parent_table, child_table, join_condition | +| `table_log_config` | 테이블 로그 설정 | table_name, log_enabled, log_table_name | + +**메타데이터 구조:** +``` +table_labels (테이블 논리명) + └─ table_type_columns (컬럼 타입 정의 - 회사별) + └─ category_column_mapping (카테고리 컬럼 매핑) + └─ table_column_category_values (카테고리 값) +``` + +**동적 테이블 생성 프로세스:** +1. `CREATE TABLE` 실행 +2. `table_labels` 등록 +3. `table_type_columns` 등록 (회사별) +4. `column_labels` 등록 (레거시 호환) +5. `ddl_execution_log`에 DDL 실행 이력 기록 + +### 3.2 화면 메타데이터 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `screen_definitions` | 화면 정의 | screen_code (PK), screen_name, table_name, screen_type, company_code | +| `screen_layouts` | 화면 레이아웃 | screen_code, layout_config (JSONB) | +| `screen_templates` | 화면 템플릿 | template_id, template_name, template_config | +| `screen_widgets` | 화면 위젯 | widget_id, screen_code, widget_type, widget_config | +| `screen_groups` | 화면 그룹 | group_id, group_name, parent_group_id | +| `screen_group_screens` | 화면-그룹 연결 | group_id, screen_code | + +**화면 생성 워크플로우:** +``` +screen_definitions 생성 + → 트리거 발동 → menu_info 자동 생성 (menu_type=2) + → screen_layouts 레이아웃 정의 + → screen_widgets 위젯 배치 + → screen_table_relations 테이블 관계 설정 +``` + +### 3.3 화면 고급 기능 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `screen_embedding` | 화면 임베딩 | parent_screen, child_screen, embed_config | +| `screen_split_panel` | 분할 패널 | screen_code, left_screen, right_screen, split_ratio | +| `screen_data_transfer` | 화면 간 데이터 전달 | source_screen, target_screen, mapping_config | +| `screen_data_flows` | 화면 간 데이터 흐름 | flow_id, source_screen, target_screen, flow_type | +| `screen_field_joins` | 화면 필드 조인 | source_table, target_table, join_condition | +| `screen_table_relations` | 화면-테이블 관계 | screen_code, table_name, relation_type | + +**화면 임베딩 패턴:** +``` +마스터 화면 (sales_order) + ├─ 좌측 패널: 수주 목록 + └─ 우측 패널: 상세 정보 (임베딩) + ├─ 수주 기본 정보 + ├─ 수주 품목 (임베딩) + └─ 배송 정보 (임베딩) +``` + +### 3.4 UI 컴포넌트 표준 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `component_standards` | UI 컴포넌트 표준 | component_type, standard_props (JSONB) | +| `button_action_standards` | 버튼 액션 기준 | action_type, action_config (JSONB) | +| `web_type_standards` | 웹 타입 기준 | web_type, type_config (JSONB) | +| `grid_standards` | 격자 시스템 기준 | grid_type, grid_config (JSONB) | +| `layout_standards` | 레이아웃 표준 | layout_type, layout_config (JSONB) | +| `layout_instances` | 레이아웃 인스턴스 | instance_id, layout_type, instance_config | +| `style_templates` | 스타일 템플릿 | template_id, template_name, style_config (JSONB) | + +--- + +## 🔄 4. 플로우 및 데이터 통합 시스템 + +### 4.1 플로우 정의 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `flow_definition` | 플로우 정의 | id, name, table_name, db_source_type, company_code | +| `flow_step` | 플로우 단계 | step_id, flow_id, step_name, step_type, step_order, action_config (JSONB) | +| `flow_step_connection` | 플로우 단계 연결 | connection_id, source_step_id, target_step_id, condition | +| `flow_data_mapping` | 플로우 데이터 매핑 | mapping_id, flow_id, source_table, target_table, mapping_config | + +**플로우 타입:** +- 승인 플로우: 결재 라인 정의 +- 상태 플로우: 데이터 상태 전환 +- 데이터 플로우: 테이블 간 데이터 이동 +- 통합 플로우: 외부 시스템 연동 + +### 4.2 플로우 실행 및 모니터링 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `flow_data_status` | 데이터 현재 상태 | data_id, flow_id, current_step_id, status | +| `flow_audit_log` | 플로우 상태 변경 이력 | log_id, flow_id, data_id, from_step, to_step, changed_at, changed_by | +| `flow_integration_log` | 플로우 외부 연동 로그 | log_id, flow_id, integration_type, request_data, response_data | + +**플로우 실행 예시: 수주 승인** +``` +수주 등록 (sales_order_mng) + → flow_data_status 생성 (status: 'PENDING') + → flow_step 1: 영업팀장 승인 + → flow_step 2: 재고 확인 + → flow_step 3: 생산계획 생성 + → flow_step 4: 최종 승인 + → flow_audit_log 각 단계 기록 +``` + +### 4.3 노드 기반 플로우 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `node_flows` | 노드 기반 플로우 | flow_id, flow_name, nodes (JSONB), edges (JSONB) | +| `dataflow_diagrams` | 데이터플로우 다이어그램 | diagram_id, diagram_name, diagram_data (JSONB) | +| `dataflow_external_calls` | 데이터플로우 외부 호출 | call_id, diagram_id, external_connection_id | + +**노드 타입:** +- Start/End Node +- Data Node (테이블 조회/저장) +- Logic Node (조건 분기, 계산) +- External Node (외부 API 호출) +- Transform Node (데이터 변환) + +--- + +## 🌐 5. 외부 연동 시스템 + +### 5.1 외부 데이터베이스 연결 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `external_db_connections` | 외부 DB 연결 정보 | connection_id, connection_name, db_type, host, port, database, username, password_encrypted | +| `external_db_connection` | 외부 DB 연결 (레거시) | connection_id, connection_config (JSONB) | +| `external_connection_permission` | 외부 연결 권한 | permission_id, connection_id, user_id, allowed_operations | +| `flow_external_db_connection` | 플로우 전용 외부 DB 연결 | connection_id, flow_id, db_config (JSONB) | +| `flow_external_connection_permission` | 플로우 외부 연결 권한 | permission_id, flow_id, connection_id | + +**지원 DB 타입:** +- PostgreSQL +- MySQL +- MS SQL Server +- Oracle +- MongoDB (NoSQL) + +### 5.2 외부 REST API 연결 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `external_rest_api_connections` | 외부 REST API 연결 | connection_id, api_name, base_url, auth_type, auth_config (JSONB) | +| `external_call_configs` | 외부 호출 설정 | config_id, connection_id, endpoint, method, headers (JSONB) | +| `external_call_logs` | 외부 호출 로그 | log_id, config_id, request_data, response_data, status_code, executed_at | + +**인증 방식:** +- API Key +- Bearer Token +- OAuth 2.0 +- Basic Auth + +### 5.3 데이터 수집 배치 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `data_collection_configs` | 데이터 수집 설정 | config_id, source_type, source_config (JSONB), schedule | +| `data_collection_jobs` | 데이터 수집 작업 | job_id, config_id, job_status, started_at, completed_at | +| `data_collection_history` | 데이터 수집 이력 | history_id, job_id, collected_count, error_count | +| `collection_batch_management` | 수집 배치 관리 | batch_id, batch_name, batch_config (JSONB) | +| `collection_batch_executions` | 배치 실행 이력 | execution_id, batch_id, execution_status, executed_at | + +--- + +## 📊 6. 비즈니스 도메인 테이블 (Business Domain) + +### 6.1 영업/수주 (Sales & Orders) - 30+ 테이블 + +#### 핵심 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `sales_order_mng` | 수주 관리 | customer_mng, product_mng | +| `sales_order_detail` | 수주 상세 | sales_order_mng | +| `sales_order_detail_log` | 수주 상세 이력 | sales_order_detail | +| `sales_request_master` | 구매요청서 마스터 | customer_mng | +| `sales_request_part` | 구매요청서 품목 | sales_request_master, part_mng | +| `estimate_mgmt` | 견적 관리 | customer_mng, product_mng | +| `contract_mgmt` | 계약 관리 | customer_mng | +| `contract_mgmt_option` | 계약 옵션 | contract_mgmt | + +#### 영업 지원 테이블 + +| 테이블 | 역할 | +|--------|------| +| `sales_bom_report` | 영업 BOM 보고서 | +| `sales_bom_part_qty` | 영업 BOM 수량 | +| `sales_bom_report_part` | 영업 BOM 품목 | +| `sales_long_delivery` | 장납기 부품 리스트 | +| `sales_long_delivery_input` | 장납기 자재 투입 이력 | +| `sales_long_delivery_predict` | 장납기 예측 | +| `sales_part_chg` | 설계 변경 리스트 | +| `sample_supply` | 샘플 공급 | + +#### 고객 관리 + +| 테이블 | 역할 | +|--------|------| +| `customer_mng` | 거래처 마스터 | +| `customer_item` | 거래처별 품번 관리 | +| `customer_item_alias` | 거래처별 품목 품번/품명 | +| `customer_item_mapping` | 거래처별 품목 매핑 | +| `customer_item_price` | 거래처별 품목 단가 이력 | +| `customer_service_mgmt` | 조치내역서 마스터 | +| `customer_service_part` | 조치내역서 사용 부품 | +| `customer_service_workingtime` | 조치내역서 작업 시간 | +| `counselingmgmt` | 상담 관리 | + +**워크플로우: 수주 프로세스** +``` +1. 견적 요청 (estimate_mgmt) +2. 견적 작성 및 발송 +3. 수주 등록 (sales_order_mng) +4. 수주 상세 입력 (sales_order_detail) +5. 계약 체결 (contract_mgmt) +6. 생산 계획 생성 (order_plan_mgmt) +7. 구매 요청 (sales_request_master) +``` + +### 6.2 구매/발주 (Purchase & Procurement) - 25+ 테이블 + +#### 핵심 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `purchase_order_master` | 발주 관리 마스터 | supplier_mng | +| `purchase_order_part` | 발주서 상세 목록 | purchase_order_master, part_mng | +| `purchase_order_multi` | 동시적용 프로젝트 발주 | purchase_order_master | +| `purchase_order_mng` | 발주 관리 | supplier_mng | +| `purchase_detail` | 구매 상세 | purchase_order | +| `purchase_order` | 구매 주문 | supplier_mng | + +#### 공급처 관리 + +| 테이블 | 역할 | +|--------|------| +| `supplier_mng` | 공급처 마스터 | +| `supplier_mng_log` | 공급처 변경 이력 | +| `supplier_item` | 공급처별 품목 정보 | +| `supplier_item_alias` | 공급처별 품목 품번/품명 | +| `supplier_item_mapping` | 공급처별 품목 매핑 | +| `supplier_item_price` | 공급처별 품목 단가 | +| `procurement_standard` | 구매 기준 정보 | + +#### 입고 관리 + +| 테이블 | 역할 | +|--------|------| +| `delivery_history` | 입고 관리 | +| `delivery_history_defect` | 입고 불량 품목 | +| `delivery_part_price` | 입고 품목 단가 | +| `receiving` | 입고 처리 | +| `receive_history` | 입고 이력 | +| `inbound_mng` | 입고 관리 | +| `check_report_mng` | 검수 관리 보고서 | + +**워크플로우: 구매 프로세스** +``` +1. 구매 요청 (sales_request_master) +2. 공급처 선정 (supplier_mng) +3. 발주서 작성 (purchase_order_master) +4. 발주서 상세 (purchase_order_part) +5. 입고 예정 (delivery_history) +6. 검수 (check_report_mng) +7. 입고 확정 (receiving) +8. 재고 반영 (inventory_stock) +``` + +### 6.3 재고/창고 (Inventory & Warehouse) - 20+ 테이블 + +#### 핵심 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `inventory_stock` | 재고 현황 | item_info, warehouse_location | +| `inventory_history` | 재고 이력 | inventory_stock | +| `warehouse_info` | 창고 정보 | - | +| `warehouse_location` | 창고 위치 | warehouse_info | +| `inbound_mng` | 입고 관리 | warehouse_location | +| `outbound_mng` | 출고 관리 | warehouse_location | +| `material_release` | 자재 출고 | - | + +#### 물류 관리 + +| 테이블 | 역할 | +|--------|------| +| `shipment_header` | 출하 헤더 | +| `shipment_detail` | 출하 상세 | +| `shipment_plan` | 출하 계획 | +| `shipment_instruction` | 출하 지시 | +| `shipment_instruction_item` | 출하 지시 품목 | +| `shipment_pallet` | 출하 파레트 | +| `delivery_destination` | 납품처 정보 | +| `delivery_route_mng` | 배송 경로 관리 | +| `delivery_route_mng_log` | 배송 경로 이력 | +| `delivery_status` | 배송 상태 | + +#### 디지털 트윈 (창고 레이아웃) + +| 테이블 | 역할 | +|--------|------| +| `digital_twin_layout` | 디지털 트윈 레이아웃 | +| `digital_twin_layout_template` | 레이아웃 템플릿 | +| `digital_twin_location_layout` | Location 배치 정보 | +| `digital_twin_zone_layout` | Zone 배치 정보 | +| `digital_twin_objects` | 디지털 트윈 객체 | +| `yard_layout` | 야드 레이아웃 | +| `yard_material_placement` | 야드 자재 배치 | + +**워크플로우: 재고 관리** +``` +1. 입고 (inbound_mng) + → inventory_stock 증가 + → inventory_history 기록 + +2. 출고 (outbound_mng) + → inventory_stock 감소 + → inventory_history 기록 + +3. 창고 위치 관리 + → warehouse_location + → digital_twin_location_layout (시각화) + +4. 재고 조사 + → inventory_stock 조회 + → 실사 vs 전산 비교 + → 차이 조정 +``` + +### 6.4 생산/작업 (Production & Work) - 25+ 테이블 + +#### 핵심 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `work_orders` | 작업지시 관리 | product_mng, sales_order_mng | +| `work_orders_detail` | 작업지시 상세 | work_orders | +| `work_instruction` | 작업지시 | - | +| `work_instruction_detail` | 작업지시 상세 | work_instruction | +| `work_instruction_log` | 작업지시 이력 | work_instruction | +| `work_instruction_detail_log` | 작업지시 상세 이력 | work_instruction_detail | +| `work_order` | 작업지시 (레거시) | - | +| `work_request` | 작업 요청 (워크플로우) | - | + +#### 생산 계획 + +| 테이블 | 역할 | +|--------|------| +| `order_plan_mgmt` | 생산 계획 관리 | +| `order_plan_result_error` | 생산 계획 오류 결과 | +| `production_task` | 생산 작업 | +| `production_record` | 생산 실적 | +| `production_issue` | 생산 이슈 | +| `facility_assembly_plan` | 설비 조립 계획 | + +#### 공정 관리 + +| 테이블 | 역할 | +|--------|------| +| `process_mng` | 공정 관리 | +| `process_equipment` | 공정 설비 | +| `item_routing_version` | 품목 라우팅 버전 | +| `item_routing_detail` | 품목 라우팅 상세 | +| `input_resource` | 투입 자원 | + +#### 설비 관리 + +| 테이블 | 역할 | +|--------|------| +| `equipment_mng` | 설비 관리 | +| `equipment_mng_log` | 설비 변경 이력 | +| `equipment_consumable` | 설비 소모품 | +| `equipment_consumable_log` | 소모품 이력 | +| `equipment_inspection_item` | 설비 점검 항목 | +| `equipment_inspection_item_log` | 점검 항목 이력 | +| `inspection_equipment_mng` | 검사 설비 관리 | +| `inspection_equipment_mng_log` | 검사 설비 이력 | + +**워크플로우: 생산 프로세스** +``` +1. 수주 확정 (sales_order_mng) +2. 생산 계획 생성 (order_plan_mgmt) +3. 자재 소요 계획 (MRP) +4. 작업지시 발행 (work_orders) +5. 자재 출고 (material_release) +6. 생산 실행 (production_record) +7. 품질 검사 (inspection_standard) +8. 완제품 입고 (inventory_stock) +``` + +### 6.5 품질/검사 (Quality & Inspection) - 15+ 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `inspection_standard` | 검사 기준 | - | +| `item_inspection_info` | 품목 검사 정보 | item_info | +| `defect_standard_mng` | 불량 기준 관리 | - | +| `defect_standard_mng_log` | 불량 기준 이력 | defect_standard_mng | +| `check_report_mng` | 검수 관리 보고서 | - | + +**품질 관리 워크플로우:** +``` +1. 입고 검사 (check_report_mng) +2. 공정 검사 (inspection_standard) +3. 최종 검사 (item_inspection_info) +4. 불량 처리 (defect_standard_mng) +5. 품질 데이터 분석 +``` + +### 6.6 물류/운송 (Logistics & Transport) - 20+ 테이블 + +#### 차량 관리 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `vehicles` | 차량 정보 | - | +| `drivers` | 운전자 정보 | - | +| `vehicle_locations` | 차량 현재 위치 | vehicles | +| `vehicle_location_history` | 차량 위치 이력 | vehicles | +| `transport_vehicle_locations` | 운행 관리 실시간 위치 | vehicles | + +#### 운송 관리 + +| 테이블 | 역할 | +|--------|------| +| `transport_logs` | 운행 이력 | +| `transport_statistics` | 일별 운행 통계 | +| `vehicle_trip_summary` | 차량 운행 요약 | +| `maintenance_schedules` | 차량 정비 일정 | + +#### 운송사 관리 + +| 테이블 | 역할 | +|--------|------| +| `carrier_mng` | 운송사 관리 | +| `carrier_mng_log` | 운송사 이력 | +| `carrier_contract_mng` | 운송사 계약 관리 | +| `carrier_contract_mng_log` | 계약 이력 | +| `carrier_vehicle_mng` | 운송사 차량 관리 | +| `carrier_vehicle_mng_log` | 차량 이력 | + +#### DTG (디지털운행기록계) + +| 테이블 | 역할 | +|--------|------| +| `dtg_management` | DTG 통합 관리 (구매/설치/점검/폐기/정산) | +| `dtg_management_log` | DTG 관리 이력 | +| `dtg_contracts` | DTG 차종별/운송사별 계약 | +| `dtg_monthly_settlements` | DTG 월별 정산 | +| `dtg_maintenance_history` | DTG 점검 이력 | + +#### 물류 비용 + +| 테이블 | 역할 | +|--------|------| +| `logistics_cost_mng` | 물류 비용 관리 | +| `logistics_cost_mng_log` | 물류 비용 이력 | + +**워크플로우: 운송 관리** +``` +1. 출하 계획 (shipment_plan) +2. 차량 배차 (vehicles) +3. 운행 시작 + → vehicle_location_history (GPS 추적) + → transport_logs 기록 +4. 배송 완료 +5. 운행 통계 집계 (transport_statistics) +6. 정산 (dtg_monthly_settlements) +``` + +### 6.7 PLM/설계 (Product Lifecycle Management) - 30+ 테이블 + +#### 제품 관리 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `product_mng` | 제품 마스터 | - | +| `product_mgmt` | 제품 관리 | - | +| `product_mgmt_model` | 제품 모델 | product_mgmt | +| `product_mgmt_price_history` | 제품 가격 이력 | product_mgmt | +| `product_mgmt_upg_master` | 제품 업그레이드 마스터 | product_mgmt | +| `product_mgmt_upg_detail` | 제품 업그레이드 상세 | product_mgmt_upg_master | +| `product_kind_spec` | 제품별 사양 관리 | - | +| `product_kind_spec_main` | 제품별 사양 메인 | - | +| `product_spec` | 제품 사양 | - | +| `product_group_mng` | 제품 그룹 관리 | - | + +#### 품목 관리 + +| 테이블 | 역할 | +|--------|------| +| `item_info` | 품목 정보 | +| `part_mng` | 부품 관리 (설계 정보 포함) | +| `part_mng_history` | 부품 변경 이력 | +| `part_mgmt` | 부품 관리 | +| `part_distribution_list` | 부품 배포 리스트 | + +#### BOM 관리 + +| 테이블 | 역할 | +|--------|------| +| `klbom_tbl` | KL BOM 테이블 | +| `part_bom_qty` | BOM 수량 관리 | +| `part_bom_report` | BOM 보고서 | +| `sales_bom_report` | 영업 BOM 보고서 | +| `sales_bom_report_part` | 영업 BOM 품목 | +| `sales_bom_part_qty` | 영업 BOM 수량 | + +#### 프로젝트 관리 + +| 테이블 | 역할 | +|--------|------| +| `pms_pjt_info` | 프로젝트 정보 | +| `pms_pjt_concept_info` | 프로젝트 개념 정보 | +| `pms_pjt_year_goal` | 프로젝트 연간 목표 | +| `pms_rel_pjt_prod` | 프로젝트-제품 관계 | +| `pms_rel_pjt_concept_prod` | 프로젝트 개념-제품 관계 | +| `pms_rel_pjt_concept_milestone` | 프로젝트 개념-마일스톤 | +| `pms_rel_prod_ref_dept` | 제품-참조부서 관계 | +| `project` | 프로젝트 (레거시) | +| `project_mgmt` | 프로젝트 관리 | +| `project_concept` | 프로젝트 개념 (레거시?) | + +#### WBS (Work Breakdown Structure) + +| 테이블 | 역할 | +|--------|------| +| `pms_wbs_task` | WBS 작업 | +| `pms_wbs_task_info` | WBS 작업 정보 | +| `pms_wbs_task_confirm` | WBS 작업 확인 | +| `pms_wbs_task_standard` | WBS 작업 표준 | +| `pms_wbs_task_standard2` | WBS 작업 표준2 | +| `pms_wbs_template` | WBS 템플릿 | + +#### 설계 관리 + +| 테이블 | 역할 | +|--------|------| +| `mold_dev_request_info` | 금형 개발 요청 | +| `structural_review_proposal` | 구조 검토 제안서 | +| `external_work_review_info` | 외주 작업 검토 정보 | +| `standard_doc_info` | 표준 문서 정보 | + +#### OEM 관리 + +| 테이블 | 역할 | +|--------|------| +| `oem_mng` | OEM 관리 | +| `oem_factory_mng` | OEM 공장 관리 | +| `oem_milestone_mng` | OEM 마일스톤 관리 | + +**워크플로우: PLM 프로세스** +``` +1. 제품 기획 (pms_pjt_concept_info) +2. 프로젝트 생성 (pms_pjt_info) +3. WBS 작업 분해 (pms_wbs_task) +4. 제품 설계 (product_mng) +5. BOM 작성 (part_bom_report) +6. 부품 관리 (part_mng) +7. 설계 검토 (structural_review_proposal) +8. 양산 전환 (production_task) +``` + +### 6.8 회계/원가 (Accounting & Costing) - 20+ 테이블 + +#### 원가 관리 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `material_cost` | 재료비 | - | +| `injection_cost` | 사출 원가 | - | +| `profit_loss` | 손익 관리 | - | +| `profit_loss_total` | 손익 합계 | - | +| `profit_loss_coefficient` | 손익 계수 | - | +| `profit_loss_coolingtime` | 손익 냉각 시간 | - | +| `profit_loss_depth` | 손익 깊이 | - | +| `profit_loss_lossrate` | 손익 손실률 | - | +| `profit_loss_machine` | 손익 기계 | - | +| `profit_loss_pretime` | 손익 사전 시간 | - | +| `profit_loss_srrate` | 손익 SR률 | - | +| `profit_loss_weight` | 손익 중량 | - | +| `profit_loss_total_addlist` | 손익 합계 추가 리스트 | - | +| `profit_loss_total_addlist2` | 손익 합계 추가 리스트2 | - | + +#### 자재/비용 관리 + +| 테이블 | 역할 | +|--------|------| +| `material_mng` | 자재 관리 | +| `material_master_mgmt` | 자재 마스터 관리 | +| `material_detail_mgmt` | 자재 상세 관리 | +| `input_cost_goal` | 투입 원가 목표 | + +#### 비용/투자 + +| 테이블 | 역할 | +|--------|------| +| `expense_master` | 비용 마스터 | +| `expense_detail` | 비용 상세 | +| `pms_invest_cost_mng` | 투자 비용 관리 | +| `fund_mgmt` | 자금 관리 | + +#### 세금계산서 + +| 테이블 | 역할 | +|--------|------| +| `tax_invoice` | 세금계산서 | +| `tax_invoice_item` | 세금계산서 항목 | + +#### 안전 예산 + +| 테이블 | 역할 | +|--------|------| +| `safety_budget_execution` | 안전 예산 집행 | +| `safety_incidents` | 안전 사고 | +| `safety_inspections` | 안전 점검 | +| `safety_inspections_log` | 안전 점검 이력 | + +**워크플로우: 원가 계산** +``` +1. BOM 기반 재료비 계산 (material_cost) +2. 공정별 가공비 계산 (injection_cost) +3. 간접비 배부 +4. 총 원가 집계 (profit_loss_total) +5. 판매가 대비 이익률 계산 (profit_loss) +``` + +### 6.9 기타 비즈니스 테이블 - 15+ 테이블 + +#### 공통 코드 + +| 테이블 | 역할 | +|--------|------| +| `comm_code` | 공통 코드 | +| `comm_code_history` | 공통 코드 이력 | +| `code_category` | 코드 카테고리 | +| `code_info` | 코드 정보 | + +#### 환율 + +| 테이블 | 역할 | +|--------|------| +| `comm_exchange_rate` | 환율 정보 | + +#### 게시판/댓글 + +| 테이블 | 역할 | +|--------|------| +| `chartmgmt` | 차트 관리 | +| `comments` | 댓글 | +| `inboxtask` | 받은편지함 작업 | + +#### 첨부파일 + +| 테이블 | 역할 | +|--------|------| +| `attach_file_info` | 첨부파일 정보 | +| `file_down_log` | 파일 다운로드 로그 | + +#### 승인 + +| 테이블 | 역할 | +|--------|------| +| `approval` | 승인 | + +#### 옵션 관리 + +| 테이블 | 역할 | +|--------|------| +| `option_mng` | 옵션 관리 | +| `option_price_history` | 옵션 가격 이력 | + +#### 수주 사양 + +| 테이블 | 역할 | +|--------|------| +| `order_spec_mng` | 수주 사양 관리 | +| `order_spec_mng_history` | 수주 사양 이력 | + +#### 기타 + +| 테이블 | 역할 | +|--------|------| +| `ratecal_mgmt` | 요율 계산 관리 | +| `time_sheet` | 타임시트 | +| `problem_mng` | 문제 관리 | +| `planning_issue` | 계획 이슈 | +| `used_mng` | 중고 관리 | + +--- + +## 📊 7. 대시보드 및 리포트 시스템 + +### 7.1 대시보드 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `dashboards` | 대시보드 정의 | dashboard_id, dashboard_name, company_code | +| `dashboard_elements` | 대시보드 요소 | element_id, dashboard_id, element_type, element_config (JSONB) | +| `dashboard_shares` | 대시보드 공유 | share_id, dashboard_id, shared_with_user_id | +| `dashboard_sliders` | 대시보드 슬라이더 | slider_id, slider_name, company_code | +| `dashboard_slider_items` | 슬라이더 내 대시보드 목록 | item_id, slider_id, dashboard_id, display_order | + +**대시보드 구조:** +``` +dashboard_sliders (슬라이더 그룹) + └─ dashboard_slider_items (슬라이더에 포함된 대시보드들) + └─ dashboards (개별 대시보드) + └─ dashboard_elements (차트, 위젯 등) +``` + +### 7.2 리포트 시스템 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `report_master` | 리포트 마스터 | report_id, report_name, report_type | +| `report_query` | 리포트 쿼리 | query_id, report_id, query_sql, query_params | +| `report_layout` | 리포트 레이아웃 | layout_id, report_id, layout_config (JSONB) | +| `report_template` | 리포트 템플릿 | template_id, template_name, template_config (JSONB) | +| `report_menu_mapping` | 리포트-메뉴 매핑 | mapping_id, report_id, menu_id | + +**리포트 생성 프로세스:** +``` +1. report_master 정의 +2. report_query SQL 작성 +3. report_layout 레이아웃 설정 +4. report_menu_mapping 메뉴 연결 +5. 사용자가 메뉴 클릭 +6. 동적 SQL 실행 +7. 결과를 레이아웃에 맞춰 렌더링 +``` + +--- + +## 🔧 8. 고급 기능 시스템 + +### 8.1 카테고리 시스템 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `category_column_mapping` | 카테고리 컬럼 매핑 | table_name, column_name, category_key | +| `table_column_category_values` | 카테고리 값 | table_name, column_name, company_code, category_values (JSONB) | +| `category_value_cascading_group` | 카테고리 연쇄 그룹 | group_id, group_name | +| `category_value_cascading_mapping` | 카테고리 연쇄 매핑 | mapping_id, parent_category, child_category | + +**카테고리 기능:** +- 컬럼별 드롭다운 옵션 관리 +- 회사별 독립적인 카테고리 값 +- 연쇄 드롭다운 (parent → child) + +### 8.2 연쇄 드롭다운 (Cascading Dropdown) + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `cascading_relation` | 연쇄 드롭다운 관계 | relation_id, parent_field, child_field, relation_config | +| `cascading_hierarchy_group` | 다단계 연쇄 그룹 | group_id, group_name, hierarchy_levels | +| `cascading_hierarchy_level` | 다단계 연쇄 레벨 | level_id, group_id, level_order, level_config | +| `cascading_condition` | 조건부 연쇄 | condition_id, parent_field, child_field, condition_config | +| `cascading_multi_parent` | 다중 부모 연쇄 | relation_id, child_field, parent_fields (ARRAY) | +| `cascading_multi_parent_source` | 다중 부모 소스 | source_id, relation_id, parent_field, source_config | +| `cascading_mutual_exclusion` | 상호 배제 | exclusion_id, field_1, field_2 | +| `cascading_reverse_lookup` | 역방향 연쇄 | lookup_id, child_field, parent_field | +| `cascading_auto_fill_group` | 자동 입력 그룹 | group_id, master_field, auto_fill_fields | +| `cascading_auto_fill_mapping` | 자동 입력 매핑 | mapping_id, group_id, source_field, target_field | + +**연쇄 드롭다운 패턴:** +``` +1. 단순 연쇄: 대분류 → 중분류 → 소분류 +2. 다단계 연쇄: 회사 → 부서 → 팀 → 직원 +3. 조건부 연쇄: 제품군에 따라 다른 옵션 표시 +4. 다중 부모: 부품은 여러 제품에 속할 수 있음 +5. 자동 입력: 거래처 선택 시 주소/연락처 자동 입력 +``` + +### 8.3 채번 규칙 (Numbering Rules) + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `numbering_rules` | 채번 규칙 마스터 | rule_id, rule_name, table_name, column_name, company_code | +| `numbering_rule_parts` | 채번 규칙 파트 | part_id, rule_id, part_order, part_type, part_config | + +**채번 규칙 예시:** +``` +수주번호: SO-20260206-001 + - SO: 고정 접두사 + - 20260206: 날짜 (YYYYMMDD) + - 001: 일련번호 (3자리) + +발주번호: PO-{회사코드}-{YYYYMM}-{시퀀스} + - PO: 고정 접두사 + - COMPANY_A: 회사 코드 + - 202602: 년월 + - 0001: 시퀀스 (4자리) +``` + +### 8.4 엑셀 업로드 매핑 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `excel_mapping_template` | 엑셀 매핑 템플릿 | template_id, template_name, table_name, column_mapping (JSONB) | + +**엑셀 업로드 프로세스:** +``` +1. 사용자가 엑셀 업로드 +2. 헤더 행 읽기 +3. excel_mapping_template에서 매칭 템플릿 조회 +4. 컬럼 자동 매핑 +5. 데이터 검증 +6. DB 삽입 +``` + +### 8.5 데이터 관계 브리지 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `data_relationship_bridge` | 테이블 간 데이터 관계 중계 | bridge_id, source_table, source_id, target_table, target_id, relation_type | + +**용도:** +- 여러 테이블에 걸친 데이터 연결 추적 +- M:N 관계 관리 +- 데이터 통합 조회 + +--- + +## ⚙️ 9. 배치 및 자동화 시스템 + +### 9.1 배치 작업 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `batch_jobs` | 배치 작업 정의 | job_id, job_name, job_type, job_config (JSONB) | +| `batch_schedules` | 배치 스케줄 | schedule_id, job_id, cron_expression | +| `batch_execution_logs` | 배치 실행 로그 | log_id, job_id, execution_status, started_at, completed_at | +| `batch_job_executions` | 배치 작업 실행 | execution_id, job_id, execution_params | +| `batch_job_parameters` | 배치 작업 파라미터 | param_id, job_id, param_name, param_value | +| `batch_configs` | 배치 설정 | config_id, job_id, config_key, config_value | +| `batch_mappings` | 배치 매핑 | mapping_id, source_config, target_config | + +**배치 작업 예시:** +- 일일 재고 실사 +- 월말 마감 처리 +- 통계 데이터 집계 +- 외부 시스템 동기화 + +### 9.2 동적 폼 데이터 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `dynamic_form_data` | 동적 폼 데이터 | form_id, table_name, form_data (JSONB) | + +**용도:** +- 런타임에 정의된 폼의 데이터 저장 +- 유연한 데이터 구조 + +--- + +## 🌍 10. 다국어 시스템 + +### 10.1 다국어 관리 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `multi_lang_category` | 다국어 키 카테고리 (2단계 계층) | category_id, category_name, parent_category_id | +| `multi_lang_key_master` | 다국어 키 마스터 | key_id, key_name, category_id, default_text | +| `multi_lang_text` | 다국어 텍스트 | text_id, key_id, language_code, translated_text | +| `language_master` | 언어 마스터 | language_code, language_name, is_active | + +**다국어 구조:** +``` +multi_lang_category (카테고리) + └─ multi_lang_key_master (키) + └─ multi_lang_text (언어별 번역) + ├─ ko: 한국어 + ├─ en: 영어 + ├─ ja: 일본어 + └─ zh: 중국어 +``` + +**사용 예시:** +```typescript +// menu_info.lang_key = "menu.sales.order" +// multi_lang_key_master: key_name = "menu.sales.order" +// multi_lang_text: +// - ko: "수주관리" +// - en: "Sales Order" +// - ja: "受注管理" +``` + +--- + +## 📧 11. 메일 및 로그 시스템 + +### 11.1 메일 시스템 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `mail_log` | 메일 발송 로그 | log_id, to_email, subject, body, sent_at, sent_status | + +### 11.2 로그 테이블 + +| 테이블 | 역할 | +|--------|------| +| `ddl_execution_log` | DDL 실행 로그 | +| `file_down_log` | 파일 다운로드 로그 | +| `login_access_log` | 로그인 접근 로그 | + +### 11.3 변경 이력 테이블 (Audit Log) + +모든 주요 테이블에는 `{table_name}_log` 테이블이 있습니다: + +| 원본 테이블 | 이력 테이블 | 트리거 함수 | +|-------------|-------------|-------------| +| `carrier_contract_mng` | `carrier_contract_mng_log` | `carrier_contract_mng_log_trigger_func()` | +| `carrier_mng` | `carrier_mng_log` | `carrier_mng_log_trigger_func()` | +| `carrier_vehicle_mng` | `carrier_vehicle_mng_log` | `carrier_vehicle_mng_log_trigger_func()` | +| `defect_standard_mng` | `defect_standard_mng_log` | - | +| `delivery_route_mng` | `delivery_route_mng_log` | - | +| `dtg_management` | `dtg_management_log` | - | +| `equipment_consumable` | `equipment_consumable_log` | - | +| `equipment_inspection_item` | `equipment_inspection_item_log` | - | +| `equipment_mng` | `equipment_mng_log` | - | +| `inspection_equipment_mng` | `inspection_equipment_mng_log` | - | +| `item_info_20251202` | `item_info_20251202_log` | - | +| `logistics_cost_mng` | `logistics_cost_mng_log` | - | +| `order_table` | `order_table_log` | - | +| `safety_inspections` | `safety_inspections_log` | - | +| `sales_order_detail` | `sales_order_detail_log` | - | +| `supplier_mng` | `supplier_mng_log` | - | +| `work_instruction` | `work_instruction_log` | - | +| `work_instruction_detail` | `work_instruction_detail_log` | - | + +**이력 테이블 구조:** +```sql +CREATE TABLE {table_name}_log ( + id SERIAL PRIMARY KEY, + operation_type VARCHAR(10), -- INSERT, UPDATE, DELETE + original_id VARCHAR(500), -- 원본 레코드 ID + changed_column VARCHAR(100), + old_value TEXT, + new_value TEXT, + changed_by VARCHAR(100), + ip_address VARCHAR(50), + changed_at TIMESTAMP DEFAULT NOW(), + full_row_before JSONB, -- 변경 전 전체 행 + full_row_after JSONB -- 변경 후 전체 행 +); +``` + +**트리거 자동 기록:** +```sql +CREATE TRIGGER trg_{table_name}_log +AFTER INSERT OR UPDATE OR DELETE ON {table_name} +FOR EACH ROW +EXECUTE FUNCTION {table_name}_log_trigger_func(); +``` + +--- + +## 🔍 12. 데이터베이스 함수 및 트리거 + +### 12.1 주요 함수 + +#### 화면 관련 + +```sql +-- 화면 생성 시 메뉴 자동 생성 +CREATE FUNCTION auto_create_menu_for_screen() RETURNS TRIGGER; + +-- 화면 삭제 시 메뉴 비활성화 +CREATE FUNCTION auto_deactivate_menu_for_screen() RETURNS TRIGGER; +``` + +#### 통계 집계 + +```sql +-- 일일 운송 통계 집계 함수 +CREATE FUNCTION aggregate_daily_transport_statistics(target_date DATE DEFAULT CURRENT_DATE - 1) +RETURNS INTEGER; +``` + +#### 거리 계산 + +```sql +-- Haversine 거리 계산 (GPS 좌표) +CREATE FUNCTION calculate_distance(lat1 NUMERIC, lng1 NUMERIC, lat2 NUMERIC, lng2 NUMERIC) +RETURNS NUMERIC; + +-- 차량 위치 이력의 이전 위치로부터 거리 계산 +CREATE FUNCTION calculate_distance_from_prev() RETURNS TRIGGER; +``` + +#### 비즈니스 로직 + +```sql +-- 수주 잔량 자동 계산 +CREATE FUNCTION calculate_order_balance() RETURNS TRIGGER; + +-- 세금계산서 합계 자동 계산 +CREATE FUNCTION calculate_tax_invoice_total() RETURNS TRIGGER; + +-- 영업에서 프로젝트 자동 생성 +CREATE FUNCTION auto_create_project_from_sales(p_sales_no VARCHAR) RETURNS VARCHAR; + +-- 차량 상태 변경 시 운행 통계 계산 +CREATE FUNCTION calculate_trip_on_status_change() RETURNS TRIGGER; +``` + +### 12.2 주요 트리거 + +```sql +-- 화면 생성 시 메뉴 자동 생성 +CREATE TRIGGER trg_auto_create_menu_for_screen +AFTER INSERT ON screen_definitions +FOR EACH ROW +EXECUTE FUNCTION auto_create_menu_for_screen(); + +-- 화면 삭제 시 메뉴 비활성화 +CREATE TRIGGER trg_auto_deactivate_menu_for_screen +AFTER UPDATE ON screen_definitions +FOR EACH ROW +EXECUTE FUNCTION auto_deactivate_menu_for_screen(); + +-- 차량 위치 이력 거리 자동 계산 +CREATE TRIGGER trg_calculate_distance_from_prev +BEFORE INSERT ON vehicle_location_history +FOR EACH ROW +EXECUTE FUNCTION calculate_distance_from_prev(); + +-- 수주 잔량 자동 계산 +CREATE TRIGGER trg_calculate_order_balance +BEFORE INSERT OR UPDATE ON orders +FOR EACH ROW +EXECUTE FUNCTION calculate_order_balance(); + +-- 세금계산서 합계 자동 계산 +CREATE TRIGGER trg_calculate_tax_invoice_total +BEFORE INSERT OR UPDATE ON tax_invoice +FOR EACH ROW +EXECUTE FUNCTION calculate_tax_invoice_total(); +``` + +--- + +## 📈 13. 인덱스 전략 + +### 13.1 필수 인덱스 + +**모든 테이블:** +```sql +-- company_code 인덱스 (멀티테넌시 필수) +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); +``` + +### 13.2 복합 인덱스 + +**자주 함께 조회되는 컬럼:** +```sql +-- 회사별, 날짜별 조회 +CREATE INDEX idx_sales_company_date ON sales_order_mng(company_code, order_date DESC); + +-- 회사별, 상태별 조회 +CREATE INDEX idx_work_orders_company_status ON work_orders(company_code, status); + +-- 회사별, 거래처별 조회 +CREATE INDEX idx_purchase_company_supplier ON purchase_order_master(company_code, partner_objid); +``` + +### 13.3 부분 인덱스 + +**특정 조건의 데이터만:** +```sql +-- 활성 상태의 메뉴만 인덱싱 +CREATE INDEX idx_menu_active ON menu_info(company_code, menu_type) +WHERE status = 'active'; + +-- 미완료 작업지시만 인덱싱 +CREATE INDEX idx_work_orders_pending ON work_orders(company_code, wo_number) +WHERE status IN ('PENDING', 'IN_PROGRESS'); +``` + +### 13.4 JSONB 인덱스 + +**JSONB 컬럼 검색:** +```sql +-- GIN 인덱스 (JSONB 전체 검색) +CREATE INDEX idx_screen_layouts_config ON screen_layouts USING GIN (layout_config); + +-- JSONB 특정 키 인덱스 +CREATE INDEX idx_flow_step_action_type ON flow_step ((action_config->>'action_type')); +``` + +--- + +## 🔒 14. 데이터베이스 보안 + +### 14.1 암호화 컬럼 + +```sql +-- 외부 DB 비밀번호 암호화 +external_db_connections.password_encrypted TEXT + +-- 외부 REST API 인증 정보 암호화 +external_rest_api_connections.auth_config JSONB +``` + +**암호화 방식:** +- AES-256-GCM +- 애플리케이션 레벨에서 암호화/복호화 +- DB에는 암호화된 값만 저장 + +### 14.2 접근 제어 + +**회사별 데이터 격리:** +```sql +-- 모든 쿼리에 company_code 필터 필수 +WHERE company_code = $1 AND company_code != '*' +``` + +**사용자별 권한 관리:** +``` +user_info + → authority_sub_user + → authority_master + → rel_menu_auth + → 메뉴별 CRUD 권한 +``` + +### 14.3 감사 로그 + +- 모든 변경 사항은 `{table_name}_log` 테이블에 기록 +- IP 주소, 사용자 ID, 변경 시각 추적 +- 변경 전후 전체 행 데이터 JSONB로 저장 + +--- + +## 🚀 15. 성능 최적화 전략 + +### 15.1 쿼리 최적화 + +**1. company_code 필터링 항상 포함** +```sql +-- ✅ Good +SELECT * FROM sales_order_mng +WHERE company_code = 'COMPANY_A' + AND order_date >= '2026-01-01'; + +-- ❌ Bad (전체 스캔) +SELECT * FROM sales_order_mng +WHERE order_date >= '2026-01-01'; +``` + +**2. JOIN 시 company_code 매칭** +```sql +-- ✅ Good +SELECT so.*, c.customer_name +FROM sales_order_mng so +LEFT JOIN customer_mng c + ON so.customer_code = c.customer_code + AND so.company_code = c.company_code -- 필수! +WHERE so.company_code = 'COMPANY_A'; + +-- ❌ Bad (크로스 조인 발생) +SELECT so.*, c.customer_name +FROM sales_order_mng so +LEFT JOIN customer_mng c + ON so.customer_code = c.customer_code +WHERE so.company_code = 'COMPANY_A'; +``` + +**3. 인덱스 활용** +```sql +-- 복합 인덱스 순서 중요 +CREATE INDEX idx_sales_company_date_status +ON sales_order_mng(company_code, order_date, status); + +-- ✅ Good (인덱스 활용) +WHERE company_code = 'COMPANY_A' + AND order_date >= '2026-01-01' + AND status = 'CONFIRMED'; + +-- ❌ Bad (인덱스 미활용) +WHERE status = 'CONFIRMED' + AND order_date >= '2026-01-01' + AND company_code = 'COMPANY_A'; +``` + +### 15.2 대용량 데이터 처리 + +**파티셔닝:** +```sql +-- 날짜 기반 파티셔닝 (예시) +CREATE TABLE vehicle_location_history ( + ... +) PARTITION BY RANGE (recorded_at); + +CREATE TABLE vehicle_location_history_2026_01 +PARTITION OF vehicle_location_history +FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'); +``` + +**배치 처리:** +```sql +-- 대량 삽입 시 COPY 사용 +COPY table_name FROM '/path/to/file.csv' WITH (FORMAT csv, HEADER true); + +-- 대량 업데이트 시 배치 단위로 +UPDATE table_name +SET status = 'PROCESSED' +WHERE id IN ( + SELECT id FROM table_name + WHERE status = 'PENDING' + LIMIT 1000 +); +``` + +### 15.3 캐싱 전략 + +**애플리케이션 레벨 캐싱:** +- 메타데이터 (table_labels, column_labels) → Redis +- 공통 코드 (comm_code) → Redis +- 메뉴 정보 (menu_info) → Redis +- 사용자 권한 (rel_menu_auth) → Redis + +**쿼리 결과 캐싱:** +- 통계 데이터 +- 집계 데이터 +- 읽기 전용 마스터 데이터 + +--- + +## 📝 16. 마이그레이션 가이드 + +### 16.1 마이그레이션 파일 목록 + +``` +db/migrations/ +├── 037_add_parent_group_to_screen_groups.sql +├── 050_create_work_orders_table.sql +├── 051_insert_work_order_screen_definition.sql +├── 052_insert_work_order_screen_layout.sql +├── 054_create_screen_management_enhancement.sql +├── 055_create_customer_item_prices_table.sql +└── plm_schema_20260120.sql (전체 스키마 덤프) +``` + +### 16.2 마이그레이션 실행 순서 + +**1. 테이블 생성** +```sql +CREATE TABLE {table_name} ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + -- 비즈니스 컬럼들... +); +``` + +**2. 인덱스 생성** +```sql +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); +CREATE INDEX idx_{table_name}_created_date ON {table_name}(created_date DESC); +-- 기타 필요한 인덱스들... +``` + +**3. 메타데이터 등록** +```sql +-- table_labels +INSERT INTO table_labels (table_name, table_label, description) +VALUES ('{table_name}', '{한글명}', '{설명}') +ON CONFLICT (table_name) DO UPDATE SET ...; + +-- table_type_columns +INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, ...) +VALUES ...; + +-- column_labels (레거시 호환) +INSERT INTO column_labels (table_name, column_name, column_label, ...) +VALUES ...; +``` + +**4. 코멘트 추가** +```sql +COMMENT ON TABLE {table_name} IS '{테이블 설명}'; +COMMENT ON COLUMN {table_name}.{column_name} IS '{컬럼 설명}'; +``` + +**5. 화면 정의 (선택)** +```sql +-- screen_definitions +INSERT INTO screen_definitions (screen_code, screen_name, table_name, ...) +VALUES ...; + +-- 트리거 자동 발동 → menu_info 자동 생성 +``` + +### 16.3 마이그레이션 롤백 + +```sql +-- 화면 정의 삭제 +DELETE FROM screen_definitions WHERE screen_code = '{screen_code}'; + +-- 메뉴 삭제 (자동 비활성화되었을 것) +DELETE FROM menu_info WHERE screen_code = '{screen_code}'; + +-- 메타데이터 삭제 +DELETE FROM column_labels WHERE table_name = '{table_name}'; +DELETE FROM table_type_columns WHERE table_name = '{table_name}'; +DELETE FROM table_labels WHERE table_name = '{table_name}'; + +-- 인덱스 삭제 +DROP INDEX IF EXISTS idx_{table_name}_company_code; + +-- 테이블 삭제 +DROP TABLE IF EXISTS {table_name}; +``` + +--- + +## 🎯 17. 데이터베이스 설계 원칙 요약 + +### 17.1 ABSOLUTE MUST (절대 필수) + +1. **모든 테이블에 company_code VARCHAR(20) NOT NULL** +2. **모든 쿼리에 company_code 필터 포함** +3. **JOIN 시 company_code 매칭 조건 포함** +4. **모든 테이블에 company_code 인덱스 생성** +5. **일반 회사는 company_code != '*' 필터 필수** + +### 17.2 표준 테이블 구조 + +```sql +CREATE TABLE {table_name} ( + -- 기본 컬럼 (표준 5종 세트) + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + company_code VARCHAR(20) NOT NULL, + + -- 비즈니스 컬럼들 + ... +); + +-- 필수 인덱스 +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); +``` + +### 17.3 메타데이터 등록 필수 + +동적 테이블 생성 시: +1. `table_labels` 등록 +2. `table_type_columns` 등록 (회사별) +3. `column_labels` 등록 (레거시 호환) +4. 코멘트 추가 + +### 17.4 쿼리 패턴 + +```sql +-- ✅ 표준 SELECT +SELECT * FROM table_name +WHERE company_code = $1 + AND company_code != '*' +ORDER BY created_date DESC; + +-- ✅ 표준 JOIN +SELECT a.*, b.name +FROM table_a a +LEFT JOIN table_b b + ON a.ref_id = b.id + AND a.company_code = b.company_code -- 필수! +WHERE a.company_code = $1; + +-- ✅ 표준 집계 +SELECT + category, + COUNT(*) as total, + SUM(amount) as total_amount +FROM sales +WHERE company_code = $1 +GROUP BY category; +``` + +--- + +## 📚 18. 참고 자료 + +### 18.1 관련 문서 + +``` +docs/ +├── DB_ARCHITECTURE_ANALYSIS.md -- 기존 상세 DB 분석 문서 +├── backend-architecture-analysis.md -- 백엔드 아키텍처 분석 +├── frontend-architecture-analysis.md -- 프론트엔드 아키텍처 분석 +└── kjs/ + ├── 멀티테넌시_구현_현황_분석_보고서.md + ├── 테이블_타입관리_성능최적화_결과.md + └── 카테고리_시스템_최종_완료_보고서.md +``` + +### 18.2 스키마 파일 + +``` +db/ +├── plm_schema_20260120.sql -- 전체 스키마 덤프 +└── migrations/ + ├── 037_add_parent_group_to_screen_groups.sql + ├── 050_create_work_orders_table.sql + ├── 051_insert_work_order_screen_definition.sql + ├── 052_insert_work_order_screen_layout.sql + ├── 054_create_screen_management_enhancement.sql + └── 055_create_customer_item_prices_table.sql +``` + +### 18.3 백엔드 서비스 매핑 + +```typescript +// backend-node/src/services/ + +// 화면 관리 +screenManagementService.ts → screen_definitions, screen_layouts + +// 테이블 관리 +tableManagementService.ts → table_labels, table_type_columns, column_labels + +// 메뉴 관리 +menuService.ts → menu_info, menu_screen_groups + +// 카테고리 관리 +categoryTreeService.ts → table_column_category_values + +// 플로우 관리 +flowDefinitionService.ts → flow_definition, flow_step +flowExecutionService.ts → flow_data_status, flow_audit_log + +// 데이터플로우 +dataflowService.ts → dataflow_diagrams, screen_data_flows + +// 외부 연동 +externalDbConnectionService.ts → external_db_connections +externalRestApiConnectionService.ts → external_rest_api_connections + +// 배치 +batchService.ts → batch_jobs, batch_execution_logs + +// 인증/권한 +authService.ts → user_info, auth_tokens +roleService.ts → authority_master, rel_menu_auth +``` + +--- + +## 🎬 19. 비즈니스 워크플로우 통합 예시 + +### 19.1 수주 → 생산 → 출하 전체 플로우 + +``` +[영업팀] +1. 견적 요청 접수 (estimate_mgmt) +2. 견적서 작성 및 발송 +3. 고객 승인 + +[영업팀] +4. 수주 등록 (sales_order_mng) + → sales_order_detail (품목별 상세) + → contract_mgmt (계약 체결) + +[생산관리팀] +5. 생산 계획 수립 (order_plan_mgmt) + → 자재 소요 계획 (MRP) + → 공정별 계획 + +[구매팀] +6. 구매 요청 (sales_request_master) + → 공급처 선정 (supplier_mng) + → 발주서 작성 (purchase_order_master) + → 발주서 상세 (purchase_order_part) + +[자재팀] +7. 입고 예정 (delivery_history) + → 검수 (check_report_mng) + → 입고 확정 (receiving) + → 재고 반영 (inventory_stock) + +[생산팀] +8. 작업지시 발행 (work_orders) + → 자재 출고 (material_release) + → 생산 실행 (production_record) + +[품질팀] +9. 품질 검사 (inspection_standard) + → 합격/불합격 판정 + → 완제품 입고 (inventory_stock) + +[물류팀] +10. 출하 계획 (shipment_plan) + → 출하 지시 (shipment_instruction) + → 차량 배차 (vehicles) + → 출고 처리 (outbound_mng) + +[운송팀] +11. 운행 시작 + → GPS 추적 (vehicle_location_history) + → 운행 로그 (transport_logs) + +[고객] +12. 납품 완료 + → 배송 상태 업데이트 (delivery_status) + → 세금계산서 발행 (tax_invoice) + +[재무팀] +13. 정산 + → 매출 인식 + → 원가 계산 (profit_loss) +``` + +### 19.2 데이터 흐름 추적 + +``` +플로우 정의 (flow_definition): "수주-생산-출하 플로우" + │ + ├─ Step 1: 수주 접수 (flow_step) + │ └─ 데이터: sales_order_mng + │ └─ flow_data_status: STEP_1_COMPLETED + │ + ├─ Step 2: 생산 계획 (flow_step) + │ └─ 데이터: order_plan_mgmt + │ └─ flow_data_status: STEP_2_COMPLETED + │ + ├─ Step 3: 발주 처리 (flow_step) + │ └─ 데이터: purchase_order_master + │ └─ flow_data_status: STEP_3_COMPLETED + │ + ├─ Step 4: 입고 처리 (flow_step) + │ └─ 데이터: receiving, inventory_stock + │ └─ flow_data_status: STEP_4_COMPLETED + │ + ├─ Step 5: 생산 실행 (flow_step) + │ └─ 데이터: work_orders, production_record + │ └─ flow_data_status: STEP_5_COMPLETED + │ + └─ Step 6: 출하 완료 (flow_step) + └─ 데이터: shipment_plan, outbound_mng + └─ flow_data_status: COMPLETED + +각 단계 변경 이력: flow_audit_log +외부 시스템 연동: flow_integration_log +``` + +--- + +**문서 작성자**: Cursor AI (DB Specialist Agent) +**문서 버전**: 2.0 +**작성일**: 2026-02-06 +**기반 스키마**: plm_schema_20260120.sql (337 테이블) +**목적**: WACE ERP 전체 워크플로우 문서화를 위한 DB 구조 분석 + +--- diff --git a/docs/WACE_SYSTEM_WORKFLOW.md b/docs/WACE_SYSTEM_WORKFLOW.md new file mode 100644 index 00000000..b9cd9f23 --- /dev/null +++ b/docs/WACE_SYSTEM_WORKFLOW.md @@ -0,0 +1,955 @@ +# WACE ERP 시스템 전체 워크플로우 문서 + +> 작성일: 2026-02-06 +> 분석 방법: Multi-Agent System (Backend + Frontend + DB 전문가 병렬 분석) + +--- + +## 목차 + +1. [시스템 개요](#1-시스템-개요) +2. [기술 스택](#2-기술-스택) +3. [전체 아키텍처](#3-전체-아키텍처) +4. [백엔드 아키텍처](#4-백엔드-아키텍처) +5. [프론트엔드 아키텍처](#5-프론트엔드-아키텍처) +6. [데이터베이스 구조](#6-데이터베이스-구조) +7. [인증/인가 워크플로우](#7-인증인가-워크플로우) +8. [화면 디자이너 워크플로우](#8-화면-디자이너-워크플로우) +9. [사용자 업무 워크플로우](#9-사용자-업무-워크플로우) +10. [플로우 엔진 워크플로우](#10-플로우-엔진-워크플로우) +11. [데이터플로우 시스템](#11-데이터플로우-시스템) +12. [대시보드 시스템](#12-대시보드-시스템) +13. [배치/스케줄 시스템](#13-배치스케줄-시스템) +14. [멀티테넌시 아키텍처](#14-멀티테넌시-아키텍처) +15. [외부 연동](#15-외부-연동) +16. [배포 환경](#16-배포-환경) + +--- + +## 1. 시스템 개요 + +WACE는 **로우코드(Low-Code) ERP 플랫폼**이다. 관리자가 코드 없이 드래그앤드롭으로 업무 화면을 설계하면, 사용자는 해당 화면으로 바로 업무를 처리할 수 있는 구조다. + +### 핵심 컨셉 + +``` +관리자 → 화면 디자이너로 화면 설계 → 메뉴에 연결 + ↓ +사용자 → 메뉴 클릭 → 화면 자동 렌더링 → 업무 수행 +``` + +### 주요 특징 + +- **드래그앤드롭 화면 디자이너**: 코드 없이 UI 구성 +- **동적 컴포넌트 시스템**: V2 통합 컴포넌트 10종으로 모든 UI 표현 +- **플로우 엔진**: 워크플로우(승인, 이동 등) 자동화 +- **데이터플로우**: 비즈니스 로직을 비주얼 다이어그램으로 설계 +- **멀티테넌시**: 회사별 완벽한 데이터 격리 +- **다국어 지원**: KR/EN/CN 다국어 라벨 관리 + +--- + +## 2. 기술 스택 + +| 영역 | 기술 | 비고 | +|------|------|------| +| **Frontend** | Next.js 15 (App Router) | React 19, TypeScript | +| **UI 라이브러리** | shadcn/ui + Radix UI | Tailwind CSS 4 | +| **상태 관리** | React Context + Zustand | React Query (서버 상태) | +| **Backend** | Node.js + Express | TypeScript | +| **Database** | PostgreSQL | Raw Query (ORM 미사용) | +| **인증** | JWT | 자동 갱신, 세션 관리 | +| **빌드/배포** | Docker | dev/prod 분리 | +| **포트** | FE: 9771(dev)/5555(prod) | BE: 8080 | + +--- + +## 3. 전체 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 사용자 브라우저 │ +│ Next.js App (React 19 + shadcn/ui + Tailwind CSS) │ +│ ├── 인증: JWT + Cookie + localStorage │ +│ ├── 상태: Context + Zustand + React Query │ +│ └── API: Axios Client (lib/api/) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ HTTP/JSON (JWT Bearer Token) + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Express Backend (Node.js) │ +│ ├── Middleware: Helmet → CORS → RateLimit → Auth → Permission │ +│ ├── Routes: 60+ 모듈 │ +│ ├── Controllers: 69개 │ +│ ├── Services: 87개 │ +│ └── Database: pg Pool (Raw Query) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ TCP/SQL + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ PostgreSQL Database │ +│ ├── 시스템 테이블: 사용자, 회사, 메뉴, 권한, 화면 │ +│ ├── 메타데이터: 테이블/컬럼 정의, 코드, 카테고리 │ +│ ├── 비즈니스: 동적 생성 테이블 (화면별) │ +│ └── 멀티테넌시: 모든 테이블에 company_code │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. 백엔드 아키텍처 + +### 4.1 디렉토리 구조 + +``` +backend-node/src/ +├── app.ts # Express 앱 진입점 +├── config/ # 환경설정, Multer +├── controllers/ # 69개 컨트롤러 +├── services/ # 87개 서비스 +├── routes/ # 60+ 라우트 모듈 +├── middleware/ # 인증, 권한, 에러 처리 +│ ├── authMiddleware.ts # JWT 인증 +│ ├── permissionMiddleware.ts # 3단계 권한 체크 +│ ├── superAdminMiddleware.ts # 슈퍼관리자 전용 +│ └── errorHandler.ts # 전역 에러 처리 +├── database/ # DB 연결, 커넥터 팩토리 +│ ├── db.ts # PostgreSQL Pool +│ ├── DatabaseConnectorFactory.ts +│ ├── PostgreSQLConnector.ts +│ ├── MySQLConnector.ts +│ └── MariaDBConnector.ts +├── types/ # TypeScript 타입 (26개) +└── utils/ # 유틸리티 (16개) +``` + +### 4.2 미들웨어 스택 (실행 순서) + +``` +요청 → Helmet (보안 헤더) + → Compression (응답 압축) + → Body Parser (JSON/URLEncoded, 10MB) + → CORS (교차 출처 허용) + → Rate Limiter (10,000 req/min) + → Token Refresh (자동 갱신) + → Route Handlers (비즈니스 로직) + → Error Handler (전역 에러 처리) +``` + +### 4.3 API 라우트 도메인별 분류 + +#### 인증/사용자 관리 +| 라우트 | 역할 | +|--------|------| +| `/api/auth` | 로그인, 로그아웃, 토큰 갱신, 회사 전환 | +| `/api/admin/users` | 사용자 CRUD, 비밀번호 초기화, 상태 변경 | +| `/api/company-management` | 회사 CRUD | +| `/api/departments` | 부서 관리 | +| `/api/roles` | 권한 그룹 관리 | + +#### 화면/메뉴 관리 +| 라우트 | 역할 | +|--------|------| +| `/api/screen-management` | 화면 정의 CRUD, 그룹, 파일, 임베딩 | +| `/api/admin/menus` | 메뉴 트리 CRUD, 화면 할당 | +| `/api/table-management` | 테이블 CRUD, 엔티티 조인, 카테고리 | +| `/api/common-codes` | 공통 코드/카테고리 관리 | +| `/api/multilang` | 다국어 키/번역 관리 | + +#### 데이터 관리 +| 라우트 | 역할 | +|--------|------| +| `/api/data` | 동적 테이블 CRUD, 조인 쿼리 | +| `/api/data/:tableName` | 특정 테이블 데이터 조회 | +| `/api/data/join` | 조인 쿼리 실행 | +| `/api/dynamic-form` | 동적 폼 데이터 저장 | +| `/api/entity-search` | 엔티티 검색 | +| `/api/entity-reference` | 엔티티 참조 | +| `/api/numbering-rules` | 채번 규칙 관리 | +| `/api/cascading-*` | 연쇄 드롭다운 관계 | + +#### 자동화 +| 라우트 | 역할 | +|--------|------| +| `/api/flow` | 플로우 정의/단계/연결/실행 | +| `/api/dataflow` | 데이터플로우 다이어그램/실행 | +| `/api/batch-configs` | 배치 작업 설정 | +| `/api/batch-management` | 배치 작업 관리 | +| `/api/batch-execution-logs` | 배치 실행 로그 | + +#### 대시보드/리포트 +| 라우트 | 역할 | +|--------|------| +| `/api/dashboards` | 대시보드 CRUD, 쿼리 실행 | +| `/api/reports` | 리포트 생성 | + +#### 외부 연동 +| 라우트 | 역할 | +|--------|------| +| `/api/external-db-connections` | 외부 DB 연결 (PostgreSQL, MySQL, MariaDB, MSSQL, Oracle) | +| `/api/external-rest-api-connections` | 외부 REST API 연결 | +| `/api/mail` | 메일 발송/수신/템플릿 | +| `/api/tax-invoice` | 세금계산서 | + +#### 특수 도메인 +| 라우트 | 역할 | +|--------|------| +| `/api/delivery` | 배송/화물 관리 | +| `/api/risk-alerts` | 위험 알림 | +| `/api/todos` | 할일 관리 | +| `/api/bookings` | 예약 관리 | +| `/api/digital-twin` | 디지털 트윈 (야드 모니터링) | +| `/api/schedule` | 스케줄 자동 생성 | +| `/api/vehicle` | 차량 운행 | +| `/api/driver` | 운전자 관리 | +| `/api/files` | 파일 업로드/다운로드 | +| `/api/ddl` | DDL 실행 (슈퍼관리자 전용) | + +### 4.4 서비스 레이어 패턴 + +```typescript +// 표준 서비스 패턴 +class ExampleService { + // 목록 조회 (멀티테넌시 적용) + async findAll(companyCode: string, filters?: any) { + if (companyCode === "*") { + // 슈퍼관리자: 전체 데이터 + return await db.query("SELECT * FROM table ORDER BY company_code"); + } else { + // 일반 사용자: 자기 회사 데이터만 + return await db.query( + "SELECT * FROM table WHERE company_code = $1", + [companyCode] + ); + } + } +} +``` + +### 4.5 에러 처리 전략 + +```typescript +// 전역 에러 핸들러 (errorHandler.ts) +- PostgreSQL 에러: 중복키(23505), 외래키(23503), 널 제약(23502) 등 +- JWT 에러: 만료, 유효하지 않은 토큰 +- 일반 에러: 500 Internal Server Error +- 개발 환경: 상세 에러 스택 포함 +- 운영 환경: 일반적인 에러 메시지만 반환 +``` + +--- + +## 5. 프론트엔드 아키텍처 + +### 5.1 디렉토리 구조 + +``` +frontend/ +├── app/ # Next.js App Router +│ ├── (auth)/ # 인증 (로그인) +│ ├── (main)/ # 메인 앱 (인증 필요) +│ ├── (pop)/ # 모바일/팝업 +│ └── (admin)/ # 특수 관리자 +├── components/ # React 컴포넌트 +│ ├── screen/ # 화면 디자이너 & 뷰어 +│ ├── admin/ # 관리 기능 +│ ├── dashboard/ # 대시보드 위젯 +│ ├── dataflow/ # 데이터플로우 디자이너 +│ ├── v2/ # V2 통합 컴포넌트 +│ ├── ui/ # shadcn/ui 기본 컴포넌트 +│ └── report/ # 리포트 디자이너 +├── lib/ +│ ├── api/ # API 클라이언트 (57개 모듈) +│ ├── registry/ # 컴포넌트 레지스트리 (482개) +│ ├── utils/ # 유틸리티 +│ └── v2-core/ # V2 코어 로직 +├── contexts/ # React Context (인증, 메뉴, 화면 등) +├── hooks/ # Custom Hooks +├── stores/ # Zustand 상태관리 +└── middleware.ts # Next.js 인증 미들웨어 +``` + +### 5.2 페이지 라우팅 구조 + +``` +/login → 로그인 +/main → 메인 대시보드 +/screens/[screenId] → 동적 화면 뷰어 (사용자) + +/admin/screenMng/screenMngList → 화면 관리 +/admin/screenMng/dashboardList → 대시보드 관리 +/admin/screenMng/reportList → 리포트 관리 +/admin/systemMng/tableMngList → 테이블 관리 +/admin/systemMng/commonCodeList → 공통코드 관리 +/admin/systemMng/dataflow → 데이터플로우 관리 +/admin/systemMng/i18nList → 다국어 관리 +/admin/userMng/userMngList → 사용자 관리 +/admin/userMng/companyList → 회사 관리 +/admin/userMng/rolesList → 권한 관리 +/admin/automaticMng/flowMgmtList → 플로우 관리 +/admin/automaticMng/batchmngList → 배치 관리 +/admin/automaticMng/mail/* → 메일 시스템 +/admin/menu → 메뉴 관리 + +/dashboard/[dashboardId] → 대시보드 뷰어 +/pop/work → 모바일 작업 화면 +``` + +### 5.3 V2 통합 컴포넌트 시스템 + +**"하나의 컴포넌트, 여러 모드"** 철학으로 설계된 10개 통합 컴포넌트: + +| 컴포넌트 | 모드 | 역할 | +|----------|------|------| +| **V2Input** | text, number, password, slider, color | 텍스트/숫자 입력 | +| **V2Select** | dropdown, radio, checkbox, tag, toggle | 선택 입력 | +| **V2Date** | date, datetime, time, range | 날짜/시간 입력 | +| **V2List** | table, card, kanban, list | 데이터 목록 표시 | +| **V2Layout** | grid, split-panel, flex | 레이아웃 구성 | +| **V2Group** | tab, accordion, section, modal | 그룹 컨테이너 | +| **V2Media** | image, video, audio, file | 미디어 표시 | +| **V2Biz** | flow, rack, numbering-rule | 비즈니스 로직 | +| **V2Hierarchy** | tree, org-chart, BOM, cascading | 계층 구조 | +| **V2Repeater** | inline-table, modal, button | 반복 데이터 | + +### 5.4 API 클라이언트 규칙 + +```typescript +// 절대 금지: fetch 직접 사용 +const res = await fetch('/api/flow/definitions'); // ❌ + +// 반드시 사용: lib/api/ 클라이언트 +import { getFlowDefinitions } from '@/lib/api/flow'; +const res = await getFlowDefinitions(); // ✅ +``` + +환경별 URL 자동 처리: +| 환경 | 프론트엔드 | 백엔드 API | +|------|-----------|-----------| +| 로컬 개발 | localhost:9771 | localhost:8080/api | +| 운영 | v1.vexplor.com | api.vexplor.com/api | + +### 5.5 상태 관리 체계 + +``` +전역 상태 +├── AuthContext → 인증/세션/토큰 +├── MenuContext → 메뉴 트리/권한 +├── ScreenPreviewContext → 프리뷰 모드 +├── ScreenMultiLangContext → 다국어 라벨 +├── TableOptionsContext → 테이블 옵션 +└── ActiveTabContext → 활성 탭 + +로컬 상태 +├── Zustand Stores → 화면 디자이너 상태, 사용자 상태 +└── React Query → 서버 데이터 캐시 (5분 stale, 30분 GC) +``` + +### 5.6 레지스트리 시스템 + +```typescript +// 컴포넌트 등록 (482개 등록됨) +ComponentRegistry.registerComponent({ + id: "v2-input", + name: "통합 입력", + category: ComponentCategory.V2, + component: V2Input, + configPanel: V2InputConfigPanel, + defaultConfig: { inputType: "text" } +}); + +// 동적 렌더링 + +``` + +--- + +## 6. 데이터베이스 구조 + +### 6.1 테이블 도메인별 분류 + +#### 사용자/인증/회사 +| 테이블 | 역할 | +|--------|------| +| `company_mng` | 회사 마스터 | +| `user_info` | 사용자 정보 | +| `user_info_history` | 사용자 변경 이력 | +| `user_dept` | 사용자-부서 매핑 | +| `dept_info` | 부서 정보 | +| `authority_master` | 권한 그룹 마스터 | +| `authority_sub_user` | 사용자-권한 매핑 | +| `login_access_log` | 로그인 로그 | + +#### 메뉴/화면 +| 테이블 | 역할 | +|--------|------| +| `menu_info` | 메뉴 트리 구조 | +| `screen_definitions` | 화면 정의 (screenId, 테이블명 등) | +| `screen_layouts_v2` | V2 레이아웃 (JSON) | +| `screen_layouts` | V1 레이아웃 (레거시) | +| `screen_groups` | 화면 그룹 (계층구조) | +| `screen_group_screens` | 화면-그룹 매핑 | +| `screen_menu_assignments` | 화면-메뉴 할당 | +| `screen_field_joins` | 화면 필드 조인 설정 | +| `screen_data_flows` | 화면 데이터 플로우 | +| `screen_table_relations` | 화면-테이블 관계 | + +#### 메타데이터 +| 테이블 | 역할 | +|--------|------| +| `table_type_columns` | 테이블 타입별 컬럼 정의 (회사별) | +| `table_column_category_values` | 컬럼 카테고리 값 | +| `code_category` | 공통 코드 카테고리 | +| `code_info` | 공통 코드 값 | +| `category_column_mapping` | 카테고리-컬럼 매핑 | +| `cascading_relation` | 연쇄 드롭다운 관계 | +| `numbering_rules` | 채번 규칙 | +| `numbering_rule_parts` | 채번 규칙 파트 | + +#### 플로우/자동화 +| 테이블 | 역할 | +|--------|------| +| `flow_definition` | 플로우 정의 | +| `flow_step` | 플로우 단계 | +| `flow_step_connection` | 플로우 단계 연결 | +| `node_flows` | 노드 플로우 (버튼 액션) | +| `dataflow_diagrams` | 데이터플로우 다이어그램 | +| `batch_definitions` | 배치 작업 정의 | +| `batch_schedules` | 배치 스케줄 | +| `batch_execution_logs` | 배치 실행 로그 | + +#### 외부 연동 +| 테이블 | 역할 | +|--------|------| +| `external_db_connections` | 외부 DB 연결 정보 | +| `external_rest_api_connections` | 외부 REST API 연결 | + +#### 다국어 +| 테이블 | 역할 | +|--------|------| +| `multi_lang_key_master` | 다국어 키 마스터 | + +#### 기타 +| 테이블 | 역할 | +|--------|------| +| `work_history` | 작업 이력 | +| `todo_items` | 할일 목록 | +| `file_uploads` | 파일 업로드 | +| `ddl_audit_log` | DDL 감사 로그 | + +### 6.2 동적 테이블 생성 패턴 + +관리자가 화면 생성 시 비즈니스 테이블이 동적으로 생성된다: + +```sql +CREATE TABLE "dynamic_table_name" ( + "id" VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" TIMESTAMP DEFAULT now(), + "updated_date" TIMESTAMP DEFAULT now(), + "writer" VARCHAR(500), + "company_code" VARCHAR(500), -- 멀티테넌시 필수! + -- 사용자 정의 컬럼들 (모두 VARCHAR(500)) + "product_name" VARCHAR(500), + "price" VARCHAR(500), + ... +); +CREATE INDEX idx_dynamic_company ON "dynamic_table_name"(company_code); +``` + +### 6.3 테이블 관계도 + +``` +company_mng (company_code PK) + │ + ├── user_info (company_code FK) + │ ├── authority_sub_user (user_id FK) + │ └── user_dept (user_id FK) + │ + ├── menu_info (company_code) + │ └── screen_menu_assignments (menu_objid FK) + │ + ├── screen_definitions (company_code) + │ ├── screen_layouts_v2 (screen_id FK) + │ ├── screen_groups → screen_group_screens (screen_id FK) + │ └── screen_field_joins (screen_id FK) + │ + ├── authority_master (company_code) + │ └── authority_sub_user (master_objid FK) + │ + ├── flow_definition (company_code) + │ ├── flow_step (flow_id FK) + │ └── flow_step_connection (flow_id FK) + │ + └── [동적 비즈니스 테이블들] (company_code) +``` + +--- + +## 7. 인증/인가 워크플로우 + +### 7.1 로그인 프로세스 + +``` +┌─── 사용자 ───┐ ┌─── 프론트엔드 ───┐ ┌─── 백엔드 ───┐ ┌─── DB ───┐ +│ │ │ │ │ │ │ │ +│ ID/PW 입력 │────→│ POST /auth/login │────→│ 비밀번호 검증 │────→│ user_info│ +│ │ │ │ │ │ │ 조회 │ +│ │ │ │ │ JWT 토큰 생성 │ │ │ +│ │ │ │←────│ 토큰 반환 │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ localStorage 저장│ │ │ │ │ +│ │ │ Cookie 저장 │ │ │ │ │ +│ │ │ /main 리다이렉트 │ │ │ │ │ +└──────────────┘ └──────────────────┘ └──────────────┘ └──────────┘ +``` + +### 7.2 JWT 토큰 관리 + +``` +토큰 저장: localStorage (주 저장소) + Cookie (SSR 미들웨어용) + +자동 갱신: +├── 10분마다 만료 시간 체크 +├── 만료 30분 전: 백그라운드 자동 갱신 +├── 401 응답 시: 즉시 갱신 시도 +└── 갱신 실패 시: /login 리다이렉트 + +세션 관리: +├── 데스크톱: 30분 비활성 → 세션 만료 (5분 전 경고) +└── 모바일: 24시간 비활성 → 세션 만료 (1시간 전 경고) +``` + +### 7.3 권한 체계 (3단계) + +``` +SUPER_ADMIN (company_code = "*") +├── 모든 회사 데이터 접근 가능 +├── DDL 실행 가능 +├── 시스템 설정 변경 +└── 다른 회사로 전환 (switch-company) + +COMPANY_ADMIN (userType = "COMPANY_ADMIN") +├── 자기 회사 데이터만 접근 +├── 사용자 관리 가능 +└── 메뉴/화면 관리 가능 + +USER (일반 사용자) +├── 자기 회사 데이터만 접근 +├── 권한 그룹에 따른 메뉴 접근 +└── 할당된 화면만 사용 가능 +``` + +--- + +## 8. 화면 디자이너 워크플로우 + +### 8.1 관리자: 화면 설계 + +``` +Step 1: 화면 생성 + └→ /admin/screenMng/screenMngList + └→ "새 화면" 클릭 → 화면명, 설명, 메인 테이블 입력 + +Step 2: 화면 디자이너 진입 (ScreenDesigner.tsx) + ├── 좌측 패널: 컴포넌트 팔레트 (V2 컴포넌트 10종) + ├── 중앙 캔버스: 드래그앤드롭 영역 + └── 우측 패널: 선택된 컴포넌트 속성 설정 + +Step 3: 컴포넌트 배치 + └→ V2Input 드래그 → 캔버스 배치 → 속성 설정: + ├── 위치: x, y 좌표 + ├── 크기: width, height + ├── 데이터 바인딩: columnName = "product_name" + ├── 라벨: "제품명" + ├── 조건부 표시: 특정 조건에서만 보이기 + └── 플로우 연결: 버튼 클릭 시 실행할 플로우 + +Step 4: 레이아웃 저장 + └→ screen_layouts_v2 테이블에 JSON 형태로 저장 + └→ Zod 스키마 검증 → V2 형식 우선, V1 호환 저장 + +Step 5: 메뉴에 화면 할당 + └→ /admin/menu → 메뉴 트리에서 "제품 관리" 선택 + └→ 화면 연결 (screen_menu_assignments) +``` + +### 8.2 화면 레이아웃 저장 구조 (V2) + +```json +{ + "version": "v2", + "components": [ + { + "id": "comp-1", + "componentType": "v2-input", + "position": { "x": 100, "y": 50 }, + "size": { "width": 200, "height": 40 }, + "config": { + "inputType": "text", + "columnName": "product_name", + "label": "제품명", + "required": true + } + }, + { + "id": "comp-2", + "componentType": "v2-list", + "position": { "x": 100, "y": 150 }, + "size": { "width": 600, "height": 400 }, + "config": { + "listType": "table", + "tableName": "products", + "columns": ["product_name", "price", "quantity"] + } + } + ] +} +``` + +--- + +## 9. 사용자 업무 워크플로우 + +### 9.1 전체 흐름 + +``` +사용자 로그인 + ↓ +메인 대시보드 (/main) + ↓ +좌측 메뉴에서 "제품 관리" 클릭 + ↓ +/screens/[screenId] 라우팅 + ↓ +InteractiveScreenViewer 렌더링 + ├── screen_definitions에서 화면 정보 로드 + ├── screen_layouts_v2에서 레이아웃 JSON 로드 + ├── V2 → Legacy 변환 (호환성) + └── 메인 테이블 데이터 자동 로드 + ↓ +컴포넌트별 렌더링 + ├── V2Input → formData 바인딩 + ├── V2List → 테이블 데이터 표시 + ├── V2Select → 드롭다운/라디오 선택 + └── Button → 플로우/액션 연결 + ↓ +사용자 인터랙션 + ├── 폼 입력 → formData 업데이트 + ├── 테이블 행 선택 → selectedRowsData 업데이트 + └── 버튼 클릭 → 플로우 실행 + ↓ +플로우 실행 (nodeFlowButtonExecutor) + ├── Step 1: 데이터 검증 + ├── Step 2: API 호출 (INSERT/UPDATE/DELETE) + ├── Step 3: 성공/실패 처리 + └── Step 4: 테이블 자동 새로고침 +``` + +### 9.2 조건부 표시 워크플로우 + +``` +관리자 설정: + "특별 할인 입력" 컴포넌트 + └→ 조건: product_type === "PREMIUM" 일 때만 표시 + +사용자 사용: + 1. 화면 진입 → evaluateConditional() 실행 + 2. product_type ≠ "PREMIUM" → "특별 할인 입력" 숨김 + 3. 사용자가 product_type을 "PREMIUM"으로 변경 + 4. formData 업데이트 → evaluateConditional() 재평가 + 5. product_type === "PREMIUM" → "특별 할인 입력" 표시! +``` + +--- + +## 10. 플로우 엔진 워크플로우 + +### 10.1 플로우 정의 (관리자) + +``` +/admin/automaticMng/flowMgmtList + ↓ +플로우 생성: + ├── 이름: "제품 승인 플로우" + ├── 테이블: "products" + └── 단계 정의: + Step 1: "신청" (requester) + Step 2: "부서장 승인" (manager) + Step 3: "최종 승인" (director) + 연결: Step 1 → Step 2 → Step 3 +``` + +### 10.2 플로우 실행 (사용자) + +``` +1. 사용자: 제품 신청 + └→ "저장" 버튼 클릭 + └→ flowApi.startFlow() → 상태: "부서장 승인 대기" + +2. 부서장: 승인 화면 + └→ V2Biz (flow) 컴포넌트 → 현재 단계 표시 + └→ [승인] 클릭 → flowApi.approveStep() + └→ 상태: "최종 승인 대기" + +3. 이사: 최종 승인 + └→ [승인] 클릭 → flowApi.approveStep() + └→ 상태: "완료" + └→ products.approval_status = "APPROVED" +``` + +### 10.3 데이터 이동 (moveData) + +``` +플로우의 핵심 동작: 데이터를 한 스텝에서 다음 스텝으로 이동 + +Step 1 (접수) → Step 2 (검토) → Step 3 (완료) + ├── 단건 이동: moveData(flowId, dataId, fromStep, toStep) + └── 배치 이동: moveBatchData(flowId, dataIds[], fromStep, toStep) +``` + +--- + +## 11. 데이터플로우 시스템 + +### 11.1 개요 + +데이터플로우는 비즈니스 로직을 **비주얼 다이어그램**으로 설계하는 시스템이다. + +``` +/admin/systemMng/dataflow + ↓ +React Flow 기반 캔버스 + ├── InputNode: 데이터 입력 (폼 데이터, 테이블 데이터) + ├── TransformNode: 데이터 변환 (매핑, 필터링, 계산) + ├── DatabaseNode: DB 조회/저장 + ├── RestApiNode: 외부 API 호출 + ├── ConditionNode: 조건 분기 + ├── LoopNode: 반복 처리 + ├── MergeNode: 데이터 합치기 + └── OutputNode: 결과 출력 +``` + +### 11.2 데이터플로우 실행 + +``` +버튼 클릭 → 데이터플로우 트리거 + ↓ +InputNode: formData 수집 + ↓ +TransformNode: 데이터 가공 + ↓ +ConditionNode: 조건 분기 (가격 > 10000?) + ├── Yes → DatabaseNode: INSERT INTO premium_products + └── No → DatabaseNode: INSERT INTO standard_products + ↓ +OutputNode: 결과 반환 → toast.success("저장 완료") +``` + +--- + +## 12. 대시보드 시스템 + +### 12.1 구조 + +``` +관리자: /admin/screenMng/dashboardList + └→ 대시보드 생성 → 위젯 추가 → 레이아웃 저장 + +사용자: /dashboard/[dashboardId] + └→ 위젯 그리드 렌더링 → 실시간 데이터 표시 +``` + +### 12.2 위젯 종류 + +| 카테고리 | 위젯 | 역할 | +|----------|------|------| +| 시각화 | CustomMetricWidget | 커스텀 메트릭 표시 | +| | StatusSummaryWidget | 상태 요약 | +| 리스트 | CargoListWidget | 화물 목록 | +| | VehicleListWidget | 차량 목록 | +| 지도 | MapTestWidget | 지도 표시 | +| | WeatherMapWidget | 날씨 지도 | +| 작업 | TodoWidget | 할일 목록 | +| | WorkHistoryWidget | 작업 이력 | +| 알림 | BookingAlertWidget | 예약 알림 | +| | RiskAlertWidget | 위험 알림 | +| 기타 | ClockWidget | 시계 | +| | CalendarWidget | 캘린더 | + +--- + +## 13. 배치/스케줄 시스템 + +### 13.1 구조 + +``` +관리자: /admin/automaticMng/batchmngList + ↓ +배치 작업 생성: + ├── 이름: "일일 재고 집계" + ├── 실행 쿼리: SQL 또는 데이터플로우 ID + ├── 스케줄: Cron 표현식 ("0 0 * * *" = 매일 자정) + └── 활성화/비활성화 + ↓ +배치 스케줄러 (batch_schedules) + ↓ +자동 실행 → 실행 로그 (batch_execution_logs) +``` + +### 13.2 배치 실행 흐름 + +``` +Cron 트리거 → 배치 정의 조회 → SQL/데이터플로우 실행 + ↓ +성공: execution_log에 "SUCCESS" 기록 +실패: execution_log에 "FAILED" + 에러 메시지 기록 +``` + +--- + +## 14. 멀티테넌시 아키텍처 + +### 14.1 핵심 원칙 + +``` +모든 비즈니스 테이블: company_code 컬럼 필수 +모든 쿼리: WHERE company_code = $1 필수 +모든 JOIN: ON a.company_code = b.company_code 필수 +모든 집계: GROUP BY company_code 필수 +``` + +### 14.2 데이터 격리 + +``` +회사 A (company_code = "COMPANY_A"): + └→ 자기 데이터만 조회/수정/삭제 가능 + +회사 B (company_code = "COMPANY_B"): + └→ 자기 데이터만 조회/수정/삭제 가능 + +슈퍼관리자 (company_code = "*"): + └→ 모든 회사 데이터 조회 가능 + └→ 일반 회사는 "*" 데이터를 볼 수 없음 + +중요: company_code = "*"는 공통 데이터가 아니라 슈퍼관리자 전용 데이터! +``` + +### 14.3 코드 패턴 + +```typescript +// 백엔드 표준 패턴 +const companyCode = req.user!.companyCode; + +if (companyCode === "*") { + // 슈퍼관리자: 전체 데이터 + query = "SELECT * FROM table ORDER BY company_code"; +} else { + // 일반 사용자: 자기 회사만, "*" 제외 + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} +``` + +--- + +## 15. 외부 연동 + +### 15.1 외부 DB 연결 + +``` +지원 DB: PostgreSQL, MySQL, MariaDB, MSSQL, Oracle + +관리: /api/external-db-connections + ├── 연결 정보 등록 (host, port, database, credentials) + ├── 연결 테스트 + ├── 쿼리 실행 + └── 데이터플로우에서 DatabaseNode로 사용 +``` + +### 15.2 외부 REST API 연결 + +``` +관리: /api/external-rest-api-connections + ├── API 엔드포인트 등록 (URL, method, headers) + ├── 인증 설정 (Bearer, Basic, API Key) + ├── 테스트 호출 + └── 데이터플로우에서 RestApiNode로 사용 +``` + +### 15.3 메일 시스템 + +``` +관리: /admin/automaticMng/mail/* + ├── 메일 템플릿 관리 + ├── 메일 발송 (개별/대량) + ├── 수신 메일 확인 + └── 발송 이력 조회 +``` + +--- + +## 16. 배포 환경 + +### 16.1 Docker 구성 + +``` +개발 환경 (Mac): +├── docker/dev/docker-compose.backend.mac.yml (BE: 8080) +└── docker/dev/docker-compose.frontend.mac.yml (FE: 9771) + +운영 환경: +├── docker/prod/docker-compose.backend.prod.yml (BE: 8080) +└── docker/prod/docker-compose.frontend.prod.yml (FE: 5555) +``` + +### 16.2 서버 정보 + +| 환경 | 서버 | 포트 | DB | +|------|------|------|-----| +| 개발 | 39.117.244.52 | FE:9771, BE:8080 | 39.117.244.52:11132 | +| 운영 | 211.115.91.141 | FE:5555, BE:8080 | 211.115.91.141:11134 | + +### 16.3 백엔드 시작 시 자동 작업 + +``` +서버 시작 (app.ts) + ├── 마이그레이션 실행 (DB 스키마 업데이트) + ├── 배치 스케줄러 초기화 + ├── 위험 알림 캐시 로드 + └── 메일 정리 Cron 시작 +``` + +--- + +## 부록: 업무 진행 요약 + +### 새로운 업무 화면을 만드는 전체 프로세스 + +``` +1. [DB] 테이블 관리에서 비즈니스 테이블 생성 + └→ 컬럼 정의, 타입 설정 + +2. [화면] 화면 관리에서 새 화면 생성 + └→ 메인 테이블 지정 + +3. [디자인] 화면 디자이너에서 UI 구성 + └→ V2 컴포넌트 배치, 데이터 바인딩 + +4. [로직] 데이터플로우 설계 (필요시) + └→ 저장/수정/삭제 로직 다이어그램 + +5. [플로우] 플로우 정의 (승인 프로세스 필요시) + └→ 단계 정의, 연결 + +6. [메뉴] 메뉴에 화면 할당 + └→ 사용자가 접근할 수 있게 메뉴 트리 배치 + +7. [권한] 권한 그룹에 메뉴 할당 + └→ 특정 사용자 그룹만 접근 가능하게 + +8. [사용] 사용자가 메뉴 클릭 → 업무 시작! +``` diff --git a/docs/backend-analysis-README.md b/docs/backend-analysis-README.md new file mode 100644 index 00000000..27694d2e --- /dev/null +++ b/docs/backend-analysis-README.md @@ -0,0 +1,246 @@ +# WACE ERP Backend - 분석 문서 인덱스 + +> **분석 완료일**: 2026-02-06 +> **분석자**: Backend Specialist + +--- + +## 📚 문서 목록 + +### 1. 📖 상세 분석 문서 +**파일**: `backend-architecture-detailed-analysis.md` +**내용**: 백엔드 전체 아키텍처 상세 분석 (16개 섹션) + +- 전체 개요 및 기술 스택 +- 디렉토리 구조 +- 미들웨어 스택 구성 +- 인증/인가 시스템 (JWT, 3단계 권한) +- 멀티테넌시 구현 방식 +- API 라우트 전체 목록 +- 비즈니스 도메인별 모듈 (8개 도메인) +- 데이터베이스 접근 방식 (Raw Query) +- 외부 시스템 연동 (DB/REST API) +- 배치/스케줄 처리 (node-cron) +- 파일 처리 (multer) +- 에러 핸들링 +- 로깅 시스템 (Winston) +- 보안 및 권한 관리 +- 성능 최적화 + +**특징**: 워크플로우 문서에 통합하기 위한 완전한 아키텍처 분석 + +--- + +### 2. 📄 요약 문서 +**파일**: `backend-architecture-summary.md` +**내용**: 백엔드 아키텍처 핵심 요약 (16개 섹션 압축) + +- 기술 스택 요약 +- 계층 구조 다이어그램 +- 디렉토리 구조 +- 미들웨어 스택 순서 +- 인증/인가 흐름도 +- 멀티테넌시 핵심 원칙 +- API 라우트 카테고리별 정리 +- 비즈니스 도메인 8개 요약 +- 데이터베이스 접근 패턴 +- 외부 연동 아키텍처 +- 배치 스케줄러 시스템 +- 파일 처리 흐름 +- 보안 정책 +- 에러 핸들링 전략 +- 로깅 구조 +- 성능 최적화 전략 +- **핵심 체크리스트** (개발 시 필수 규칙 8개) + +**특징**: 빠른 참조를 위한 간결한 요약 + +--- + +### 3. 🔗 API 라우트 완전 매핑 +**파일**: `backend-api-route-mapping.md` +**내용**: 프론트엔드 개발자용 API 엔드포인트 전체 목록 (200+개) + +#### 포함된 API 카테고리 +1. 인증 API (7개) +2. 관리자 API (15개) +3. 테이블 관리 API (30개) +4. 화면 관리 API (10개) +5. 플로우 API (15개) +6. 데이터플로우 API (10개) +7. 외부 연동 API (15개) +8. 배치 API (10개) +9. 메일 API (5개) +10. 파일 API (5개) +11. 대시보드 API (5개) +12. 공통코드 API (3개) +13. 다국어 API (3개) +14. 회사 관리 API (4개) +15. 부서 API (2개) +16. 권한 그룹 API (2개) +17. DDL 실행 API (1개) +18. 외부 API 프록시 (2개) +19. 디지털 트윈 API (3개) +20. 3D 필드 API (2개) +21. 스케줄 API (1개) +22. 채번 규칙 API (3개) +23. 엔티티 검색 API (2개) +24. To-Do API (3개) +25. 예약 요청 API (2개) +26. 리스크/알림 API (2개) +27. 헬스 체크 (1개) + +#### 각 API 정보 포함 +- HTTP 메서드 +- 엔드포인트 경로 +- 필요 권한 (공개/인증/관리자/슈퍼관리자) +- 기능 설명 +- Request Body/Query Params +- Response 형식 + +#### 추가 정보 +- Base URL (개발/운영) +- 공통 헤더 (Authorization) +- 응답 형식 (성공/에러) +- 에러 코드 목록 + +**특징**: 프론트엔드에서 API 호출 시 즉시 참조 가능 + +--- + +### 4. 📊 JSON 응답 요약 +**파일**: `backend-analysis-response.json` +**내용**: 구조화된 JSON 형식의 분석 결과 + +```json +{ + "status": "success", + "confidence": "high", + "result": { + "summary": "...", + "details": "...", + "files_affected": [...], + "key_findings": { + "architecture_pattern": "...", + "tech_stack": {...}, + "middleware_stack": [...], + "authentication_flow": {...}, + "permission_levels": {...}, + "multi_tenancy": {...}, + "business_domains": {...}, + "database_access": {...}, + "security": {...}, + "performance_optimization": {...} + }, + "critical_rules": [...] + } +} +``` + +**특징**: 프로그래밍 방식으로 분석 결과 활용 가능 + +--- + +## 🎯 핵심 요약 + +### 아키텍처 +- **패턴**: Layered Architecture (Controller → Service → Database) +- **언어**: TypeScript (Strict Mode) +- **프레임워크**: Express.js +- **데이터베이스**: PostgreSQL (Raw Query, Connection Pool) +- **인증**: JWT (24시간 만료, 자동 갱신) + +### 멀티테넌시 +```typescript +// ✅ 핵심 원칙 +const companyCode = req.user!.companyCode; // JWT에서 추출 + +if (companyCode === "*") { + // 슈퍼관리자: 모든 데이터 + query = "SELECT * FROM table ORDER BY company_code"; +} else { + // 일반 사용자: 자기 회사만 + 슈퍼관리자 숨김 + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} +``` + +### 권한 체계 (3단계) +1. **SUPER_ADMIN** (`company_code = "*"`) + - 전체 회사 데이터 접근 + - DDL 실행, 회사 생성/삭제 + +2. **COMPANY_ADMIN** (`company_code = "ILSHIN"`) + - 자기 회사 데이터만 접근 + - 사용자/설정 관리 + +3. **USER** (`company_code = "ILSHIN"`) + - 자기 회사 데이터만 접근 + - 읽기/쓰기만 + +### 주요 도메인 (8개) +1. **관리자** - 사용자/메뉴/권한 +2. **테이블/화면** - 메타데이터, 동적 화면 +3. **플로우** - 워크플로우 엔진 +4. **데이터플로우** - ERD, 관계도 +5. **외부 연동** - 외부 DB/REST API +6. **배치** - Cron 스케줄러 +7. **메일** - 발송/수신 +8. **파일** - 업로드/다운로드 + +### API 통계 +- **총 라우트**: 70+개 +- **총 API**: 200+개 +- **컨트롤러**: 70+개 +- **서비스**: 80+개 +- **미들웨어**: 4개 + +--- + +## 🚨 개발 시 필수 규칙 + +✅ **모든 쿼리에 `company_code` 필터 추가** +✅ **JWT 토큰에서 `company_code` 추출 (클라이언트 신뢰 금지)** +✅ **Parameterized Query 사용 (SQL Injection 방지)** +✅ **슈퍼관리자 데이터 숨김 (`company_code != '*'`)** +✅ **비밀번호는 bcrypt, 민감정보는 AES-256** +✅ **에러 핸들링 try/catch 필수** +✅ **트랜잭션이 필요한 경우 `transaction()` 사용** +✅ **파일 업로드는 회사별 디렉토리 분리** + +--- + +## 📁 문서 위치 + +``` +ERP-node/docs/ +├── backend-architecture-detailed-analysis.md (상세 분석, 16개 섹션) +├── backend-architecture-summary.md (요약, 간결한 참조) +├── backend-api-route-mapping.md (API 200+개 전체 매핑) +└── backend-analysis-response.json (JSON 구조화 데이터) +``` + +--- + +## 🔍 문서 사용 가이드 + +### 처음 백엔드를 이해하려면 +→ `backend-architecture-summary.md` 읽기 (20분) + +### 특정 기능을 구현하려면 +→ `backend-architecture-detailed-analysis.md`에서 해당 도메인 섹션 참조 + +### API를 호출하려면 +→ `backend-api-route-mapping.md`에서 엔드포인트 검색 + +### 워크플로우 문서에 통합하려면 +→ `backend-architecture-detailed-analysis.md` 전체 복사 + +### 프로그래밍 방식으로 활용하려면 +→ `backend-analysis-response.json` 파싱 + +--- + +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-02-06 +**다음 업데이트 예정**: 신규 API 추가 시 diff --git a/docs/backend-analysis-response.json b/docs/backend-analysis-response.json new file mode 100644 index 00000000..b6b11bb1 --- /dev/null +++ b/docs/backend-analysis-response.json @@ -0,0 +1,239 @@ +{ + "status": "success", + "confidence": "high", + "result": { + "summary": "WACE ERP 백엔드 전체 아키텍처 분석 완료", + "details": "Node.js + Express + TypeScript + PostgreSQL Raw Query 기반 멀티테넌시 시스템. 70+ 라우트, 70+ 컨트롤러, 80+ 서비스로 구성된 계층형 아키텍처. JWT 인증, 3단계 권한 체계(SUPER_ADMIN/COMPANY_ADMIN/USER), company_code 기반 완전한 데이터 격리 구현.", + "files_affected": [ + "docs/backend-architecture-detailed-analysis.md (상세 분석 문서)", + "docs/backend-architecture-summary.md (요약 문서)", + "docs/backend-api-route-mapping.md (API 라우트 전체 매핑)" + ], + "key_findings": { + "architecture_pattern": "Layered Architecture (Controller → Service → Database)", + "tech_stack": { + "language": "TypeScript", + "runtime": "Node.js 20.10.0+", + "framework": "Express.js", + "database": "PostgreSQL (pg 라이브러리, Raw Query)", + "authentication": "JWT (jsonwebtoken)", + "scheduler": "node-cron", + "external_db_support": ["PostgreSQL", "MySQL", "MSSQL", "Oracle"] + }, + "directory_structure": { + "controllers": "70+ 파일 (API 요청 수신, 응답 생성)", + "services": "80+ 파일 (비즈니스 로직, 트랜잭션 관리)", + "routes": "70+ 파일 (API 라우팅)", + "middleware": "4개 (인증, 권한, 슈퍼관리자, 에러핸들러)", + "types": "26개 (TypeScript 타입 정의)", + "utils": "유틸리티 함수 (JWT, 암호화, 로거)" + }, + "middleware_stack": [ + "1. Process Level Exception Handlers", + "2. Helmet (보안 헤더)", + "3. Compression (Gzip)", + "4. Body Parser (10MB limit)", + "5. Static Files (/uploads)", + "6. CORS (credentials: true)", + "7. Rate Limiting (1분 10000회)", + "8. Token Auto Refresh (1시간 이내 만료 시 갱신)", + "9. API Routes (70+개)", + "10. 404 Handler", + "11. Error Handler" + ], + "authentication_flow": { + "step1": "로그인 요청 → AuthController.login()", + "step2": "AuthService.processLogin() → loginPwdCheck() (bcrypt 검증)", + "step3": "getPersonBeanFromSession() → 사용자 정보 조회", + "step4": "insertLoginAccessLog() → 로그인 이력 저장", + "step5": "JwtUtils.generateToken() → JWT 토큰 생성", + "step6": "응답: { token, userInfo, firstMenuPath }" + }, + "jwt_payload": { + "userId": "사용자 ID", + "userName": "사용자명", + "companyCode": "회사 코드 (멀티테넌시 키)", + "userType": "권한 레벨 (SUPER_ADMIN/COMPANY_ADMIN/USER)", + "exp": "만료 시간 (24시간)" + }, + "permission_levels": { + "SUPER_ADMIN": { + "company_code": "*", + "userType": "SUPER_ADMIN", + "capabilities": [ + "전체 회사 데이터 접근", + "DDL 실행", + "회사 생성/삭제", + "시스템 설정 변경" + ] + }, + "COMPANY_ADMIN": { + "company_code": "특정 회사 (예: ILSHIN)", + "userType": "COMPANY_ADMIN", + "capabilities": [ + "자기 회사 데이터만 접근", + "자기 회사 사용자 관리", + "회사 설정 변경" + ] + }, + "USER": { + "company_code": "특정 회사", + "userType": "USER", + "capabilities": [ + "자기 회사 데이터만 접근", + "읽기/쓰기 권한만" + ] + } + }, + "multi_tenancy": { + "principle": "모든 쿼리에 company_code 필터 필수", + "pattern": "JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)", + "super_admin_visibility": "일반 회사 사용자에게 슈퍼관리자(company_code='*') 숨김", + "correct_pattern": "WHERE company_code = $1 AND company_code != '*'", + "wrong_pattern": "req.body.companyCode 사용 (보안 위험!)" + }, + "api_routes": { + "total_count": "200+개", + "categories": { + "인증/관리자": "15개", + "테이블/화면": "40개", + "플로우": "15개", + "데이터플로우": "5개", + "외부 연동": "15개", + "배치": "10개", + "메일": "5개", + "파일": "5개", + "기타": "90개" + } + }, + "business_domains": { + "관리자": { + "controller": "adminController.ts", + "service": "adminService.ts", + "features": ["사용자 관리", "메뉴 관리", "권한 그룹 관리", "시스템 설정"] + }, + "테이블/화면": { + "controller": "tableManagementController.ts, screenManagementController.ts", + "service": "tableManagementService.ts, screenManagementService.ts", + "features": ["테이블 메타데이터", "화면 정의", "화면 그룹", "테이블 로그", "엔티티 관계"] + }, + "플로우": { + "controller": "flowController.ts", + "service": "flowExecutionService.ts, flowDefinitionService.ts", + "features": ["워크플로우 설계", "단계 관리", "데이터 이동", "조건부 이동", "오딧 로그"] + }, + "데이터플로우": { + "controller": "dataflowController.ts, dataflowDiagramController.ts", + "service": "dataflowService.ts, dataflowDiagramService.ts", + "features": ["테이블 관계 정의", "ERD", "다이어그램 시각화", "관계 실행"] + }, + "외부 연동": { + "controller": "externalDbConnectionController.ts, externalRestApiConnectionController.ts", + "service": "externalDbConnectionService.ts, dbConnectionManager.ts", + "features": ["외부 DB 연결", "Connection Pool 관리", "REST API 프록시"] + }, + "배치": { + "controller": "batchController.ts, batchManagementController.ts", + "service": "batchService.ts, batchSchedulerService.ts", + "features": ["Cron 스케줄러", "외부 DB → 내부 DB 동기화", "컬럼 매핑", "실행 이력"] + }, + "메일": { + "controller": "mailSendSimpleController.ts, mailReceiveBasicController.ts", + "service": "mailSendSimpleService.ts, mailReceiveBasicService.ts", + "features": ["메일 발송 (nodemailer)", "메일 수신 (IMAP)", "템플릿 관리", "첨부파일"] + }, + "파일": { + "controller": "fileController.ts, screenFileController.ts", + "service": "fileSystemManager.ts", + "features": ["파일 업로드 (multer)", "파일 다운로드", "화면별 파일 관리"] + } + }, + "database_access": { + "connection_pool": { + "min": "2~5 (환경별)", + "max": "10~20 (환경별)", + "connectionTimeout": "30000ms", + "idleTimeout": "600000ms", + "statementTimeout": "60000ms" + }, + "query_patterns": { + "multi_row": "query('SELECT ...', [params])", + "single_row": "queryOne('SELECT ...', [params])", + "transaction": "transaction(async (client) => { ... })" + }, + "sql_injection_prevention": "Parameterized Query 사용 (pg 라이브러리)" + }, + "external_integration": { + "supported_databases": ["PostgreSQL", "MySQL", "MSSQL", "Oracle"], + "connector_pattern": "Factory Pattern (DatabaseConnectorFactory)", + "rest_api": "axios 기반 프록시" + }, + "batch_scheduler": { + "library": "node-cron", + "timezone": "Asia/Seoul", + "cron_examples": { + "매일 새벽 2시": "0 2 * * *", + "5분마다": "*/5 * * * *", + "평일 오전 8시": "0 8 * * 1-5" + }, + "execution_flow": [ + "1. 소스 DB에서 데이터 조회", + "2. 컬럼 매핑 적용", + "3. 타겟 DB에 INSERT/UPDATE", + "4. 실행 로그 기록" + ] + }, + "file_handling": { + "upload_path": "uploads/{company_code}/{timestamp}-{uuid}-{filename}", + "max_file_size": "10MB", + "allowed_types": ["이미지", "PDF", "Office 문서"], + "library": "multer" + }, + "security": { + "password_encryption": "bcrypt (12 rounds)", + "sensitive_data_encryption": "AES-256-CBC (외부 DB 비밀번호)", + "jwt_secret": "환경변수 관리", + "security_headers": ["Helmet (CSP, X-Frame-Options)", "CORS (credentials: true)", "Rate Limiting (1분 10000회)"], + "sql_injection_prevention": "Parameterized Query" + }, + "error_handling": { + "postgres_error_codes": { + "23505": "중복된 데이터", + "23503": "참조 무결성 위반", + "23502": "필수 입력값 누락" + }, + "process_level": { + "unhandledRejection": "로깅 (서버 유지)", + "uncaughtException": "로깅 (서버 유지, 주의)", + "SIGTERM/SIGINT": "Graceful Shutdown" + } + }, + "logging": { + "library": "Winston", + "log_files": { + "error.log": "에러만 (10MB × 5파일)", + "combined.log": "전체 로그 (10MB × 10파일)" + }, + "log_levels": "error (0) → warn (1) → info (2) → debug (5)" + }, + "performance_optimization": { + "pool_monitoring": "5분마다 상태 체크, 대기 연결 5개 이상 시 경고", + "slow_query_detection": "1초 이상 걸린 쿼리 자동 경고", + "caching": "Redis (메뉴: 10분 TTL, 공통코드: 30분 TTL)", + "compression": "Gzip (1KB 이상 응답, 레벨 6)" + } + }, + "critical_rules": [ + "✅ 모든 쿼리에 company_code 필터 추가", + "✅ JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)", + "✅ Parameterized Query 사용 (SQL Injection 방지)", + "✅ 슈퍼관리자 데이터 숨김 (company_code != '*')", + "✅ 비밀번호는 bcrypt, 민감정보는 AES-256", + "✅ 에러 핸들링 try/catch 필수", + "✅ 트랜잭션이 필요한 경우 transaction() 사용", + "✅ 파일 업로드는 회사별 디렉토리 분리" + ] + }, + "needs_from_others": [], + "questions": [] +} diff --git a/docs/backend-api-route-mapping.md b/docs/backend-api-route-mapping.md new file mode 100644 index 00000000..972f64b1 --- /dev/null +++ b/docs/backend-api-route-mapping.md @@ -0,0 +1,542 @@ +# WACE ERP Backend - API 라우트 완전 매핑 + +> **작성일**: 2026-02-06 +> **목적**: 프론트엔드 개발자용 API 엔드포인트 전체 목록 + +--- + +## 📌 공통 규칙 + +### Base URL +``` +개발: http://localhost:8080 +운영: http://39.117.244.52:8080 +``` + +### 헤더 +```http +Content-Type: application/json +Authorization: Bearer {JWT_TOKEN} +``` + +### 응답 형식 +```json +{ + "success": true, + "message": "성공 메시지", + "data": { ... } +} + +// 에러 시 +{ + "success": false, + "error": { + "code": "ERROR_CODE", + "details": "에러 상세" + } +} +``` + +--- + +## 1. 인증 API (`/api/auth`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/auth/login` | 공개 | 로그인 | `{ userId, password }` | `{ token, userInfo, firstMenuPath }` | +| POST | `/auth/logout` | 인증 | 로그아웃 | - | `{ success: true }` | +| GET | `/auth/me` | 인증 | 현재 사용자 정보 | - | `{ userInfo }` | +| GET | `/auth/status` | 공개 | 인증 상태 확인 | - | `{ isLoggedIn, isAdmin }` | +| POST | `/auth/refresh` | 인증 | 토큰 갱신 | - | `{ token }` | +| POST | `/auth/signup` | 공개 | 회원가입 (공차중계) | `{ userId, password, userName, phoneNumber, licenseNumber, vehicleNumber }` | `{ success: true }` | +| POST | `/auth/switch-company` | 슈퍼관리자 | 회사 전환 | `{ companyCode }` | `{ token, companyCode }` | + +--- + +## 2. 관리자 API (`/api/admin`) + +### 2.1 사용자 관리 + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/admin/users` | 관리자 | 사용자 목록 | `page, limit, search` | `{ users[], total }` | +| POST | `/admin/users` | 관리자 | 사용자 생성 | - | `{ user }` | +| PUT | `/admin/users/:userId` | 관리자 | 사용자 수정 | - | `{ user }` | +| DELETE | `/admin/users/:userId` | 관리자 | 사용자 삭제 | - | `{ success: true }` | +| GET | `/admin/users/:userId/history` | 관리자 | 사용자 이력 | - | `{ history[] }` | + +### 2.2 메뉴 관리 + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/admin/menus` | 인증 | 메뉴 목록 (트리) | `userId, userLang` | `{ menus[] }` | +| POST | `/admin/menus` | 관리자 | 메뉴 생성 | - | `{ menu }` | +| PUT | `/admin/menus/:menuId` | 관리자 | 메뉴 수정 | - | `{ menu }` | +| DELETE | `/admin/menus/:menuId` | 관리자 | 메뉴 삭제 | - | `{ success: true }` | + +### 2.3 표준 관리 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/admin/web-types` | 인증 | 웹타입 표준 목록 | `{ webTypes[] }` | +| GET | `/admin/button-actions` | 인증 | 버튼 액션 표준 | `{ buttonActions[] }` | +| GET | `/admin/component-standards` | 인증 | 컴포넌트 표준 | `{ components[] }` | +| GET | `/admin/template-standards` | 인증 | 템플릿 표준 | `{ templates[] }` | +| GET | `/admin/reports` | 인증 | 리포트 목록 | `{ reports[] }` | + +--- + +## 3. 테이블 관리 API (`/api/table-management`) + +### 3.1 테이블 메타데이터 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/table-management/tables` | 인증 | 테이블 목록 | `{ tables[] }` | +| GET | `/table-management/tables/:table/columns` | 인증 | 컬럼 목록 | `{ columns[] }` | +| GET | `/table-management/tables/:table/schema` | 인증 | 테이블 스키마 | `{ schema }` | +| GET | `/table-management/tables/:table/exists` | 인증 | 테이블 존재 여부 | `{ exists: boolean }` | +| GET | `/table-management/tables/:table/web-types` | 인증 | 웹타입 정보 | `{ webTypes }` | + +### 3.2 컬럼 설정 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | +|--------|------|------|------|--------------| +| POST | `/table-management/tables/:table/columns/:column/settings` | 인증 | 컬럼 설정 업데이트 | `{ web_type, input_type, ... }` | +| POST | `/table-management/tables/:table/columns/settings` | 인증 | 전체 컬럼 일괄 업데이트 | `{ columns[] }` | +| PUT | `/table-management/tables/:table/label` | 인증 | 테이블 라벨 설정 | `{ label }` | + +### 3.3 데이터 CRUD + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/table-management/tables/:table/data` | 인증 | 데이터 조회 (페이징) | `{ page, limit, filters, sort }` | `{ data[], total }` | +| POST | `/table-management/tables/:table/record` | 인증 | 단일 레코드 조회 | `{ conditions }` | `{ record }` | +| POST | `/table-management/tables/:table/add` | 인증 | 데이터 추가 | `{ data }` | `{ success: true, id }` | +| PUT | `/table-management/tables/:table/edit` | 인증 | 데이터 수정 | `{ conditions, data }` | `{ success: true }` | +| DELETE | `/table-management/tables/:table/delete` | 인증 | 데이터 삭제 | `{ conditions }` | `{ success: true }` | + +### 3.4 다중 테이블 저장 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | +|--------|------|------|------|--------------| +| POST | `/table-management/multi-table-save` | 인증 | 메인+서브 테이블 저장 | `{ mainTable, mainData, subTables: [{ table, data[] }] }` | + +### 3.5 로그 시스템 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | +|--------|------|------|------|--------------| +| POST | `/table-management/tables/:table/log` | 관리자 | 로그 테이블 생성 | - | +| GET | `/table-management/tables/:table/log/config` | 인증 | 로그 설정 조회 | - | +| GET | `/table-management/tables/:table/log` | 인증 | 로그 데이터 조회 | - | +| POST | `/table-management/tables/:table/log/toggle` | 관리자 | 로그 활성화/비활성화 | `{ is_active }` | + +### 3.6 엔티티 관계 + +| 메서드 | 경로 | 권한 | 기능 | Query Params | +|--------|------|------|------|--------------| +| GET | `/table-management/tables/entity-relations` | 인증 | 두 테이블 간 관계 조회 | `leftTable, rightTable` | +| GET | `/table-management/columns/:table/referenced-by` | 인증 | 현재 테이블 참조 목록 | - | + +### 3.7 카테고리 관리 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/table-management/category-columns` | 인증 | 회사별 카테고리 컬럼 | `{ categoryColumns[] }` | +| GET | `/table-management/menu/:menuObjid/category-columns` | 인증 | 메뉴별 카테고리 컬럼 | `{ categoryColumns[] }` | + +--- + +## 4. 화면 관리 API (`/api/screen-management`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/screen-management/screens` | 인증 | 화면 목록 | `page, limit` | `{ screens[], total }` | +| GET | `/screen-management/screens/:id` | 인증 | 화면 상세 | - | `{ screen }` | +| POST | `/screen-management/screens` | 관리자 | 화면 생성 | - | `{ screen }` | +| PUT | `/screen-management/screens/:id` | 관리자 | 화면 수정 | - | `{ screen }` | +| DELETE | `/screen-management/screens/:id` | 관리자 | 화면 삭제 | - | `{ success: true }` | + +### 화면 그룹 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/screen-groups` | 인증 | 화면 그룹 목록 | `{ screenGroups[] }` | +| POST | `/screen-groups` | 관리자 | 그룹 생성 | `{ group }` | + +### 화면 파일 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/screen-files` | 인증 | 화면 파일 목록 | `{ files[] }` | +| POST | `/screen-files` | 관리자 | 파일 업로드 | `{ file }` | + +--- + +## 5. 플로우 API (`/api/flow`) + +### 5.1 플로우 정의 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/flow/definitions` | 인증 | 플로우 목록 | - | `{ flows[] }` | +| GET | `/flow/definitions/:id` | 인증 | 플로우 상세 | - | `{ flow }` | +| POST | `/flow/definitions` | 인증 | 플로우 생성 | `{ name, description, targetTable }` | `{ flow }` | +| PUT | `/flow/definitions/:id` | 인증 | 플로우 수정 | `{ name, description }` | `{ flow }` | +| DELETE | `/flow/definitions/:id` | 인증 | 플로우 삭제 | - | `{ success: true }` | + +### 5.2 단계 관리 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/flow/definitions/:flowId/steps` | 인증 | 단계 목록 | - | `{ steps[] }` | +| POST | `/flow/definitions/:flowId/steps` | 인증 | 단계 생성 | `{ name, type, settings }` | `{ step }` | +| PUT | `/flow/steps/:stepId` | 인증 | 단계 수정 | `{ name, settings }` | `{ step }` | +| DELETE | `/flow/steps/:stepId` | 인증 | 단계 삭제 | - | `{ success: true }` | + +### 5.3 연결 관리 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/flow/connections/:flowId` | 인증 | 연결 목록 | - | `{ connections[] }` | +| POST | `/flow/connections` | 인증 | 연결 생성 | `{ fromStepId, toStepId, condition }` | `{ connection }` | +| DELETE | `/flow/connections/:connectionId` | 인증 | 연결 삭제 | - | `{ success: true }` | + +### 5.4 데이터 이동 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/flow/move` | 인증 | 데이터 이동 (단건) | `{ flowId, fromStepId, toStepId, recordId }` | `{ success: true }` | +| POST | `/flow/move-batch` | 인증 | 데이터 이동 (다건) | `{ flowId, fromStepId, toStepId, recordIds[] }` | `{ success: true, movedCount }` | + +### 5.5 단계 데이터 조회 + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/flow/:flowId/step/:stepId/count` | 인증 | 단계 데이터 개수 | - | `{ count }` | +| GET | `/flow/:flowId/step/:stepId/list` | 인증 | 단계 데이터 목록 | `page, limit` | `{ data[], total }` | +| GET | `/flow/:flowId/step/:stepId/column-labels` | 인증 | 컬럼 라벨 조회 | - | `{ labels }` | +| GET | `/flow/:flowId/steps/counts` | 인증 | 모든 단계 카운트 | - | `{ counts[] }` | + +### 5.6 단계 데이터 수정 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| PUT | `/flow/:flowId/step/:stepId/data/:recordId` | 인증 | 인라인 편집 | `{ data }` | `{ success: true }` | + +### 5.7 오딧 로그 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/flow/audit/:flowId/:recordId` | 인증 | 레코드별 오딧 로그 | `{ auditLogs[] }` | +| GET | `/flow/audit/:flowId` | 인증 | 플로우 전체 오딧 로그 | `{ auditLogs[] }` | + +--- + +## 6. 데이터플로우 API (`/api/dataflow`) + +### 6.1 관계 관리 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/dataflow/relationships` | 인증 | 관계 목록 | - | `{ relationships[] }` | +| POST | `/dataflow/relationships` | 인증 | 관계 생성 | `{ fromTable, toTable, fromColumn, toColumn, type }` | `{ relationship }` | +| PUT | `/dataflow/relationships/:id` | 인증 | 관계 수정 | `{ name, type }` | `{ relationship }` | +| DELETE | `/dataflow/relationships/:id` | 인증 | 관계 삭제 | - | `{ success: true }` | + +### 6.2 다이어그램 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/dataflow-diagrams` | 인증 | 다이어그램 목록 | - | `{ diagrams[] }` | +| GET | `/dataflow-diagrams/:id` | 인증 | 다이어그램 상세 | - | `{ diagram }` | +| POST | `/dataflow-diagrams` | 인증 | 다이어그램 생성 | `{ name, description }` | `{ diagram }` | + +### 6.3 실행 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/dataflow` | 인증 | 데이터플로우 실행 | `{ relationshipId, params }` | `{ result[] }` | + +--- + +## 7. 외부 연동 API + +### 7.1 외부 DB 연결 (`/api/external-db-connections`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/external-db-connections` | 인증 | 연결 목록 | - | `{ connections[] }` | +| GET | `/external-db-connections/:id` | 인증 | 연결 상세 | - | `{ connection }` | +| POST | `/external-db-connections` | 관리자 | 연결 생성 | `{ connectionName, dbType, host, port, database, username, password }` | `{ connection }` | +| PUT | `/external-db-connections/:id` | 관리자 | 연결 수정 | `{ connectionName, ... }` | `{ connection }` | +| DELETE | `/external-db-connections/:id` | 관리자 | 연결 삭제 | - | `{ success: true }` | +| POST | `/external-db-connections/:id/test` | 인증 | 연결 테스트 | - | `{ success: boolean, message }` | +| GET | `/external-db-connections/:id/tables` | 인증 | 테이블 목록 조회 | - | `{ tables[] }` | +| GET | `/external-db-connections/:id/tables/:table/columns` | 인증 | 컬럼 목록 조회 | - | `{ columns[] }` | + +### 7.2 외부 REST API (`/api/external-rest-api-connections`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/external-rest-api-connections` | 인증 | API 연결 목록 | - | `{ connections[] }` | +| POST | `/external-rest-api-connections` | 관리자 | API 연결 생성 | `{ name, baseUrl, authType, ... }` | `{ connection }` | +| POST | `/external-rest-api-connections/:id/test` | 인증 | API 테스트 | `{ endpoint, method }` | `{ response }` | + +### 7.3 멀티 커넥션 (`/api/multi-connection`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/multi-connection/query` | 인증 | 멀티 DB 쿼리 | `{ connections: [{ connectionId, sql }] }` | `{ results[] }` | + +--- + +## 8. 배치 API + +### 8.1 배치 설정 (`/api/batch-configs`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/batch-configs` | 인증 | 배치 설정 목록 | - | `{ batchConfigs[] }` | +| GET | `/batch-configs/:id` | 인증 | 배치 설정 상세 | - | `{ batchConfig }` | +| POST | `/batch-configs` | 관리자 | 배치 설정 생성 | `{ batchName, cronSchedule, sourceConnection, targetTable, mappings }` | `{ batchConfig }` | +| PUT | `/batch-configs/:id` | 관리자 | 배치 설정 수정 | `{ batchName, ... }` | `{ batchConfig }` | +| DELETE | `/batch-configs/:id` | 관리자 | 배치 설정 삭제 | - | `{ success: true }` | +| GET | `/batch-configs/connections` | 관리자 | 사용 가능한 커넥션 목록 | - | `{ connections[] }` | +| GET | `/batch-configs/connections/:type/tables` | 관리자 | 테이블 목록 조회 | - | `{ tables[] }` | + +### 8.2 배치 실행 (`/api/batch-management`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/batch-management/:id/execute` | 관리자 | 배치 즉시 실행 | - | `{ success: true, executionLogId }` | + +### 8.3 실행 이력 (`/api/batch-execution-logs`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/batch-execution-logs` | 인증 | 실행 이력 목록 | `batchConfigId, page, limit` | `{ logs[], total }` | +| GET | `/batch-execution-logs/:id` | 인증 | 실행 이력 상세 | - | `{ log }` | + +--- + +## 9. 메일 API (`/api/mail`) + +### 9.1 계정 관리 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/mail/accounts` | 인증 | 계정 목록 | - | `{ accounts[] }` | +| POST | `/mail/accounts` | 관리자 | 계정 추가 | `{ email, smtpHost, smtpPort, password }` | `{ account }` | + +### 9.2 템플릿 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/mail/templates-file` | 인증 | 템플릿 목록 | - | `{ templates[] }` | +| POST | `/mail/templates-file` | 관리자 | 템플릿 생성 | `{ name, subject, body }` | `{ template }` | + +### 9.3 발송/수신 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/mail/send` | 인증 | 메일 발송 | `{ accountId, to, subject, body, attachments[] }` | `{ success: true, messageId }` | +| GET | `/mail/sent` | 인증 | 발송 이력 | `page, limit` | `{ mails[], total }` | +| POST | `/mail/receive` | 인증 | 메일 수신 | `{ accountId }` | `{ mails[] }` | + +--- + +## 10. 파일 API (`/api/files`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/files/upload` | 인증 | 파일 업로드 (multipart) | `FormData { file }` | `{ fileId, fileName, filePath, fileSize }` | +| GET | `/files` | 인증 | 파일 목록 | `page, limit` | `{ files[], total }` | +| GET | `/files/:id` | 인증 | 파일 정보 조회 | - | `{ file }` | +| GET | `/files/download/:id` | 인증 | 파일 다운로드 | - | `(파일 스트림)` | +| DELETE | `/files/:id` | 인증 | 파일 삭제 | - | `{ success: true }` | +| GET | `/uploads/:filename` | 공개 | 정적 파일 서빙 | - | `(파일 스트림)` | + +--- + +## 11. 대시보드 API (`/api/dashboards`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/dashboards` | 인증 | 대시보드 목록 | - | `{ dashboards[] }` | +| GET | `/dashboards/:id` | 인증 | 대시보드 상세 | - | `{ dashboard }` | +| POST | `/dashboards` | 관리자 | 대시보드 생성 | - | `{ dashboard }` | +| GET | `/dashboards/:id/widgets` | 인증 | 위젯 데이터 조회 | - | `{ widgets[] }` | + +--- + +## 12. 공통코드 API (`/api/common-codes`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/common-codes` | 인증 | 공통코드 목록 | `codeGroup` | `{ codes[] }` | +| GET | `/common-codes/:codeGroup/:code` | 인증 | 공통코드 상세 | - | `{ code }` | +| POST | `/common-codes` | 관리자 | 공통코드 생성 | `{ codeGroup, code, name }` | `{ code }` | + +--- + +## 13. 다국어 API (`/api/multilang`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/multilang` | 인증 | 다국어 키 목록 | `lang` | `{ translations{} }` | +| GET | `/multilang/:key` | 인증 | 특정 키 조회 | `lang` | `{ key, value }` | +| POST | `/multilang` | 관리자 | 다국어 추가 | `{ key, ko, en, cn }` | `{ translation }` | + +--- + +## 14. 회사 관리 API (`/api/company-management`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/company-management` | 슈퍼관리자 | 회사 목록 | - | `{ companies[] }` | +| POST | `/company-management` | 슈퍼관리자 | 회사 생성 | `{ companyCode, companyName }` | `{ company }` | +| PUT | `/company-management/:code` | 슈퍼관리자 | 회사 수정 | `{ companyName }` | `{ company }` | +| DELETE | `/company-management/:code` | 슈퍼관리자 | 회사 삭제 | - | `{ success: true }` | + +--- + +## 15. 부서 API (`/api/departments`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/departments` | 인증 | 부서 목록 (트리) | - | `{ departments[] }` | +| POST | `/departments` | 관리자 | 부서 생성 | `{ deptCode, deptName, parentDeptCode }` | `{ department }` | + +--- + +## 16. 권한 그룹 API (`/api/roles`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/roles` | 인증 | 권한 그룹 목록 | - | `{ roles[] }` | +| POST | `/roles` | 관리자 | 권한 그룹 생성 | `{ roleName, permissions[] }` | `{ role }` | + +--- + +## 17. DDL 실행 API (`/api/ddl`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/ddl` | 슈퍼관리자 | DDL 실행 | `{ sql }` | `{ success: true, result }` | + +--- + +## 18. 외부 API 프록시 (`/api/open-api`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/open-api/weather` | 인증 | 날씨 정보 조회 | `location` | `{ weather }` | +| GET | `/open-api/exchange` | 인증 | 환율 정보 조회 | `fromCurrency, toCurrency` | `{ rate }` | + +--- + +## 19. 디지털 트윈 API (`/api/digital-twin`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/digital-twin/layouts` | 인증 | 레이아웃 목록 | - | `{ layouts[] }` | +| GET | `/digital-twin/templates` | 인증 | 템플릿 목록 | - | `{ templates[] }` | +| GET | `/digital-twin/data` | 인증 | 실시간 데이터 | - | `{ data[] }` | + +--- + +## 20. 3D 필드 API (`/api/yard-layouts`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/yard-layouts` | 인증 | 필드 레이아웃 목록 | - | `{ yardLayouts[] }` | +| POST | `/yard-layouts` | 인증 | 레이아웃 저장 | `{ layout }` | `{ success: true }` | + +--- + +## 21. 스케줄 API (`/api/schedule`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/schedule` | 인증 | 스케줄 자동 생성 | `{ params }` | `{ schedule }` | + +--- + +## 22. 채번 규칙 API (`/api/numbering-rules`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/numbering-rules` | 인증 | 채번 규칙 목록 | - | `{ rules[] }` | +| POST | `/numbering-rules` | 관리자 | 규칙 생성 | `{ ruleName, prefix, format }` | `{ rule }` | +| POST | `/numbering-rules/:id/generate` | 인증 | 번호 생성 | - | `{ number }` | + +--- + +## 23. 엔티티 검색 API (`/api/entity-search`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/entity-search` | 인증 | 엔티티 검색 | `{ table, filters, page, limit }` | `{ results[], total }` | +| GET | `/entity/:table/options` | 인증 | V2Select용 옵션 | `search, limit` | `{ options[] }` | + +--- + +## 24. To-Do API (`/api/todos`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/todos` | 인증 | To-Do 목록 | `status, assignee` | `{ todos[] }` | +| POST | `/todos` | 인증 | To-Do 생성 | `{ title, description, dueDate }` | `{ todo }` | +| PUT | `/todos/:id` | 인증 | To-Do 수정 | `{ status }` | `{ todo }` | + +--- + +## 25. 예약 요청 API (`/api/bookings`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/bookings` | 인증 | 예약 목록 | - | `{ bookings[] }` | +| POST | `/bookings` | 인증 | 예약 생성 | `{ resourceId, startTime, endTime }` | `{ booking }` | + +--- + +## 26. 리스크/알림 API (`/api/risk-alerts`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/risk-alerts` | 인증 | 리스크/알림 목록 | `priority, status` | `{ alerts[] }` | +| POST | `/risk-alerts` | 인증 | 알림 생성 | `{ title, content, priority }` | `{ alert }` | + +--- + +## 27. 헬스 체크 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/health` | 공개 | 서버 상태 확인 | `{ status: "OK", timestamp, uptime, environment }` | + +--- + +## 🔐 에러 코드 목록 + +| 코드 | HTTP Status | 설명 | +|------|-------------|------| +| `TOKEN_MISSING` | 401 | 인증 토큰 누락 | +| `TOKEN_EXPIRED` | 401 | 토큰 만료 | +| `INVALID_TOKEN` | 401 | 유효하지 않은 토큰 | +| `AUTHENTICATION_REQUIRED` | 401 | 인증 필요 | +| `INSUFFICIENT_PERMISSION` | 403 | 권한 부족 | +| `SUPER_ADMIN_REQUIRED` | 403 | 슈퍼관리자 권한 필요 | +| `COMPANY_ACCESS_DENIED` | 403 | 회사 데이터 접근 거부 | +| `INVALID_INPUT` | 400 | 잘못된 입력 | +| `RESOURCE_NOT_FOUND` | 404 | 리소스 없음 | +| `DUPLICATE_ENTRY` | 400 | 중복 데이터 | +| `FOREIGN_KEY_VIOLATION` | 400 | 참조 무결성 위반 | +| `SERVER_ERROR` | 500 | 서버 오류 | + +--- + +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-02-06 +**총 API 개수**: 200+개 diff --git a/docs/backend-architecture-analysis.md b/docs/backend-architecture-analysis.md new file mode 100644 index 00000000..c5c2b549 --- /dev/null +++ b/docs/backend-architecture-analysis.md @@ -0,0 +1,1424 @@ +# WACE ERP 백엔드 아키텍처 상세 분석 + +> **작성일**: 2026-02-06 +> **분석 대상**: ERP-node/backend-node +> **Stack**: Node.js + Express + TypeScript + PostgreSQL Raw Query + +--- + +## 📋 목차 + +1. [전체 디렉토리 구조](#1-전체-디렉토리-구조) +2. [API 라우트 목록 및 역할](#2-api-라우트-목록-및-역할) +3. [인증/인가 워크플로우](#3-인증인가-워크플로우) +4. [비즈니스 도메인별 모듈 분류](#4-비즈니스-도메인별-모듈-분류) +5. [미들웨어 스택 구성](#5-미들웨어-스택-구성) +6. [서비스 레이어 패턴](#6-서비스-레이어-패턴) +7. [멀티테넌시 구현 방식](#7-멀티테넌시-구현-방식) +8. [에러 핸들링 전략](#8-에러-핸들링-전략) +9. [파일 업로드/다운로드 처리](#9-파일-업로드다운로드-처리) +10. [외부 연동](#10-외부-연동) +11. [배치/스케줄 처리](#11-배치스케줄-처리) +12. [컨트롤러/서비스 상세 역할](#12-컨트롤러서비스-상세-역할) + +--- + +## 1. 전체 디렉토리 구조 + +``` +backend-node/ +├── src/ +│ ├── app.ts # Express 앱 진입점, 라우트 등록, 미들웨어 설정 +│ ├── config/ +│ │ └── environment.ts # 환경변수 관리 (PORT, DB, JWT, CORS 등) +│ ├── controllers/ # 69개 컨트롤러 (요청 처리 및 응답) +│ ├── services/ # 87개 서비스 (비즈니스 로직) +│ ├── routes/ # 77개 라우터 (엔드포인트 정의) +│ ├── middleware/ # 4개 미들웨어 (인증, 권한, 에러 핸들링) +│ ├── database/ # DB 연결 풀, 커넥터, 마이그레이션 +│ ├── utils/ # 16개 유틸리티 (JWT, 암호화, 로거 등) +│ ├── types/ # 26개 TypeScript 타입 정의 +│ ├── interfaces/ # 인터페이스 정의 +│ └── tests/ # 테스트 파일 +├── scripts/ # 배치 및 유틸리티 스크립트 +├── data/ # JSON 기반 설정 데이터 +├── uploads/ # 파일 업로드 디렉토리 +└── package.json # 의존성 관리 +``` + +### 주요 특징 +- **Layered Architecture**: Controller → Service → Database 3계층 구조 +- **TypeScript Strict Mode**: 타입 안전성 보장 +- **Raw Query 기반**: Prisma → PostgreSQL Raw Query 전환 완료 +- **Connection Pool**: pg 라이브러리 기반 연결 풀 관리 +- **마이크로서비스 지향**: 도메인별 명확한 분리 + +--- + +## 2. API 라우트 목록 및 역할 + +### 2.1 인증 및 관리자 (Auth & Admin) + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/auth/login` | POST | 로그인 (JWT 토큰 발급) | ❌ | +| `/api/auth/signup` | POST | 회원가입 (공차중계) | ❌ | +| `/api/auth/me` | GET | 현재 사용자 정보 조회 | ✅ | +| `/api/auth/logout` | POST | 로그아웃 | ✅ | +| `/api/auth/refresh` | POST | JWT 토큰 갱신 | ✅ | +| `/api/auth/switch-company` | POST | 관리자 전용: 회사 전환 | ✅ | +| `/api/admin/menus` | GET | 메뉴 목록 조회 | ✅ | +| `/api/admin/users` | GET/POST/PUT | 사용자 관리 (CRUD) | ✅ | +| `/api/admin/companies` | GET/POST/PUT/DELETE | 회사 관리 (CRUD) | ✅ | +| `/api/admin/departments` | GET | 부서 목록 조회 | ✅ | + +### 2.2 테이블 및 데이터 관리 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/table-management/tables` | GET | 테이블 목록 조회 | ✅ | +| `/api/table-management/columns` | GET | 컬럼 정보 조회 | ✅ | +| `/api/table-management/entity-joins` | GET/POST | 테이블 조인 설정 | ✅ | +| `/api/data/*` | GET/POST/PUT/DELETE | 동적 테이블 데이터 CRUD | ✅ | +| `/api/ddl/*` | POST | DDL 실행 (테이블 생성/수정/삭제) | ✅ (Super Admin) | + +### 2.3 화면 및 폼 관리 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/screen-management/*` | GET/POST/PUT/DELETE | 화면 메타데이터 관리 | ✅ | +| `/api/screen-groups/*` | GET/POST/PUT/DELETE | 화면 그룹 관리 | ✅ | +| `/api/dynamic-form/*` | GET/POST | 동적 폼 생성 및 렌더링 | ✅ | +| `/api/admin/web-types` | GET/POST | 웹 컴포넌트 타입 표준 관리 | ✅ | +| `/api/admin/button-actions` | GET/POST | 버튼 액션 표준 관리 | ✅ | +| `/api/admin/template-standards` | GET/POST | 템플릿 표준 관리 | ✅ | +| `/api/admin/component-standards` | GET/POST | 컴포넌트 표준 관리 | ✅ | + +### 2.4 플로우 및 데이터플로우 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/flow/definitions` | GET/POST/PUT/DELETE | 플로우 정의 관리 | ✅ | +| `/api/flow/definitions/:id/steps` | GET/POST | 플로우 단계 관리 | ✅ | +| `/api/flow/connections` | GET/POST/DELETE | 플로우 연결 관리 | ✅ | +| `/api/flow/move` | POST | 데이터 이동 실행 | ✅ | +| `/api/flow/audit/:flowId` | GET | 플로우 오딧 로그 조회 | ✅ | +| `/api/dataflow/*` | GET/POST/PUT/DELETE | 데이터플로우 관계 관리 | ✅ | +| `/api/dataflow-diagrams/*` | GET/POST/PUT/DELETE | 데이터플로우 다이어그램 | ✅ | +| `/api/dataflow/execute` | POST | 데이터플로우 실행 | ✅ | + +### 2.5 배치 관리 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/batch-configs` | GET/POST/PUT/DELETE | 배치 설정 관리 | ✅ | +| `/api/batch-configs/connections` | GET | 사용 가능한 커넥션 목록 | ✅ | +| `/api/batch-configs/:id/execute` | POST | 배치 수동 실행 | ✅ | +| `/api/batch-management/*` | GET/POST | 배치 실행 관리 | ✅ | +| `/api/batch-execution-logs` | GET | 배치 실행 이력 조회 | ✅ | + +### 2.6 외부 연동 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/external-db-connections` | GET/POST/PUT/DELETE | 외부 DB 연결 관리 | ✅ | +| `/api/external-db-connections/:id/test` | POST | 외부 DB 연결 테스트 | ✅ | +| `/api/external-rest-api-connections` | GET/POST/PUT/DELETE | 외부 REST API 연결 | ✅ | +| `/api/external-calls/*` | GET/POST | 외부 API 호출 설정 | ✅ | +| `/api/multi-connection/query` | POST | 멀티 DB 통합 쿼리 | ✅ | + +### 2.7 메일 관리 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/mail/accounts` | GET/POST/PUT/DELETE | 메일 계정 관리 | ✅ | +| `/api/mail/templates-file` | GET/POST/PUT/DELETE | 메일 템플릿 관리 | ✅ | +| `/api/mail/send` | POST | 메일 발송 (단일/대량) | ✅ | +| `/api/mail/sent` | GET | 발송 이력 조회 | ✅ | +| `/api/mail/receive` | GET | 메일 수신함 조회 | ✅ | + +### 2.8 기타 도메인 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/dashboards/*` | GET/POST | 대시보드 관리 | ✅ | +| `/api/admin/reports/*` | GET/POST | 리포트 생성 및 조회 | ✅ | +| `/api/files/*` | POST | 파일 업로드/다운로드 | ✅ | +| `/api/delivery/*` | GET/POST | 배송/화물 관리 | ✅ | +| `/api/risk-alerts/*` | GET/POST | 리스크/알림 관리 | ✅ | +| `/api/todos/*` | GET/POST/PUT/DELETE | To-Do 관리 | ✅ | +| `/api/bookings/*` | GET/POST | 예약 관리 | ✅ | +| `/api/digital-twin/*` | GET/POST | 디지털 트윈 (야드 관제) | ✅ | +| `/api/schedule/*` | GET/POST | 스케줄 자동 생성 | ✅ | +| `/api/work-history/*` | GET | 작업 이력 조회 | ✅ | +| `/api/table-history/*` | GET | 테이블 변경 이력 조회 | ✅ | +| `/api/roles/*` | GET/POST | 권한 그룹 관리 | ✅ | +| `/api/numbering-rules/*` | GET/POST | 채번 규칙 관리 | ✅ | +| `/api/entity-search/*` | GET | 엔티티 검색 | ✅ | +| `/api/cascading-*` | GET/POST | 연쇄 드롭다운 관계 | ✅ | +| `/api/category-tree/*` | GET/POST | 카테고리 트리 | ✅ | +| `/api/vehicle/*` | GET/POST | 차량 운행 이력 | ✅ | +| `/api/tax-invoice/*` | GET/POST | 세금계산서 관리 | ✅ | + +**총 77개 라우터 파일, 200개 이상의 엔드포인트 제공** + +--- + +## 3. 인증/인가 워크플로우 + +### 3.1 인증 메커니즘 + +``` +로그인 요청 (userId, password) + ↓ +1. AuthController.login() + ↓ +2. AuthService.processLogin() + ├─ 비밀번호 검증 (BCrypt + 마스터 패스워드) + ├─ 사용자 정보 조회 (user_info 테이블) + ├─ 로그인 로그 기록 (LOGIN_ACCESS_LOG) + └─ JWT 토큰 생성 (JwtUtils.generateToken) + ↓ +3. JWT 토큰 응답 + ├─ accessToken (24시간 유효) + ├─ refreshToken (7일 유효) + └─ userInfo (userId, userName, companyCode, userType) +``` + +### 3.2 JWT 토큰 구조 + +```typescript +// JWT Payload +{ + userId: string; // 사용자 ID + userName: string; // 사용자 이름 + companyCode: string; // 회사 코드 (멀티테넌시 핵심) + userType: string; // 사용자 유형 (SUPER_ADMIN, COMPANY_ADMIN, USER) + userLang?: string; // 사용자 언어 + iat: number; // 발급 시간 + exp: number; // 만료 시간 +} +``` + +### 3.3 미들웨어 체인 + +``` +1. refreshTokenIfNeeded (자동 토큰 갱신) + ↓ +2. authenticateToken (JWT 검증 및 사용자 정보 설정) + ↓ +3. 권한 미들웨어 (선택적) + ├─ requireSuperAdmin (회사코드 '*' 필수) + ├─ requireAdmin (회사관리자 이상) + ├─ requireCompanyAccess (회사 데이터 접근 권한) + ├─ requireDDLPermission (DDL 실행 권한) + └─ requireUserManagement (사용자 관리 권한) + ↓ +4. Controller 실행 + ↓ +5. errorHandler (에러 발생 시) +``` + +### 3.4 권한 레벨 (3단계) + +| 레벨 | companyCode | userType | 권한 범위 | +|------|-------------|----------|----------| +| **Super Admin** | `*` | `SUPER_ADMIN` | 전체 시스템 접근, DDL 실행, 회사 생성/삭제 | +| **Company Admin** | 회사코드 | `COMPANY_ADMIN` | 자사 데이터 관리, 사용자 관리, 설정 변경 | +| **일반 사용자** | 회사코드 | `USER` | 자사 데이터 조회/수정 (권한 범위 내) | + +### 3.5 토큰 갱신 전략 + +- **자동 갱신**: 토큰이 1시간 이내 만료 시 응답 헤더(`X-New-Token`)에 새 토큰 포함 +- **명시적 갱신**: `/api/auth/refresh` 엔드포인트 호출 +- **만료 처리**: 만료된 토큰은 401 Unauthorized 응답 (`TOKEN_EXPIRED`) + +--- + +## 4. 비즈니스 도메인별 모듈 분류 + +### 4.1 관리자 영역 (Admin) + +**파일**: +- `adminController.ts`, `adminService.ts`, `adminRoutes.ts` + +**주요 기능**: +- 메뉴 관리 (CRUD, 복사, 상태 토글, 일괄 삭제) +- 사용자 관리 (등록, 수정, 상태 변경, 비밀번호 초기화) +- 회사 관리 (등록, 수정, 삭제, 조회) +- 부서 관리 (조회, 사용자-부서 통합 저장) +- 로케일 설정 (다국어 지원) +- 테이블 스키마 조회 (엑셀 매핑용) + +**특징**: +- 멀티테넌시 기반 회사별 데이터 격리 +- Super Admin만 회사 생성/삭제 가능 +- 사용자 변경 이력 추적 + +### 4.2 테이블 및 데이터 관리 (Table Management & Data) + +**파일**: +- `tableManagementController.ts`, `tableManagementService.ts` +- `dataController.ts`, `dataService.ts` +- `entityJoinController.ts`, `entityJoinService.ts` + +**주요 기능**: +- 테이블 목록 조회 (PostgreSQL information_schema 활용) +- 컬럼 정보 조회 (타입, 라벨, 제약조건, 참조 관계) +- 동적 테이블 데이터 CRUD (Raw Query 기반) +- 테이블 조인 설정 및 실행 (1:N, N:M 관계) +- 컬럼 라벨 및 설정 관리 (table_type_columns) + +**특징**: +- 캐시 기반 성능 최적화 (테이블/컬럼 정보) +- 멀티테넌시 자동 필터링 (`company_code` 조건) +- 코드 타입 컬럼 자동 처리 (공통 코드 연동) + +### 4.3 화면 관리 (Screen Management) + +**파일**: +- `screenManagementController.ts`, `screenManagementService.ts` +- `screenGroupController.ts`, `screenEmbeddingController.ts` + +**주요 기능**: +- 화면 메타데이터 관리 (테이블 연결, 컬럼 설정, 레이아웃) +- 화면 그룹 관리 (폴더 구조) +- 화면 임베딩 (부모-자식 화면 데이터 전달) +- 동적 폼 생성 (JSON 기반 폼 설정 → React 컴포넌트) + +**특징**: +- Low-Code 화면 구성 +- 웹 컴포넌트 타입 표준 기반 렌더링 +- 버튼 액션 표준 지원 (저장, 삭제, 조회, 커스텀) + +### 4.4 플로우 관리 (Flow Management) + +**파일**: +- `flowController.ts`, `flowService.ts` +- `flowExecutionService.ts`, `flowStepService.ts` +- `flowConnectionService.ts`, `flowDataMoveService.ts` + +**주요 기능**: +- 플로우 정의 관리 (작업 흐름 설계) +- 플로우 단계 관리 (스텝 생성, 수정, 삭제) +- 플로우 연결 관리 (스텝 간 조건부 연결) +- 데이터 이동 실행 (스텝 간 데이터 이동) +- 오딧 로그 조회 (변경 이력 추적) + +**특징**: +- 비주얼 워크플로우 엔진 +- 조건부 분기 지원 +- 배치 데이터 이동 지원 + +### 4.5 데이터플로우 (Dataflow) + +**파일**: +- `dataflowController.ts`, `dataflowService.ts` +- `dataflowDiagramController.ts`, `dataflowDiagramService.ts` +- `dataflowExecutionController.ts` + +**주요 기능**: +- 테이블 관계 정의 (1:1, 1:N, N:M) +- 데이터플로우 다이어그램 생성 (ERD 같은 시각화) +- 데이터플로우 실행 (자동 데이터 동기화) +- 관계 기반 데이터 조회 (조인 쿼리 자동 생성) + +**특징**: +- 그래프 기반 데이터 관계 모델링 +- 다이어그램별 관계 그룹화 + +### 4.6 배치 관리 (Batch Management) + +**파일**: +- `batchController.ts`, `batchService.ts` +- `batchSchedulerService.ts`, `batchExecutionLogService.ts` +- `batchExternalDbService.ts` + +**주요 기능**: +- 배치 설정 관리 (CRUD) +- Cron 기반 스케줄링 (node-cron) +- 배치 수동/자동 실행 +- 실행 이력 조회 (성공/실패 로그) +- 외부 DB 연동 배치 지원 + +**특징**: +- 실시간 스케줄러 업데이트 +- 다중 DB 간 데이터 동기화 +- 실행 시간 제한 및 오류 알림 + +### 4.7 외부 연동 (External Integration) + +**파일**: +- `externalDbConnectionController.ts`, `externalDbConnectionService.ts` +- `externalRestApiConnectionController.ts`, `externalRestApiConnectionService.ts` +- `externalCallController.ts`, `externalCallService.ts` +- `multiConnectionQueryService.ts` + +**주요 기능**: +- 외부 DB 연결 관리 (PostgreSQL, MySQL, MSSQL, Oracle, MariaDB) +- 외부 REST API 연결 관리 +- 멀티 DB 통합 쿼리 실행 +- 연결 테스트 및 상태 확인 +- 크레덴셜 암호화 저장 + +**특징**: +- 5종 DB 지원 (PostgreSQL, MySQL, MSSQL, Oracle, MariaDB) +- Connection Pool 기반 연결 관리 +- 비밀번호 암호화 (AES-256-CBC) + +### 4.8 메일 관리 (Mail Management) + +**파일**: +- `mailAccountFileController.ts`, `mailAccountFileService.ts` +- `mailTemplateFileController.ts`, `mailTemplateFileService.ts` +- `mailSendSimpleController.ts`, `mailSendSimpleService.ts` +- `mailSentHistoryController.ts`, `mailSentHistoryService.ts` +- `mailReceiveBasicController.ts`, `mailReceiveBasicService.ts` + +**주요 기능**: +- 메일 계정 관리 (SMTP/IMAP 설정) +- 메일 템플릿 관리 (JSON 기반 컴포넌트 조합) +- 메일 발송 (단일/대량, 첨부파일 지원) +- 발송 이력 조회 (30일 자동 삭제) +- 메일 수신함 조회 (IMAP) + +**특징**: +- Nodemailer 기반 발송 +- 템플릿 변수 치환 +- 대량 발송 지원 (100건/배치) +- 메일 예약 발송 + +### 4.9 대시보드 (Dashboard) + +**파일**: +- `DashboardController.ts`, `DashboardService.ts` + +**주요 기능**: +- 대시보드 위젯 관리 (차트, 테이블, 카드) +- 실시간 데이터 조회 (집계 쿼리) +- 사용자별 대시보드 설정 + +**특징**: +- JSON 기반 위젯 설정 +- 캐시 기반 성능 최적화 + +### 4.10 기타 도메인 + +**파일 및 주요 기능**: +- **리포트**: 리포트 생성 및 조회 (`reportController.ts`, `reportService.ts`) +- **파일**: 파일 업로드/다운로드 (`fileController.ts`, Multer) +- **배송/화물**: 배송 관리, 화물 추적 (`deliveryController.ts`) +- **리스크/알림**: 리스크 알림, 캐시 기반 자동 갱신 (`riskAlertController.ts`) +- **To-Do**: 할 일 관리 (`todoController.ts`) +- **예약**: 예약 요청 관리 (`bookingController.ts`) +- **디지털 트윈**: 야드 관제, 3D 레이아웃 (`digitalTwinController.ts`) +- **스케줄**: 스케줄 자동 생성 (`scheduleController.ts`) +- **작업 이력**: 작업 로그 조회 (`workHistoryController.ts`) +- **권한 그룹**: 권한 그룹 관리 (`roleController.ts`) +- **채번 규칙**: 자동 채번 규칙 (`numberingRuleController.ts`) +- **엔티티 검색**: 동적 엔티티 검색 (`entitySearchController.ts`) +- **연쇄 드롭다운**: 조건부 드롭다운 (`cascadingController.ts` 시리즈) + +--- + +## 5. 미들웨어 스택 구성 + +### 5.1 미들웨어 실행 순서 (app.ts 기준) + +``` +1. 프로세스 레벨 예외 처리 (unhandledRejection, uncaughtException) +2. 보안 헤더 (helmet) +3. 압축 (compression) +4. 바디 파싱 (express.json, express.urlencoded) +5. 정적 파일 서빙 (/uploads) +6. CORS (cors) +7. Rate Limiting (express-rate-limit) +8. 토큰 자동 갱신 (refreshTokenIfNeeded) +9. [라우트별 미들웨어] + ├─ authenticateToken (모든 /api/* 라우트) + ├─ 권한 미들웨어 (선택적) + └─ 컨트롤러 실행 +10. 404 핸들러 +11. 에러 핸들러 (errorHandler) +``` + +### 5.2 미들웨어 파일 + +#### `authMiddleware.ts` +- **authenticateToken**: JWT 토큰 검증 및 사용자 정보 설정 +- **optionalAuth**: 선택적 인증 (토큰 없어도 통과) +- **requireAdmin**: 관리자 권한 필수 (userId === 'plm_admin') +- **refreshTokenIfNeeded**: 토큰 자동 갱신 (1시간 이내 만료 시) +- **checkAuthStatus**: 인증 상태 확인 (유효성 검사만) + +#### `permissionMiddleware.ts` +- **requireSuperAdmin**: 슈퍼관리자 권한 필수 (companyCode === '*') +- **requireAdmin**: 관리자 이상 권한 필수 (Super Admin + Company Admin) +- **requireCompanyAccess**: 회사 데이터 접근 권한 체크 +- **requireUserManagement**: 사용자 관리 권한 체크 +- **requireCompanySettingsManagement**: 회사 설정 변경 권한 체크 +- **requireCompanyManagement**: 회사 생성/삭제 권한 체크 +- **requireDDLPermission**: DDL 실행 권한 체크 + +#### `superAdminMiddleware.ts` +- **requireSuperAdmin**: 슈퍼관리자 권한 확인 (DDL 전용) +- **validateDDLPermission**: DDL 실행 전 추가 보안 검증 (5초 간격 제한) +- **isSuperAdmin**: 슈퍼관리자 여부 확인 유틸 함수 +- **checkDDLPermission**: DDL 권한 체크 (미들웨어 없이 사용) + +#### `errorHandler.ts` +- **AppError**: 커스텀 에러 클래스 (statusCode, isOperational) +- **errorHandler**: 전역 에러 핸들러 (PostgreSQL, JWT 에러 처리) +- **notFoundHandler**: 404 에러 핸들러 + +### 5.3 보안 설정 + +```typescript +// helmet: 보안 헤더 설정 +helmet({ + contentSecurityPolicy: { + directives: { + 'frame-ancestors': ['self', 'http://localhost:9771', 'http://localhost:3000'] + } + } +}) + +// Rate Limiting: 1분당 10,000 요청 (개발), 100 요청 (운영) +rateLimit({ + windowMs: 1 * 60 * 1000, + max: process.env.NODE_ENV === 'development' ? 10000 : 100, + skip: (req) => { + // 헬스 체크, 자주 호출되는 API는 제외 + return req.path === '/health' + || req.path.includes('/table-management/') + || req.path.includes('/external-db-connections/') + } +}) + +// CORS: 환경별 origin 설정 +cors({ + origin: process.env.NODE_ENV === 'development' + ? true + : ['http://localhost:9771', 'http://39.117.244.52:5555'], + credentials: true +}) +``` + +--- + +## 6. 서비스 레이어 패턴 + +### 6.1 서비스 레이어 구조 + +``` +Controller (요청 처리) + ↓ +Service (비즈니스 로직) + ↓ +Database (Raw Query 실행) + ↓ +PostgreSQL (데이터 저장소) +``` + +### 6.2 데이터베이스 접근 방식 + +#### `db.ts` - Raw Query 매니저 + +```typescript +// 기본 쿼리 실행 +async function query(text: string, params?: any[]): Promise + +// 단일 행 조회 +async function queryOne(text: string, params?: any[]): Promise + +// 트랜잭션 +async function transaction(callback: (client: PoolClient) => Promise): Promise + +// 연결 풀 상태 +function getPoolStatus(): { totalCount, idleCount, waitingCount } +``` + +#### Connection Pool 설정 + +```typescript +new Pool({ + host: dbConfig.host, + port: dbConfig.port, + database: dbConfig.database, + user: dbConfig.user, + password: dbConfig.password, + + // 연결 풀 설정 + min: process.env.NODE_ENV === 'production' ? 5 : 2, + max: process.env.NODE_ENV === 'production' ? 20 : 10, + + // 타임아웃 설정 + connectionTimeoutMillis: 30000, // 30초 + idleTimeoutMillis: 600000, // 10분 + statement_timeout: 60000, // 60초 + query_timeout: 60000, + + application_name: 'WACE-PLM-Backend' +}) +``` + +### 6.3 서비스 패턴 예시 + +#### 멀티테넌시 쿼리 패턴 + +```typescript +// Super Admin: 모든 데이터 조회 +if (companyCode === '*') { + query = 'SELECT * FROM table_name ORDER BY company_code'; + params = []; +} +// 일반 사용자: 자사 데이터만 조회 (Super Admin 데이터 제외) +else { + query = ` + SELECT * FROM table_name + WHERE company_code = $1 AND company_code != '*' + ORDER BY created_at DESC + `; + params = [companyCode]; +} +``` + +#### 트랜잭션 패턴 + +```typescript +await transaction(async (client) => { + // 1. 부모 레코드 삽입 + const parent = await client.query( + 'INSERT INTO parent_table (...) VALUES (...) RETURNING *', + [...] + ); + + // 2. 자식 레코드 삽입 + await client.query( + 'INSERT INTO child_table (parent_id, ...) VALUES ($1, ...) RETURNING *', + [parent.rows[0].id, ...] + ); + + return { success: true }; +}); +``` + +#### 캐시 패턴 + +```typescript +// 캐시 조회 +const cachedData = cache.get(CacheKeys.TABLE_LIST); +if (cachedData) { + return cachedData; +} + +// DB 조회 +const data = await query('SELECT ...'); + +// 캐시 저장 (10분 TTL) +cache.set(CacheKeys.TABLE_LIST, data, 10 * 60 * 1000); + +return data; +``` + +### 6.4 외부 DB 커넥터 패턴 + +```typescript +// DatabaseConnectorFactory.ts +export class DatabaseConnectorFactory { + static createConnector(dbType: string, config: ConnectionConfig): DatabaseConnector { + switch (dbType) { + case 'postgresql': return new PostgreSQLConnector(config); + case 'mysql': return new MySQLConnector(config); + case 'mssql': return new MSSQLConnector(config); + case 'oracle': return new OracleConnector(config); + case 'mariadb': return new MariaDBConnector(config); + default: throw new Error(`Unsupported DB type: ${dbType}`); + } + } +} + +// 사용 예시 +const connector = DatabaseConnectorFactory.createConnector('mysql', config); +await connector.connect(); +const result = await connector.executeQuery('SELECT * FROM users'); +await connector.disconnect(); +``` + +--- + +## 7. 멀티테넌시 구현 방식 + +### 7.1 핵심 원칙 + +**CRITICAL PROJECT RULES**: +1. **모든 쿼리는 company_code 필터 필수** +2. **req.user!.companyCode 사용 (클라이언트 전송 값 신뢰 금지)** +3. **Super Admin (company_code = '*')만 전체 데이터 조회** +4. **일반 사용자는 company_code = '*' 데이터 조회 불가** + +### 7.2 쿼리 패턴 + +```typescript +const companyCode = req.user!.companyCode; + +// Super Admin: 모든 회사 데이터 조회 +if (companyCode === '*') { + query = 'SELECT * FROM users ORDER BY company_code'; + params = []; +} +// 일반 사용자: 자사 데이터만 조회 (Super Admin 제외) +else { + query = ` + SELECT * FROM users + WHERE company_code = $1 AND company_code != '*' + `; + params = [companyCode]; +} +``` + +### 7.3 테이블 설계 + +```sql +-- 모든 비즈니스 테이블에 company_code 컬럼 필수 +CREATE TABLE table_name ( + id SERIAL PRIMARY KEY, + company_code VARCHAR(50) NOT NULL, -- 회사 코드 + ... + INDEX idx_company_code (company_code) +); + +-- Super Admin 데이터: company_code = '*' +-- 회사별 데이터: company_code = '회사코드' +``` + +### 7.4 회사 전환 (Super Admin 전용) + +```typescript +// POST /api/auth/switch-company +{ + targetCompanyCode: "ILSHIN" // 전환할 회사 코드 +} + +// 응답 +{ + success: true, + token: "새로운 JWT 토큰", // companyCode가 변경된 토큰 + userInfo: { companyCode: "ILSHIN", ... } +} +``` + +### 7.5 권한 체크 + +```typescript +// 회사 데이터 접근 권한 확인 +export function canAccessCompanyData(user: PersonBean, targetCompanyCode: string): boolean { + // Super Admin: 모든 회사 접근 가능 + if (user.companyCode === '*') { + return true; + } + + // 일반 사용자: 자사만 접근 가능 + return user.companyCode === targetCompanyCode; +} +``` + +--- + +## 8. 에러 핸들링 전략 + +### 8.1 에러 핸들링 구조 + +``` +Controller (try-catch) + ↓ 에러 발생 +Service (throw error) + ↓ +errorHandler (미들웨어) + ├─ PostgreSQL 에러 처리 + ├─ JWT 에러 처리 + ├─ 커스텀 에러 처리 (AppError) + └─ 응답 전송 (JSON) +``` + +### 8.2 커스텀 에러 클래스 + +```typescript +export class AppError extends Error { + public statusCode: number; + public isOperational: boolean; + + constructor(message: string, statusCode: number = 500) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + } +} + +// 사용 예시 +throw new AppError('중복된 데이터가 존재합니다.', 400); +``` + +### 8.3 PostgreSQL 에러 처리 + +```typescript +// errorHandler.ts +if (pgError.code === '23505') { // unique_violation + error = new AppError('중복된 데이터가 존재합니다.', 400); +} else if (pgError.code === '23503') { // foreign_key_violation + error = new AppError('참조 무결성 제약 조건 위반입니다.', 400); +} else if (pgError.code === '23502') { // not_null_violation + error = new AppError('필수 입력값이 누락되었습니다.', 400); +} +``` + +### 8.4 에러 응답 형식 + +```json +{ + "success": false, + "error": { + "code": "UNIQUE_VIOLATION", + "message": "중복된 데이터가 존재합니다.", + "details": "사용자 ID가 이미 존재합니다.", + "stack": "..." // 개발 환경에서만 포함 + } +} +``` + +### 8.5 에러 로깅 + +```typescript +// logger.ts (Winston 기반) +logger.error({ + message: error.message, + stack: error.stack, + url: req.url, + method: req.method, + ip: req.ip, + userAgent: req.get('User-Agent') +}); +``` + +### 8.6 프로세스 레벨 예외 처리 + +```typescript +// app.ts +process.on('unhandledRejection', (reason, promise) => { + logger.error('⚠️ Unhandled Promise Rejection:', reason); + // 프로세스 종료하지 않고 로깅만 수행 +}); + +process.on('uncaughtException', (error) => { + logger.error('🔥 Uncaught Exception:', error); + // 심각한 에러 시 graceful shutdown 고려 +}); +``` + +--- + +## 9. 파일 업로드/다운로드 처리 + +### 9.1 파일 업로드 + +**파일**: `fileController.ts`, `fileRoutes.ts` + +```typescript +// Multer 설정 +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, 'uploads/'); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname)); + } +}); + +const upload = multer({ + storage, + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB + fileFilter: (req, file, cb) => { + // 허용된 확장자 체크 + const allowedTypes = /jpeg|jpg|png|gif|pdf|doc|docx|xls|xlsx/; + const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = allowedTypes.test(file.mimetype); + + if (mimetype && extname) { + return cb(null, true); + } else { + cb(new Error('허용되지 않는 파일 형식입니다.')); + } + } +}); + +// 라우트 +router.post('/upload', authenticateToken, upload.single('file'), uploadFile); +router.post('/upload-multiple', authenticateToken, upload.array('files', 10), uploadMultipleFiles); +``` + +### 9.2 파일 다운로드 + +```typescript +// 정적 파일 서빙 (app.ts) +app.use('/uploads', + (req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + next(); + }, + express.static(path.join(process.cwd(), 'uploads')) +); + +// 다운로드 엔드포인트 +router.get('/download/:filename', authenticateToken, async (req, res) => { + const filename = req.params.filename; + const filePath = path.join(process.cwd(), 'uploads', filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: '파일을 찾을 수 없습니다.' }); + } + + res.download(filePath); +}); +``` + +### 9.3 화면별 파일 관리 + +**파일**: `screenFileController.ts`, `screenFileService.ts` + +```typescript +// 화면별 파일 업로드 +router.post('/screens/:screenId/files', authenticateToken, upload.single('file'), uploadScreenFile); + +// 화면별 파일 목록 조회 +router.get('/screens/:screenId/files', authenticateToken, getScreenFiles); + +// 파일 삭제 +router.delete('/screens/:screenId/files/:fileId', authenticateToken, deleteScreenFile); +``` + +--- + +## 10. 외부 연동 + +### 10.1 외부 DB 연결 + +**지원 DB**: PostgreSQL, MySQL, MSSQL, Oracle, MariaDB + +**파일**: +- `externalDbConnectionService.ts` +- `PostgreSQLConnector.ts`, `MySQLConnector.ts`, `MSSQLConnector.ts`, `OracleConnector.ts`, `MariaDBConnector.ts` + +```typescript +// 외부 DB 연결 설정 +{ + connection_name: "외부 ERP DB", + db_type: "mysql", + host: "192.168.0.100", + port: 3306, + database: "erp_db", + username: "erp_user", + password: "encrypted_password", // AES-256-CBC 암호화 + company_code: "ILSHIN", + is_active: "Y" +} + +// 연결 테스트 +POST /api/external-db-connections/:id/test +{ + success: true, + message: "연결 테스트 성공" +} + +// 쿼리 실행 +POST /api/external-db-connections/:id/query +{ + query: "SELECT * FROM products WHERE category = ?", + params: ["전자제품"] +} +``` + +### 10.2 외부 REST API 연결 + +**파일**: `externalRestApiConnectionService.ts` + +```typescript +// 외부 REST API 연결 설정 +{ + connection_name: "날씨 API", + base_url: "https://api.weather.com", + auth_type: "bearer", // bearer, api-key, basic, oauth2 + auth_credentials: { + token: "encrypted_token" + }, + headers: { + "Content-Type": "application/json" + }, + company_code: "ILSHIN" +} + +// API 호출 +POST /api/external-rest-api-connections/:id/call +{ + method: "GET", + endpoint: "/weather", + params: { city: "Seoul" } +} +``` + +### 10.3 멀티 DB 통합 쿼리 + +**파일**: `multiConnectionQueryService.ts` + +```typescript +// 여러 DB에서 동시 쿼리 실행 +POST /api/multi-connection/query +{ + connections: [ + { + connectionId: 1, + query: "SELECT * FROM orders WHERE status = 'pending'" + }, + { + connectionId: 2, + query: "SELECT * FROM inventory WHERE quantity < 10" + } + ] +} + +// 응답 +{ + success: true, + results: [ + { connectionId: 1, data: [...], rowCount: 15 }, + { connectionId: 2, data: [...], rowCount: 8 } + ] +} +``` + +### 10.4 Open API Proxy + +**파일**: `openApiProxyController.ts` + +```typescript +// 날씨 API +GET /api/open-api/weather?city=Seoul + +// 환율 API +GET /api/open-api/exchange-rate?from=USD&to=KRW +``` + +--- + +## 11. 배치/스케줄 처리 + +### 11.1 배치 스케줄러 + +**파일**: `batchSchedulerService.ts` + +```typescript +// 배치 설정 +{ + batch_name: "일일 재고 동기화", + batch_type: "external_db", // external_db, rest_api, internal + cron_schedule: "0 2 * * *", // 매일 새벽 2시 + source_connection_id: 1, + source_query: "SELECT * FROM inventory", + target_table: "inventory_sync", + mapping: { + product_id: "item_id", + quantity: "stock_qty" + }, + is_active: "Y" +} + +// 스케줄러 초기화 (서버 시작 시) +await BatchSchedulerService.initializeScheduler(); + +// 배치 수동 실행 +POST /api/batch-configs/:id/execute +``` + +### 11.2 Cron 기반 자동 실행 + +```typescript +// node-cron 사용 +const task = cron.schedule( + config.cron_schedule, // "0 2 * * *" + async () => { + logger.info(`배치 실행 시작: ${config.batch_name}`); + await executeBatchConfig(config); + }, + { timezone: 'Asia/Seoul' } +); + +// 스케줄 업데이트 +await BatchSchedulerService.updateBatchSchedule(configId); + +// 스케줄 제거 +await BatchSchedulerService.removeBatchSchedule(configId); +``` + +### 11.3 배치 실행 로그 + +**파일**: `batchExecutionLogService.ts` + +```typescript +// 배치 실행 이력 +{ + batch_config_id: 1, + execution_status: "success", // success, failed, running + start_time: "2024-12-24T02:00:00Z", + end_time: "2024-12-24T02:05:23Z", + rows_processed: 1523, + rows_inserted: 1200, + rows_updated: 300, + rows_failed: 23, + error_message: null, + execution_log: "..." +} + +// 배치 이력 조회 +GET /api/batch-execution-logs?batch_config_id=1&page=1&limit=10 +``` + +### 11.4 자동 스케줄 작업 + +**파일**: `app.ts` + +```typescript +// 메일 자동 삭제 (매일 새벽 2시) +cron.schedule('0 2 * * *', async () => { + logger.info('🗑️ 30일 지난 삭제된 메일 자동 삭제 시작...'); + const deletedCount = await mailSentHistoryService.cleanupOldDeletedMails(); + logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`); +}); + +// 리스크/알림 자동 갱신 (10분 간격) +const cacheService = RiskAlertCacheService.getInstance(); +cacheService.startAutoRefresh(); +``` + +--- + +## 12. 컨트롤러/서비스 상세 역할 + +### 12.1 인증 및 관리자 + +#### `authController.ts` / `authService.ts` +- **로그인**: 비밀번호 검증, JWT 토큰 발급, 로그인 로그 기록 +- **회원가입**: 공차중계 사용자 등록 +- **토큰 갱신**: accessToken, refreshToken 갱신 +- **현재 사용자 정보**: JWT 기반 사용자 정보 조회 +- **로그아웃**: 토큰 무효화 (클라이언트 측 처리) +- **회사 전환**: Super Admin 전용 회사 전환 + +#### `adminController.ts` / `adminService.ts` +- **메뉴 관리**: 메뉴 트리 조회, 메뉴 CRUD, 메뉴 복사, 상태 토글, 일괄 삭제 +- **사용자 관리**: 사용자 목록, 사용자 CRUD, 상태 변경, 비밀번호 초기화, 변경 이력 +- **회사 관리**: 회사 목록, 회사 CRUD (Super Admin 전용) +- **부서 관리**: 부서 목록, 사용자-부서 통합 저장 +- **로케일 설정**: 사용자 언어 설정 (ko, en) +- **테이블 스키마**: 엑셀 업로드 컬럼 매핑용 스키마 조회 + +### 12.2 테이블 및 데이터 + +#### `tableManagementController.ts` / `tableManagementService.ts` +- **테이블 목록**: PostgreSQL information_schema 조회 +- **컬럼 정보**: 컬럼 타입, 제약조건, 라벨, 참조 관계 +- **컬럼 라벨 관리**: 다국어 라벨, 표시 순서, 표시 여부 +- **컬럼 설정**: 입력 타입, 코드 카테고리, 필수 여부, 기본값 +- **테이블 조회 설정**: 조회 컬럼, 정렬 순서, 필터 조건 +- **캐시 관리**: 테이블/컬럼 정보 캐시 (10분 TTL) + +#### `dataController.ts` / `dataService.ts` +- **동적 데이터 조회**: 테이블명 기반 데이터 조회 (페이지네이션, 필터, 정렬) +- **동적 데이터 생성**: INSERT 쿼리 자동 생성 +- **동적 데이터 수정**: UPDATE 쿼리 자동 생성 +- **동적 데이터 삭제**: DELETE 쿼리 자동 생성 (논리 삭제 지원) +- **멀티테넌시 자동 필터링**: company_code 자동 추가 + +#### `entityJoinController.ts` / `entityJoinService.ts` +- **조인 설정 관리**: 테이블 간 조인 관계 설정 (1:N, N:M) +- **조인 쿼리 실행**: 설정된 조인 관계 기반 데이터 조회 +- **참조 데이터 캐싱**: 자주 사용되는 참조 데이터 캐시 + +### 12.3 화면 관리 + +#### `screenManagementController.ts` / `screenManagementService.ts` +- **화면 메타데이터 관리**: 화면 설정 (테이블, 컬럼, 레이아웃) +- **화면 목록 조회**: 회사별, 화면 그룹별 필터링 +- **화면 복사**: 화면 설정 복제 +- **화면 삭제**: 논리 삭제 +- **화면 설정 조회**: 화면 메타데이터 상세 조회 + +#### `dynamicFormController.ts` / `dynamicFormService.ts` +- **동적 폼 생성**: JSON 기반 폼 설정 → React 컴포넌트 +- **폼 유효성 검사**: 필수 입력, 데이터 타입, 길이 제한 +- **폼 제출**: 데이터 저장 (INSERT/UPDATE) + +#### `buttonActionStandardController.ts` / `buttonActionStandardService.ts` +- **버튼 액션 표준**: 저장, 삭제, 조회, 엑셀 다운로드, 커스텀 액션 +- **버튼 액션 설정**: 액션 타입, 파라미터, 권한 설정 + +### 12.4 플로우 및 데이터플로우 + +#### `flowController.ts` / `flowService.ts` +- **플로우 정의**: 플로우 생성, 수정, 삭제, 조회 +- **플로우 단계**: 단계 생성, 수정, 삭제, 순서 변경 +- **플로우 연결**: 단계 간 연결 (조건부 분기) +- **데이터 이동**: 단계 간 데이터 이동 (단일/배치) +- **오딧 로그**: 플로우 실행 이력 조회 + +#### `dataflowController.ts` / `dataflowService.ts` +- **테이블 관계 정의**: 테이블 간 관계 설정 (1:1, 1:N, N:M) +- **데이터플로우 다이어그램**: ERD 같은 시각화 +- **데이터플로우 실행**: 관계 기반 데이터 동기화 +- **관계 조회**: 다이어그램별 관계 목록 + +### 12.5 배치 관리 + +#### `batchController.ts` / `batchService.ts` +- **배치 설정 관리**: 배치 CRUD +- **배치 실행**: 수동 실행, 스케줄 실행 +- **배치 이력**: 실행 로그, 성공/실패 통계 +- **커넥션 조회**: 사용 가능한 외부 DB/API 목록 + +#### `batchSchedulerService.ts` +- **스케줄러 초기화**: 서버 시작 시 활성 배치 등록 +- **스케줄 등록**: Cron 표현식 기반 스케줄 등록 +- **스케줄 업데이트**: 배치 설정 변경 시 스케줄 재등록 +- **스케줄 제거**: 배치 삭제 시 스케줄 제거 + +#### `batchExternalDbService.ts` +- **외부 DB 배치**: 외부 DB → 내부 DB 데이터 동기화 +- **컬럼 매핑**: 소스-타겟 컬럼 매핑 +- **데이터 변환**: 타입 변환, 값 변환 + +### 12.6 외부 연동 + +#### `externalDbConnectionController.ts` / `externalDbConnectionService.ts` +- **외부 DB 연결 관리**: 연결 CRUD +- **연결 테스트**: 연결 유효성 검증 +- **쿼리 실행**: 외부 DB 쿼리 실행 +- **테이블 목록**: 외부 DB 테이블 목록 조회 +- **컬럼 정보**: 외부 DB 컬럼 정보 조회 + +#### `externalRestApiConnectionController.ts` / `externalRestApiConnectionService.ts` +- **REST API 연결 관리**: API 연결 CRUD +- **API 호출**: 외부 API 호출 (GET, POST, PUT, DELETE) +- **인증 처리**: Bearer, API Key, Basic, OAuth2 +- **응답 캐싱**: API 응답 캐싱 (TTL 설정) + +#### `multiConnectionQueryService.ts` +- **멀티 DB 통합 쿼리**: 여러 DB에서 동시 쿼리 실행 +- **결과 병합**: 여러 DB 쿼리 결과 병합 +- **오류 처리**: 부분 실패 시 에러 로그 기록 + +### 12.7 메일 관리 + +#### `mailAccountFileController.ts` / `mailAccountFileService.ts` +- **메일 계정 관리**: SMTP/IMAP 계정 CRUD +- **계정 테스트**: 연결 유효성 검증 +- **계정 상태**: 활성/비활성 상태 관리 + +#### `mailTemplateFileController.ts` / `mailTemplateFileService.ts` +- **메일 템플릿 관리**: 템플릿 CRUD +- **템플릿 컴포넌트**: JSON 기반 컴포넌트 조합 (헤더, 본문, 버튼, 푸터) +- **변수 치환**: {변수명} 형태의 변수 치환 + +#### `mailSendSimpleController.ts` / `mailSendSimpleService.ts` +- **메일 발송**: 단일 발송, 대량 발송 (100건/배치) +- **첨부파일**: 다중 첨부파일 지원 +- **참조/숨은참조**: CC, BCC 지원 +- **발송 이력**: 발송 성공/실패 로그 기록 + +#### `mailSentHistoryController.ts` / `mailSentHistoryService.ts` +- **발송 이력 조회**: 페이지네이션, 필터링, 검색 +- **이력 삭제**: 논리 삭제 (30일 후 물리 삭제) +- **자동 삭제**: Cron 기반 자동 삭제 (매일 새벽 2시) + +#### `mailReceiveBasicController.ts` / `mailReceiveBasicService.ts` +- **메일 수신**: IMAP 기반 메일 수신 +- **메일 목록**: 수신함 목록 조회 +- **메일 읽기**: 메일 상세 조회, 첨부파일 다운로드 + +### 12.8 대시보드 및 리포트 + +#### `DashboardController.ts` / `DashboardService.ts` +- **대시보드 위젯**: 차트, 테이블, 카드 위젯 +- **실시간 데이터**: 집계 쿼리 기반 실시간 데이터 조회 +- **사용자 설정**: 사용자별 대시보드 레이아웃 저장 + +#### `reportController.ts` / `reportService.ts` +- **리포트 생성**: 동적 리포트 생성 (PDF, Excel, Word) +- **리포트 템플릿**: 템플릿 기반 리포트 생성 +- **리포트 스케줄**: 정기 리포트 자동 생성 및 메일 발송 + +### 12.9 기타 도메인 + +#### `deliveryController.ts` / `deliveryService.ts` +- **배송 관리**: 배송 정보 등록, 조회, 수정 +- **화물 추적**: 배송 상태 추적 + +#### `riskAlertController.ts` / `riskAlertService.ts` / `riskAlertCacheService.ts` +- **리스크 알림**: 리스크 기준 설정, 알림 발생 +- **자동 갱신**: 10분 간격 자동 갱신 (캐시 기반) + +#### `todoController.ts` / `todoService.ts` +- **To-Do 관리**: 할 일 CRUD, 상태 변경 (대기, 진행, 완료) + +#### `bookingController.ts` / `bookingService.ts` +- **예약 관리**: 예약 요청 CRUD, 승인/거부 + +#### `digitalTwinController.ts` / `digitalTwinLayoutController.ts` / `digitalTwinDataController.ts` +- **디지털 트윈**: 야드 관제, 3D 레이아웃, 실시간 데이터 시각화 + +#### `scheduleController.ts` / `scheduleService.ts` +- **스케줄 자동 생성**: 작업 스케줄 자동 생성 (규칙 기반) + +#### `workHistoryController.ts` / `workHistoryService.ts` +- **작업 이력**: 작업 로그 조회, 필터링, 검색 + +#### `roleController.ts` / `roleService.ts` +- **권한 그룹**: 권한 그룹 CRUD, 사용자-권한 매핑 + +#### `numberingRuleController.ts` / `numberingRuleService.ts` +- **채번 규칙**: 자동 채번 규칙 설정 (접두사, 연번, 접미사) + +#### `entitySearchController.ts` / `entitySearchService.ts` +- **엔티티 검색**: 동적 엔티티 검색 (테이블, 컬럼, 조건 기반) + +#### `cascadingController.ts` 시리즈 +- **연쇄 드롭다운**: 조건부 드롭다운 관계 설정 +- **자동 입력**: 연쇄 자동 입력 관계 설정 +- **상호 배제**: 상호 배타적 선택 관계 설정 +- **다단계 계층**: 계층 구조 관계 설정 + +--- + +## 📊 통계 요약 + +| 구분 | 개수 | +|------|------| +| **컨트롤러** | 69개 | +| **서비스** | 87개 | +| **라우터** | 77개 | +| **미들웨어** | 4개 | +| **엔드포인트** | 200개 이상 | +| **데이터베이스 커넥터** | 5종 (PostgreSQL, MySQL, MSSQL, Oracle, MariaDB) | +| **유틸리티** | 16개 | +| **타입 정의** | 26개 | + +--- + +## 🔧 기술 스택 + +```json +{ + "런타임": "Node.js 20.10.0+", + "언어": "TypeScript 5.3.3", + "프레임워크": "Express 4.18.2", + "데이터베이스": "PostgreSQL (pg 8.16.3)", + "인증": "JWT (jsonwebtoken 9.0.2)", + "암호화": "BCrypt (bcryptjs 2.4.3)", + "로깅": "Winston 3.11.0", + "스케줄링": "node-cron 4.2.1", + "메일": "Nodemailer 6.10.1 + IMAP 0.8.19", + "파일 업로드": "Multer 1.4.5", + "보안": "Helmet 7.1.0", + "외부 DB": "mysql2, mssql, oracledb", + "캐싱": "In-Memory Cache (Map 기반)", + "압축": "compression 1.7.4", + "Rate Limiting": "express-rate-limit 7.1.5" +} +``` + +--- + +## 📁 핵심 파일 경로 + +``` +backend-node/ +├── src/ +│ ├── app.ts # 앱 진입점 +│ ├── config/environment.ts # 환경변수 설정 +│ ├── database/ +│ │ ├── db.ts # Raw Query 매니저 +│ │ ├── DatabaseConnectorFactory.ts # DB 커넥터 팩토리 +│ │ └── [DB]Connector.ts # 각 DB별 커넥터 +│ ├── middleware/ +│ │ ├── authMiddleware.ts # JWT 인증 +│ │ ├── permissionMiddleware.ts # 권한 체크 +│ │ ├── superAdminMiddleware.ts # Super Admin 체크 +│ │ └── errorHandler.ts # 에러 핸들링 +│ ├── utils/ +│ │ ├── logger.ts # Winston 로거 +│ │ ├── jwtUtils.ts # JWT 유틸 +│ │ ├── encryptUtil.ts # BCrypt 암호화 +│ │ ├── passwordEncryption.ts # AES 암호화 +│ │ ├── cache.ts # 캐시 유틸 +│ │ └── permissionUtils.ts # 권한 유틸 +│ └── types/ +│ ├── auth.ts # 인증 타입 +│ ├── tableManagement.ts # 테이블 관리 타입 +│ └── ... +└── package.json # 의존성 관리 +``` + +--- + +## 🚀 서버 시작 프로세스 + +``` +1. dotenv 환경변수 로드 +2. Express 앱 생성 +3. 미들웨어 설정 (helmet, cors, compression, rate-limit) +4. 데이터베이스 연결 풀 초기화 +5. 라우터 등록 (77개 라우터) +6. 에러 핸들러 등록 +7. 서버 리스닝 (기본 포트: 8080) +8. 데이터베이스 마이그레이션 실행 +9. 배치 스케줄러 초기화 +10. 리스크/알림 자동 갱신 시작 +11. 메일 자동 삭제 스케줄러 시작 +``` + +--- + +## 🔒 보안 고려사항 + +1. **JWT 기반 인증**: 세션 없이 무상태(Stateless) 인증 +2. **비밀번호 암호화**: BCrypt (12 rounds) +3. **외부 DB 크레덴셜 암호화**: AES-256-CBC +4. **SQL Injection 방지**: Parameterized Query 필수 +5. **XSS 방지**: Helmet 보안 헤더 +6. **CSRF 방지**: CORS 설정 + JWT +7. **Rate Limiting**: 1분당 요청 수 제한 +8. **DDL 실행 제한**: Super Admin만 가능 + 5초 간격 제한 +9. **멀티테넌시 격리**: company_code 자동 필터링 +10. **에러 정보 노출 방지**: 운영 환경에서 스택 트레이스 숨김 + +--- + +## 📝 추천 개선 사항 + +1. **API 문서화**: Swagger/OpenAPI 자동 생성 +2. **단위 테스트**: Jest 기반 테스트 커버리지 확대 +3. **Redis 캐싱**: In-Memory 캐시 → Redis 전환 +4. **로그 중앙화**: Winston → ELK Stack 연동 +5. **성능 모니터링**: APM 도구 연동 (New Relic, Datadog) +6. **Docker 컨테이너화**: Dockerfile 및 docker-compose 개선 +7. **CI/CD 파이프라인**: GitHub Actions, Jenkins 연동 +8. **API Rate Limiting 세분화**: 엔드포인트별 제한 설정 +9. **WebSocket 지원**: 실시간 알림 및 데이터 업데이트 +10. **GraphQL API**: REST API + GraphQL 병행 지원 + +--- + +**문서 작성자**: WACE 백엔드 전문가 +**최종 수정일**: 2026-02-06 +**버전**: 1.0.0 + diff --git a/docs/backend-architecture-detailed-analysis.md b/docs/backend-architecture-detailed-analysis.md new file mode 100644 index 00000000..534cf7a3 --- /dev/null +++ b/docs/backend-architecture-detailed-analysis.md @@ -0,0 +1,1855 @@ +# WACE ERP Backend Architecture - 상세 분석 문서 + +> **작성일**: 2026-02-06 +> **작성자**: Backend Specialist +> **목적**: WACE ERP 시스템 백엔드 전체 아키텍처 분석 및 워크플로우 문서화 + +--- + +## 📑 목차 + +1. [전체 개요](#1-전체-개요) +2. [디렉토리 구조](#2-디렉토리-구조) +3. [기술 스택](#3-기술-스택) +4. [미들웨어 스택](#4-미들웨어-스택) +5. [인증/인가 시스템](#5-인증인가-시스템) +6. [멀티테넌시 구현](#6-멀티테넌시-구현) +7. [API 라우트 전체 목록](#7-api-라우트-전체-목록) +8. [비즈니스 도메인별 모듈](#8-비즈니스-도메인별-모듈) +9. [데이터베이스 접근 방식](#9-데이터베이스-접근-방식) +10. [외부 시스템 연동](#10-외부-시스템-연동) +11. [배치/스케줄 처리](#11-배치스케줄-처리) +12. [파일 처리](#12-파일-처리) +13. [에러 핸들링](#13-에러-핸들링) +14. [로깅 시스템](#14-로깅-시스템) +15. [보안 및 권한 관리](#15-보안-및-권한-관리) +16. [성능 최적화](#16-성능-최적화) + +--- + +## 1. 전체 개요 + +### 1.1 프로젝트 정보 +- **프로젝트명**: WACE ERP Backend (Node.js) +- **언어**: TypeScript (Strict Mode) +- **런타임**: Node.js 20.10.0+ +- **프레임워크**: Express.js +- **데이터베이스**: PostgreSQL (Raw Query 기반) +- **포트**: 8080 (기본값) + +### 1.2 아키텍처 특징 +1. **Layered Architecture**: Controller → Service → Database 3계층 구조 +2. **Multi-tenancy**: company_code 기반 완전한 데이터 격리 +3. **JWT 인증**: Stateless 토큰 기반 인증 시스템 +4. **Raw Query**: ORM 없이 PostgreSQL 직접 쿼리 (성능 최적화) +5. **Connection Pool**: pg 라이브러리 기반 안정적인 연결 관리 +6. **Type-Safe**: TypeScript 타입 시스템 적극 활용 + +### 1.3 주요 기능 +- 관리자 기능 (사용자/권한/메뉴 관리) +- 테이블/화면 메타데이터 관리 (동적 화면 생성) +- 플로우 관리 (워크플로우 엔진) +- 데이터플로우 다이어그램 (ERD/관계도) +- 외부 DB 연동 (PostgreSQL, MySQL, MSSQL, Oracle) +- 외부 REST API 연동 +- 배치 자동 실행 (Cron 스케줄러) +- 메일 발송/수신 +- 파일 업로드/다운로드 +- 다국어 지원 +- 대시보드/리포트 + +--- + +## 2. 디렉토리 구조 + +``` +backend-node/ +├── src/ +│ ├── app.ts # Express 앱 진입점 +│ ├── config/ # 환경 설정 +│ │ ├── environment.ts # 환경변수 관리 +│ │ └── multerConfig.ts # 파일 업로드 설정 +│ ├── controllers/ # 컨트롤러 (70+ 파일) +│ │ ├── authController.ts +│ │ ├── adminController.ts +│ │ ├── tableManagementController.ts +│ │ ├── flowController.ts +│ │ ├── dataflowController.ts +│ │ ├── batchController.ts +│ │ └── ... +│ ├── services/ # 비즈니스 로직 (80+ 파일) +│ │ ├── authService.ts +│ │ ├── adminService.ts +│ │ ├── tableManagementService.ts +│ │ ├── flowExecutionService.ts +│ │ ├── batchSchedulerService.ts +│ │ └── ... +│ ├── routes/ # API 라우터 (70+ 파일) +│ │ ├── authRoutes.ts +│ │ ├── adminRoutes.ts +│ │ ├── tableManagementRoutes.ts +│ │ ├── flowRoutes.ts +│ │ └── ... +│ ├── middleware/ # 미들웨어 (4개) +│ │ ├── authMiddleware.ts # JWT 인증 +│ │ ├── permissionMiddleware.ts # 권한 체크 +│ │ ├── superAdminMiddleware.ts # 슈퍼관리자 전용 +│ │ └── errorHandler.ts # 에러 핸들러 +│ ├── database/ # DB 연결 +│ │ ├── db.ts # PostgreSQL Pool 관리 +│ │ ├── DatabaseConnectorFactory.ts # 외부 DB 연결 +│ │ └── runMigration.ts # 마이그레이션 +│ ├── types/ # TypeScript 타입 정의 (26개) +│ │ ├── auth.ts +│ │ ├── batchTypes.ts +│ │ ├── flow.ts +│ │ └── ... +│ ├── utils/ # 유틸리티 함수 +│ │ ├── jwtUtils.ts # JWT 토큰 관리 +│ │ ├── permissionUtils.ts # 권한 체크 +│ │ ├── logger.ts # Winston 로거 +│ │ ├── encryptUtil.ts # 암호화/복호화 +│ │ ├── passwordEncryption.ts # 비밀번호 암호화 +│ │ └── ... +│ └── interfaces/ # 인터페이스 +│ └── DatabaseConnector.ts # DB 커넥터 인터페이스 +├── scripts/ # 스크립트 +│ ├── dev/ # 개발 환경 스크립트 +│ └── prod/ # 운영 환경 스크립트 +├── data/ # 정적 데이터 (JSON) +├── uploads/ # 업로드된 파일 +├── logs/ # 로그 파일 +├── package.json # NPM 의존성 +├── tsconfig.json # TypeScript 설정 +└── .env # 환경변수 +``` + +--- + +## 3. 기술 스택 + +### 3.1 핵심 라이브러리 + +```json +{ + "dependencies": { + "express": "^4.18.2", // 웹 프레임워크 + "pg": "^8.16.3", // PostgreSQL 클라이언트 + "jsonwebtoken": "^9.0.2", // JWT 토큰 + "bcryptjs": "^2.4.3", // 비밀번호 암호화 + "dotenv": "^16.3.1", // 환경변수 + "cors": "^2.8.5", // CORS 처리 + "helmet": "^7.1.0", // 보안 헤더 + "compression": "^1.7.4", // Gzip 압축 + "express-rate-limit": "^7.1.5", // Rate Limiting + "winston": "^3.11.0", // 로깅 + "multer": "^1.4.5-lts.1", // 파일 업로드 + "node-cron": "^4.2.1", // Cron 스케줄러 + "axios": "^1.11.0", // HTTP 클라이언트 + "nodemailer": "^6.10.1", // 메일 발송 + "imap": "^0.8.19", // 메일 수신 + "mysql2": "^3.15.0", // MySQL 연결 + "mssql": "^11.0.1", // MSSQL 연결 + "oracledb": "^6.9.0", // Oracle 연결 + "uuid": "^13.0.0", // UUID 생성 + "joi": "^17.11.0" // 데이터 검증 + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/pg": "^8.15.5", + "typescript": "^5.3.3", + "nodemon": "^3.1.10", + "ts-node": "^10.9.2", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "eslint": "^8.55.0" + } +} +``` + +### 3.2 데이터베이스 연결 +- **메인 DB**: PostgreSQL (pg 라이브러리) +- **외부 DB 지원**: MySQL, MSSQL, Oracle, PostgreSQL +- **Connection Pool**: Min 2~5 / Max 10~20 +- **Timeout**: Connection 30s / Query 60s + +--- + +## 4. 미들웨어 스택 + +### 4.1 미들웨어 실행 순서 (app.ts) + +```typescript +// 1. 프로세스 레벨 예외 처리 +process.on('unhandledRejection', ...) +process.on('uncaughtException', ...) +process.on('SIGTERM', ...) +process.on('SIGINT', ...) + +// 2. 보안 미들웨어 +app.use(helmet({ + contentSecurityPolicy: { ... }, // CSP 설정 + frameguard: { ... } // Iframe 보호 +})) + +// 3. 압축 미들웨어 +app.use(compression()) + +// 4. Body Parser +app.use(express.json({ limit: '10mb' })) +app.use(express.urlencoded({ extended: true, limit: '10mb' })) + +// 5. 정적 파일 서빙 (/uploads) +app.use('/uploads', express.static(...)) + +// 6. CORS 설정 +app.use(cors({ + origin: [...], // 허용 도메인 + credentials: true, // 쿠키 포함 + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'] +})) + +// 7. Rate Limiting (1분에 10000회) +app.use('/api/', limiter) + +// 8. 토큰 자동 갱신 (1시간 이내 만료 시 갱신) +app.use('/api/', refreshTokenIfNeeded) + +// 9. API 라우터 (70+개) +app.use('/api/auth', authRoutes) +app.use('/api/admin', adminRoutes) +// ... + +// 10. 404 핸들러 +app.use('*', notFoundHandler) + +// 11. 에러 핸들러 +app.use(errorHandler) +``` + +### 4.2 인증 미들웨어 체인 + +```typescript +// 기본 인증 +authenticateToken → Controller + +// 관리자 권한 +authenticateToken → requireAdmin → Controller + +// 슈퍼관리자 권한 +authenticateToken → requireSuperAdmin → Controller + +// 회사 데이터 접근 +authenticateToken → requireCompanyAccess → Controller + +// DDL 실행 권한 +authenticateToken → requireDDLPermission → Controller +``` + +--- + +## 5. 인증/인가 시스템 + +### 5.1 인증 플로우 + +``` +┌──────────┐ +│ 로그인 │ +│ 요청 │ +└────┬─────┘ + │ + ▼ +┌────────────────────┐ +│ AuthController │ +│ .login() │ +└────┬───────────────┘ + │ + ▼ +┌────────────────────┐ +│ AuthService │ +│ .processLogin() │ +└────┬───────────────┘ + │ + ├─► 1. loginPwdCheck() → DB에서 비밀번호 검증 + │ (마스터 패스워드: qlalfqjsgh11) + │ + ├─► 2. getPersonBeanFromSession() → 사용자 정보 조회 + │ (user_info, dept_info, company_mng JOIN) + │ + ├─► 3. insertLoginAccessLog() → 로그인 이력 저장 + │ + └─► 4. JwtUtils.generateToken() → JWT 토큰 생성 + (payload: userId, userName, companyCode, userType) +``` + +### 5.2 JWT 토큰 구조 + +```typescript +// Payload +{ + userId: "user123", // 사용자 ID + userName: "홍길동", // 사용자명 + deptName: "개발팀", // 부서명 + companyCode: "ILSHIN", // 회사 코드 (멀티테넌시 키) + companyName: "일신정공", // 회사명 + userType: "COMPANY_ADMIN", // 권한 레벨 + userTypeName: "회사관리자", // 권한명 + iat: 1234567890, // 발급 시간 + exp: 1234654290, // 만료 시간 (24시간) + iss: "PMS-System", // 발급자 + aud: "PMS-Users" // 대상 +} +``` + +### 5.3 권한 체계 (3단계) + +```typescript +// 1. SUPER_ADMIN (최고 관리자) +- company_code = "*" +- userType = "SUPER_ADMIN" +- 전체 회사 데이터 접근 가능 +- DDL 실행 가능 +- 회사 생성/삭제 가능 +- 시스템 설정 변경 가능 + +// 2. COMPANY_ADMIN (회사 관리자) +- company_code = "ILSHIN" (특정 회사) +- userType = "COMPANY_ADMIN" +- 자기 회사 데이터만 접근 +- 자기 회사 사용자 관리 가능 +- 회사 설정 변경 가능 + +// 3. USER (일반 사용자) +- company_code = "ILSHIN" +- userType = "USER" | "GUEST" | "PARTNER" +- 자기 회사 데이터만 접근 +- 읽기/쓰기 권한만 +``` + +### 5.4 토큰 갱신 메커니즘 + +```typescript +// refreshTokenIfNeeded 미들웨어 +// 1. 토큰 만료까지 1시간 미만 남은 경우 +// 2. 자동으로 새 토큰 발급 +// 3. 응답 헤더에 "X-New-Token" 추가 +// 4. 프론트엔드에서 자동으로 토큰 교체 +``` + +--- + +## 6. 멀티테넌시 구현 + +### 6.1 핵심 원칙 + +```typescript +// 🚨 절대 규칙: 모든 쿼리는 company_code 필터 필수 +const companyCode = req.user!.companyCode; + +if (companyCode === "*") { + // 슈퍼관리자: 모든 데이터 조회 가능 + query = "SELECT * FROM table ORDER BY company_code"; +} else { + // 일반 사용자: 자기 회사 데이터만 + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} +``` + +### 6.2 회사 데이터 격리 패턴 + +```typescript +// ✅ 올바른 패턴 +async function getDataList(req: AuthenticatedRequest) { + const companyCode = req.user!.companyCode; // JWT에서 추출 + + // 슈퍼관리자 체크 + if (companyCode === "*") { + // 모든 회사 데이터 조회 + return await query("SELECT * FROM data WHERE 1=1"); + } + + // 일반 사용자: 자기 회사 + 슈퍼관리자 데이터 제외 + return await query( + "SELECT * FROM data WHERE company_code = $1 AND company_code != '*'", + [companyCode] + ); +} + +// ❌ 잘못된 패턴 (절대 금지!) +async function getDataList(req: AuthenticatedRequest) { + const companyCode = req.body.companyCode; // 클라이언트에서 받음 (위험!) + return await query("SELECT * FROM data WHERE company_code = $1", [companyCode]); +} +``` + +### 6.3 슈퍼관리자 숨김 규칙 + +```sql +-- 슈퍼관리자 사용자 (company_code = '*')는 +-- 일반 회사 사용자에게 보이면 안 됨 + +-- ✅ 올바른 쿼리 +SELECT * FROM user_info +WHERE company_code = $1 + AND company_code != '*' -- 슈퍼관리자 숨김 + +-- ❌ 잘못된 쿼리 +SELECT * FROM user_info +WHERE company_code = $1 -- 슈퍼관리자 노출 위험 +``` + +### 6.4 회사 전환 기능 (SUPER_ADMIN 전용) + +```typescript +// POST /api/auth/switch-company +// WACE 관리자가 특정 회사로 컨텍스트 전환 +{ + companyCode: "ILSHIN" // 전환할 회사 코드 +} + +// 새로운 JWT 토큰 발급 (company_code만 변경) +// userType은 "SUPER_ADMIN" 유지 +``` + +--- + +## 7. API 라우트 전체 목록 + +### 7.1 인증/관리자 기능 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/auth/login` | POST | 로그인 | 공개 | +| `/api/auth/logout` | POST | 로그아웃 | 인증 | +| `/api/auth/me` | GET | 현재 사용자 정보 | 인증 | +| `/api/auth/status` | GET | 인증 상태 확인 | 공개 | +| `/api/auth/refresh` | POST | 토큰 갱신 | 인증 | +| `/api/auth/signup` | POST | 회원가입 (공차중계) | 공개 | +| `/api/auth/switch-company` | POST | 회사 전환 | 슈퍼관리자 | +| `/api/admin/users` | GET | 사용자 목록 | 관리자 | +| `/api/admin/users` | POST | 사용자 생성 | 관리자 | +| `/api/admin/menus` | GET | 메뉴 목록 | 인증 | +| `/api/admin/web-types` | GET | 웹타입 표준 관리 | 인증 | +| `/api/admin/button-actions` | GET | 버튼 액션 표준 | 인증 | +| `/api/admin/component-standards` | GET | 컴포넌트 표준 | 인증 | +| `/api/admin/template-standards` | GET | 템플릿 표준 | 인증 | +| `/api/admin/reports` | GET | 리포트 관리 | 인증 | + +### 7.2 테이블/화면 관리 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/table-management/tables` | GET | 테이블 목록 | 인증 | +| `/api/table-management/tables/:table/columns` | GET | 컬럼 목록 | 인증 | +| `/api/table-management/tables/:table/data` | POST | 데이터 조회 | 인증 | +| `/api/table-management/tables/:table/add` | POST | 데이터 추가 | 인증 | +| `/api/table-management/tables/:table/edit` | PUT | 데이터 수정 | 인증 | +| `/api/table-management/tables/:table/delete` | DELETE | 데이터 삭제 | 인증 | +| `/api/table-management/tables/:table/log` | POST | 로그 테이블 생성 | 관리자 | +| `/api/table-management/multi-table-save` | POST | 다중 테이블 저장 | 인증 | +| `/api/screen-management/screens` | GET | 화면 목록 | 인증 | +| `/api/screen-management/screens/:id` | GET | 화면 상세 | 인증 | +| `/api/screen-management/screens` | POST | 화면 생성 | 관리자 | +| `/api/screen-groups` | GET | 화면 그룹 관리 | 인증 | +| `/api/screen-files` | GET | 화면 파일 관리 | 인증 | + +### 7.3 플로우 관리 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/flow/definitions` | GET | 플로우 정의 목록 | 인증 | +| `/api/flow/definitions` | POST | 플로우 생성 | 인증 | +| `/api/flow/definitions/:id/steps` | GET | 단계 목록 | 인증 | +| `/api/flow/definitions/:id/steps` | POST | 단계 생성 | 인증 | +| `/api/flow/connections/:flowId` | GET | 연결 목록 | 인증 | +| `/api/flow/connections` | POST | 연결 생성 | 인증 | +| `/api/flow/move` | POST | 데이터 이동 (단건) | 인증 | +| `/api/flow/move-batch` | POST | 데이터 이동 (다건) | 인증 | +| `/api/flow/:flowId/step/:stepId/count` | GET | 단계 데이터 개수 | 인증 | +| `/api/flow/:flowId/step/:stepId/list` | GET | 단계 데이터 목록 | 인증 | +| `/api/flow/audit/:flowId/:recordId` | GET | 오딧 로그 조회 | 인증 | + +### 7.4 데이터플로우/다이어그램 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/dataflow/relationships` | GET | 관계 목록 | 인증 | +| `/api/dataflow/relationships` | POST | 관계 생성 | 인증 | +| `/api/dataflow-diagrams` | GET | 다이어그램 목록 | 인증 | +| `/api/dataflow-diagrams/:id` | GET | 다이어그램 상세 | 인증 | +| `/api/dataflow` | POST | 데이터플로우 실행 | 인증 | + +### 7.5 외부 연동 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/external-db-connections` | GET | 외부 DB 연결 목록 | 인증 | +| `/api/external-db-connections` | POST | 외부 DB 연결 생성 | 관리자 | +| `/api/external-db-connections/:id/test` | POST | 연결 테스트 | 인증 | +| `/api/external-db-connections/:id/tables` | GET | 테이블 목록 | 인증 | +| `/api/external-rest-api-connections` | GET | REST API 연결 목록 | 인증 | +| `/api/external-rest-api-connections` | POST | REST API 연결 생성 | 관리자 | +| `/api/external-rest-api-connections/:id/test` | POST | API 테스트 | 인증 | +| `/api/multi-connection/query` | POST | 멀티 DB 쿼리 | 인증 | + +### 7.6 배치 관리 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/batch-configs` | GET | 배치 설정 목록 | 인증 | +| `/api/batch-configs` | POST | 배치 설정 생성 | 관리자 | +| `/api/batch-configs/:id` | PUT | 배치 설정 수정 | 관리자 | +| `/api/batch-configs/:id` | DELETE | 배치 설정 삭제 | 관리자 | +| `/api/batch-management/:id/execute` | POST | 배치 즉시 실행 | 관리자 | +| `/api/batch-execution-logs` | GET | 실행 이력 | 인증 | + +### 7.7 메일 시스템 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/mail/accounts` | GET | 계정 목록 | 인증 | +| `/api/mail/templates-file` | GET | 템플릿 목록 | 인증 | +| `/api/mail/send` | POST | 메일 발송 | 인증 | +| `/api/mail/sent` | GET | 발송 이력 | 인증 | +| `/api/mail/receive` | POST | 메일 수신 | 인증 | + +### 7.8 파일 관리 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/files/upload` | POST | 파일 업로드 | 인증 | +| `/api/files/download/:id` | GET | 파일 다운로드 | 인증 | +| `/api/files` | GET | 파일 목록 | 인증 | +| `/api/files/:id` | DELETE | 파일 삭제 | 인증 | +| `/uploads/:filename` | GET | 정적 파일 서빙 | 공개 | + +### 7.9 기타 기능 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/dashboards` | GET | 대시보드 데이터 | 인증 | +| `/api/common-codes` | GET | 공통코드 조회 | 인증 | +| `/api/multilang` | GET | 다국어 조회 | 인증 | +| `/api/company-management` | GET | 회사 목록 | 슈퍼관리자 | +| `/api/departments` | GET | 부서 목록 | 인증 | +| `/api/roles` | GET | 권한 그룹 관리 | 인증 | +| `/api/ddl` | POST | DDL 실행 | 슈퍼관리자 | +| `/api/open-api/weather` | GET | 날씨 정보 | 인증 | +| `/api/open-api/exchange` | GET | 환율 정보 | 인증 | +| `/api/digital-twin` | GET | 디지털 트윈 | 인증 | +| `/api/yard-layouts` | GET | 3D 필드 레이아웃 | 인증 | +| `/api/schedule` | POST | 스케줄 자동 생성 | 인증 | +| `/api/numbering-rules` | GET | 채번 규칙 관리 | 인증 | +| `/api/entity-search` | POST | 엔티티 검색 | 인증 | +| `/api/todos` | GET | To-Do 관리 | 인증 | +| `/api/bookings` | GET | 예약 요청 관리 | 인증 | +| `/api/risk-alerts` | GET | 리스크/알림 관리 | 인증 | +| `/health` | GET | 헬스 체크 | 공개 | + +--- + +## 8. 비즈니스 도메인별 모듈 + +### 8.1 관리자 도메인 (Admin) + +**컨트롤러**: `adminController.ts` +**서비스**: `adminService.ts` +**주요 기능**: +- 사용자 관리 (CRUD) +- 메뉴 관리 (트리 구조) +- 권한 그룹 관리 +- 시스템 설정 +- 사용자 이력 조회 + +**핵심 로직**: +```typescript +// 사용자 목록 조회 (멀티테넌시 적용) +async getUserList(params) { + const companyCode = params.userCompanyCode; + + // 슈퍼관리자: 모든 회사 사용자 조회 + if (companyCode === "*") { + return await query("SELECT * FROM user_info WHERE 1=1"); + } + + // 일반 관리자: 자기 회사 사용자만 + 슈퍼관리자 숨김 + return await query( + "SELECT * FROM user_info WHERE company_code = $1 AND company_code != '*'", + [companyCode] + ); +} +``` + +### 8.2 테이블/화면 관리 도메인 + +**컨트롤러**: `tableManagementController.ts`, `screenManagementController.ts` +**서비스**: `tableManagementService.ts`, `screenManagementService.ts` +**주요 기능**: +- 테이블 메타데이터 관리 (컬럼 설정) +- 화면 정의 (JSON 기반 동적 화면) +- 화면 그룹 관리 +- 테이블 로그 시스템 +- 엔티티 관계 관리 + +**핵심 로직**: +```typescript +// 테이블 데이터 조회 (페이징, 정렬, 필터) +async getTableData(tableName, filters, pagination) { + const companyCode = req.user!.companyCode; + + // 동적 WHERE 절 생성 + const whereClauses = [`company_code = '${companyCode}'`]; + + // 필터 조건 추가 + filters.forEach(filter => { + whereClauses.push(`${filter.column} ${filter.operator} '${filter.value}'`); + }); + + // 페이징 + 정렬 + const sql = ` + SELECT * FROM ${tableName} + WHERE ${whereClauses.join(' AND ')} + ORDER BY ${pagination.sortBy} ${pagination.sortOrder} + LIMIT ${pagination.limit} OFFSET ${pagination.offset} + `; + + return await query(sql); +} +``` + +### 8.3 플로우 도메인 (Flow) + +**컨트롤러**: `flowController.ts` +**서비스**: `flowExecutionService.ts`, `flowDefinitionService.ts` +**주요 기능**: +- 플로우 정의 (워크플로우 설계) +- 단계(Step) 관리 +- 단계 간 연결(Connection) 관리 +- 데이터 이동 (단건/다건) +- 조건부 이동 +- 오딧 로그 + +**핵심 로직**: +```typescript +// 데이터 이동 (단계 간 전환) +async moveData(flowId, fromStepId, toStepId, recordIds) { + return await transaction(async (client) => { + // 1. 연결 조건 확인 + const connection = await getConnection(fromStepId, toStepId); + + // 2. 조건 평가 (있으면) + if (connection.condition) { + const isValid = await evaluateCondition(connection.condition, recordIds); + if (!isValid) throw new Error("조건 불충족"); + } + + // 3. 데이터 이동 + await client.query( + `UPDATE ${connection.targetTable} + SET flow_step_id = $1, updated_at = now() + WHERE id = ANY($2) AND company_code = $3`, + [toStepId, recordIds, companyCode] + ); + + // 4. 오딧 로그 기록 + await insertAuditLog(flowId, recordIds, fromStepId, toStepId); + }); +} +``` + +### 8.4 데이터플로우 도메인 (Dataflow) + +**컨트롤러**: `dataflowController.ts`, `dataflowDiagramController.ts` +**서비스**: `dataflowService.ts`, `dataflowDiagramService.ts` +**주요 기능**: +- 테이블 관계 정의 (ERD) +- 다이어그램 관리 (시각화) +- 관계 실행 (조인 쿼리 자동 생성) +- 관계 검증 + +**핵심 로직**: +```typescript +// 테이블 관계 실행 (동적 조인) +async executeRelationship(relationshipId) { + const rel = await getRelationship(relationshipId); + + // 동적 조인 쿼리 생성 + const sql = ` + SELECT + a.*, + b.* + FROM ${rel.fromTableName} a + ${rel.relationshipType === '1:N' ? 'LEFT JOIN' : 'INNER JOIN'} + ${rel.toTableName} b + ON a.${rel.fromColumnName} = b.${rel.toColumnName} + WHERE a.company_code = $1 + `; + + return await query(sql, [companyCode]); +} +``` + +### 8.5 배치 도메인 (Batch) + +**컨트롤러**: `batchController.ts`, `batchManagementController.ts` +**서비스**: `batchService.ts`, `batchSchedulerService.ts` +**주요 기능**: +- 배치 설정 관리 +- Cron 스케줄링 (node-cron) +- 외부 DB → 내부 DB 데이터 동기화 +- 컬럼 매핑 +- 실행 이력 관리 + +**핵심 로직**: +```typescript +// 배치 스케줄러 초기화 (서버 시작 시) +async initializeScheduler() { + const activeBatches = await getBatchConfigs({ is_active: 'Y' }); + + for (const batch of activeBatches) { + // Cron 스케줄 등록 + const task = cron.schedule(batch.cron_schedule, async () => { + await executeBatchConfig(batch); + }, { + timezone: "Asia/Seoul" + }); + + scheduledTasks.set(batch.id, task); + } +} + +// 배치 실행 +async executeBatchConfig(config) { + // 1. 소스 DB에서 데이터 가져오기 + const sourceData = await getSourceData(config.source_connection); + + // 2. 컬럼 매핑 적용 + const mappedData = applyColumnMapping(sourceData, config.batch_mappings); + + // 3. 타겟 DB에 INSERT/UPDATE + await upsertToTarget(mappedData, config.target_table); + + // 4. 실행 로그 기록 + await logExecution(config.id, sourceData.length); +} +``` + +### 8.6 외부 연동 도메인 (External) + +**컨트롤러**: `externalDbConnectionController.ts`, `externalRestApiConnectionController.ts` +**서비스**: `externalDbConnectionService.ts`, `dbConnectionManager.ts` +**주요 기능**: +- 외부 DB 연결 설정 (PostgreSQL, MySQL, MSSQL, Oracle) +- Connection Pool 관리 +- 연결 테스트 +- 외부 REST API 연결 설정 +- API 프록시 + +**핵심 로직**: +```typescript +// 외부 DB 연결 (Factory 패턴) +class DatabaseConnectorFactory { + static getConnector(dbType: string, config: any) { + switch (dbType) { + case 'POSTGRESQL': + return new PostgreSQLConnector(config); + case 'MYSQL': + return new MySQLConnector(config); + case 'MSSQL': + return new MSSQLConnector(config); + case 'ORACLE': + return new OracleConnector(config); + default: + throw new Error(`지원하지 않는 DB 타입: ${dbType}`); + } + } +} + +// 외부 DB 쿼리 실행 +async executeExternalQuery(connectionId, sql) { + // 1. 연결 정보 조회 (암호화된 비밀번호 복호화) + const connection = await getConnection(connectionId); + connection.password = decrypt(connection.password); + + // 2. 커넥터 생성 + const connector = DatabaseConnectorFactory.getConnector( + connection.db_type, + connection + ); + + // 3. 연결 및 쿼리 실행 + await connector.connect(); + const result = await connector.query(sql); + await connector.disconnect(); + + return result; +} +``` + +### 8.7 메일 도메인 (Mail) + +**컨트롤러**: `mailSendSimpleController.ts`, `mailReceiveBasicController.ts` +**서비스**: `mailSendSimpleService.ts`, `mailReceiveBasicService.ts` +**주요 기능**: +- 메일 계정 관리 (파일 기반) +- 메일 템플릿 관리 +- 메일 발송 (nodemailer) +- 메일 수신 (IMAP) +- 발송 이력 관리 +- 첨부파일 처리 + +**핵심 로직**: +```typescript +// 메일 발송 +async sendEmail(params) { + // 1. 계정 정보 조회 + const account = await getMailAccount(params.accountId); + + // 2. 템플릿 적용 (있으면) + let emailBody = params.body; + if (params.templateId) { + const template = await getTemplate(params.templateId); + emailBody = renderTemplate(template.content, params.variables); + } + + // 3. nodemailer 전송 + const transporter = nodemailer.createTransport({ + host: account.smtp_host, + port: account.smtp_port, + secure: true, + auth: { + user: account.email, + pass: decrypt(account.password) + } + }); + + const result = await transporter.sendMail({ + from: account.email, + to: params.to, + subject: params.subject, + html: emailBody, + attachments: params.attachments + }); + + // 4. 발송 이력 저장 + await saveSentHistory(params, result); +} +``` + +### 8.8 파일 도메인 (File) + +**컨트롤러**: `fileController.ts`, `screenFileController.ts` +**서비스**: `fileSystemManager.ts` +**주요 기능**: +- 파일 업로드 (multer) +- 파일 다운로드 +- 파일 삭제 +- 화면별 파일 관리 + +**핵심 로직**: +```typescript +// 파일 업로드 +const upload = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => { + const companyCode = req.user!.companyCode; + const dir = `uploads/${companyCode}`; + fs.mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: (req, file, cb) => { + const uniqueName = `${Date.now()}-${uuidv4()}-${file.originalname}`; + cb(null, uniqueName); + } + }), + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB + fileFilter: (req, file, cb) => { + // 파일 타입 검증 + const allowedTypes = /jpeg|jpg|png|gif|pdf|doc|docx|xls|xlsx/; + const isValid = allowedTypes.test(file.mimetype); + cb(null, isValid); + } +}); + +// 파일 메타데이터 저장 +async saveFileMetadata(file, userId, companyCode) { + return await query( + `INSERT INTO file_info ( + file_name, file_path, file_size, mime_type, + company_code, created_by + ) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [file.originalname, file.path, file.size, file.mimetype, companyCode, userId] + ); +} +``` + +### 8.9 대시보드 도메인 (Dashboard) + +**컨트롤러**: `DashboardController.ts` +**서비스**: `DashboardService.ts` +**주요 기능**: +- 대시보드 설정 관리 +- 위젯 관리 +- 통계 데이터 조회 +- 차트 데이터 생성 + +--- + +## 9. 데이터베이스 접근 방식 + +### 9.1 Connection Pool 설정 + +```typescript +// database/db.ts +const pool = new Pool({ + host: "localhost", + port: 5432, + database: "ilshin", + user: "postgres", + password: "postgres", + + // Pool 설정 + min: config.nodeEnv === "production" ? 5 : 2, // 최소 연결 수 + max: config.nodeEnv === "production" ? 20 : 10, // 최대 연결 수 + + // Timeout 설정 + connectionTimeoutMillis: 30000, // 연결 대기 30초 + idleTimeoutMillis: 600000, // 유휴 연결 유지 10분 + statement_timeout: 60000, // 쿼리 실행 60초 + query_timeout: 60000, + + // 연결 유지 + keepAlive: true, + keepAliveInitialDelayMillis: 10000, + + // Application Name + application_name: "WACE-PLM-Backend" +}); +``` + +### 9.2 쿼리 실행 패턴 + +```typescript +// 1. 기본 쿼리 (다중 행) +const users = await query( + 'SELECT * FROM user_info WHERE company_code = $1', + [companyCode] +); + +// 2. 단일 행 쿼리 +const user = await queryOne( + 'SELECT * FROM user_info WHERE user_id = $1', + ['user123'] +); + +// 3. 트랜잭션 +const result = await transaction(async (client) => { + await client.query('INSERT INTO table1 (...) VALUES (...)', [...]); + await client.query('INSERT INTO table2 (...) VALUES (...)', [...]); + return { success: true }; +}); +``` + +### 9.3 Parameterized Query (SQL Injection 방지) + +```typescript +// ✅ 올바른 방법 (Parameterized Query) +const users = await query( + 'SELECT * FROM user_info WHERE user_id = $1 AND dept_code = $2', + [userId, deptCode] +); + +// ❌ 잘못된 방법 (SQL Injection 위험!) +const users = await query( + `SELECT * FROM user_info WHERE user_id = '${userId}'` +); +``` + +### 9.4 동적 쿼리 빌더 패턴 + +```typescript +// utils/queryBuilder.ts +class QueryBuilder { + private table: string; + private whereClauses: string[] = []; + private params: any[] = []; + private paramIndex: number = 1; + + constructor(table: string) { + this.table = table; + } + + where(column: string, value: any) { + this.whereClauses.push(`${column} = $${this.paramIndex++}`); + this.params.push(value); + return this; + } + + whereIn(column: string, values: any[]) { + this.whereClauses.push(`${column} = ANY($${this.paramIndex++})`); + this.params.push(values); + return this; + } + + build() { + const where = this.whereClauses.length > 0 + ? `WHERE ${this.whereClauses.join(' AND ')}` + : ''; + return { + sql: `SELECT * FROM ${this.table} ${where}`, + params: this.params + }; + } +} + +// 사용 예시 +const { sql, params } = new QueryBuilder('user_info') + .where('company_code', companyCode) + .where('user_type', 'USER') + .whereIn('dept_code', ['D001', 'D002']) + .build(); + +const users = await query(sql, params); +``` + +--- + +## 10. 외부 시스템 연동 + +### 10.1 외부 DB 연결 아키텍처 + +``` +┌─────────────────────┐ +│ Backend Service │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ DatabaseConnector │ +│ Factory │ +└──────────┬──────────┘ + │ + ┌─────┴─────┬─────────┬─────────┐ + ▼ ▼ ▼ ▼ +┌─────────┐ ┌─────────┐ ┌───────┐ ┌────────┐ +│PostgreSQL│ │ MySQL │ │ MSSQL │ │ Oracle │ +│Connector│ │Connector│ │Connect│ │Connect │ +└─────────┘ └─────────┘ └───────┘ └────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌─────────┐ ┌─────────┐ ┌───────┐ ┌────────┐ +│External │ │External │ │External│ │External│ +│ PG DB │ │MySQL DB │ │MSSQL DB│ │Oracle │ +└─────────┘ └─────────┘ └───────┘ └────────┘ +``` + +### 10.2 외부 DB Connector 인터페이스 + +```typescript +// interfaces/DatabaseConnector.ts +interface DatabaseConnector { + connect(): Promise; + disconnect(): Promise; + query(sql: string, params?: any[]): Promise; + testConnection(): Promise; + getTables(): Promise; + getColumns(tableName: string): Promise; +} + +// 구현 예시: PostgreSQL +class PostgreSQLConnector implements DatabaseConnector { + private pool: Pool; + + constructor(config: ExternalDbConnection) { + this.pool = new Pool({ + host: config.host, + port: config.port, + database: config.database, + user: config.username, + password: decrypt(config.password), + ssl: config.use_ssl ? { rejectUnauthorized: false } : false + }); + } + + async connect() { + await this.pool.connect(); + } + + async query(sql: string, params?: any[]) { + const result = await this.pool.query(sql, params); + return result.rows; + } + + async getTables() { + const result = await this.query( + `SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + ORDER BY tablename` + ); + return result.map(row => row.tablename); + } +} +``` + +### 10.3 외부 REST API 연동 + +```typescript +// services/externalRestApiConnectionService.ts +async callExternalApi(connectionId: number, endpoint: string, options: any) { + // 1. 연결 정보 조회 + const connection = await getApiConnection(connectionId); + + // 2. 인증 헤더 생성 + const headers: any = { + 'Content-Type': 'application/json', + ...connection.headers + }; + + if (connection.auth_type === 'BEARER') { + headers['Authorization'] = `Bearer ${decrypt(connection.auth_token)}`; + } else if (connection.auth_type === 'API_KEY') { + headers[connection.api_key_header] = decrypt(connection.api_key); + } + + // 3. Axios 요청 + const response = await axios({ + method: options.method || 'GET', + url: `${connection.base_url}${endpoint}`, + headers, + data: options.body, + timeout: connection.timeout || 30000 + }); + + return response.data; +} +``` + +--- + +## 11. 배치/스케줄 처리 + +### 11.1 배치 스케줄러 시스템 + +```typescript +// services/batchSchedulerService.ts +class BatchSchedulerService { + private static scheduledTasks: Map = new Map(); + + // 서버 시작 시 모든 활성 배치 스케줄링 + static async initializeScheduler() { + const activeBatches = await getBatchConfigs({ is_active: 'Y' }); + + for (const batch of activeBatches) { + this.scheduleBatch(batch); + } + } + + // 개별 배치 스케줄 등록 + static scheduleBatch(config: any) { + if (!cron.validate(config.cron_schedule)) { + throw new Error(`Invalid cron: ${config.cron_schedule}`); + } + + const task = cron.schedule( + config.cron_schedule, + async () => { + await this.executeBatchConfig(config); + }, + { timezone: "Asia/Seoul" } + ); + + this.scheduledTasks.set(config.id, task); + } + + // 배치 실행 + static async executeBatchConfig(config: any) { + const startTime = new Date(); + + try { + // 1. 소스 DB에서 데이터 조회 + const sourceData = await this.getSourceData(config); + + // 2. 컬럼 매핑 적용 + const mappedData = this.applyMapping(sourceData, config.batch_mappings); + + // 3. 타겟 DB에 저장 + await this.upsertToTarget(mappedData, config); + + // 4. 성공 로그 + await BatchExecutionLogService.updateExecutionLog(executionLogId, { + execution_status: 'SUCCESS', + end_time: new Date(), + success_records: mappedData.length + }); + } catch (error) { + // 5. 실패 로그 + await BatchExecutionLogService.updateExecutionLog(executionLogId, { + execution_status: 'FAILURE', + end_time: new Date(), + error_message: error.message + }); + } + } +} +``` + +### 11.2 Cron 표현식 예시 + +``` +# 형식: 초 분 시 일 월 요일 + +# 매 시간 정각 +0 * * * * + +# 매일 새벽 2시 +0 2 * * * + +# 매주 월요일 오전 9시 +0 9 * * 1 + +# 매월 1일 자정 +0 0 1 * * + +# 5분마다 +*/5 * * * * + +# 평일 오전 8시~오후 6시, 매 시간 +0 8-18 * * 1-5 +``` + +### 11.3 배치 실행 이력 관리 + +```typescript +// services/batchExecutionLogService.ts +interface ExecutionLog { + id: number; + batch_config_id: number; + execution_status: 'RUNNING' | 'SUCCESS' | 'FAILURE'; + start_time: Date; + end_time?: Date; + total_records: number; + success_records: number; + failure_records: number; + error_message?: string; + company_code: string; +} + +// 실행 로그 저장 +async createExecutionLog(data: Partial) { + return await query( + `INSERT INTO batch_execution_logs ( + batch_config_id, company_code, execution_status, + start_time, total_records + ) VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [data.batch_config_id, data.company_code, data.execution_status, + data.start_time, data.total_records] + ); +} +``` + +--- + +## 12. 파일 처리 + +### 12.1 파일 업로드 흐름 + +``` +┌─────────────┐ +│ Frontend │ +└──────┬──────┘ + │ (FormData) + ▼ +┌─────────────────────┐ +│ Multer Middleware │ → 파일 저장 (uploads/COMPANY_CODE/) +└──────┬──────────────┘ + │ + ▼ +┌─────────────────────┐ +│ FileController │ → 메타데이터 저장 (file_info 테이블) +└──────┬──────────────┘ + │ + ▼ +┌─────────────────────┐ +│ Response │ → { fileId, fileName, filePath, fileSize } +└─────────────────────┘ +``` + +### 12.2 Multer 설정 + +```typescript +// config/multerConfig.ts +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const companyCode = req.user!.companyCode; + const uploadDir = path.join(process.cwd(), 'uploads', companyCode); + + // 디렉토리 없으면 생성 + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + cb(null, uploadDir); + }, + + filename: (req, file, cb) => { + // 파일명 중복 방지: 타임스탬프 + UUID + 원본파일명 + const timestamp = Date.now(); + const uniqueId = uuidv4(); + const extension = path.extname(file.originalname); + const basename = path.basename(file.originalname, extension); + const uniqueName = `${timestamp}-${uniqueId}-${basename}${extension}`; + + cb(null, uniqueName); + } +}); + +export const upload = multer({ + storage, + limits: { + fileSize: 10 * 1024 * 1024 // 10MB + }, + fileFilter: (req, file, cb) => { + // 허용 확장자 검증 + const allowedMimeTypes = [ + 'image/jpeg', 'image/png', 'image/gif', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ]; + + if (allowedMimeTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('허용되지 않는 파일 형식입니다.')); + } + } +}); +``` + +### 12.3 파일 다운로드 + +```typescript +// controllers/fileController.ts +async downloadFile(req: AuthenticatedRequest, res: Response) { + const fileId = parseInt(req.params.id); + const companyCode = req.user!.companyCode; + + // 1. 파일 정보 조회 (권한 체크) + const file = await queryOne( + `SELECT * FROM file_info + WHERE id = $1 AND company_code = $2`, + [fileId, companyCode] + ); + + if (!file) { + return res.status(404).json({ error: '파일을 찾을 수 없습니다.' }); + } + + // 2. 파일 존재 확인 + if (!fs.existsSync(file.file_path)) { + return res.status(404).json({ error: '파일이 존재하지 않습니다.' }); + } + + // 3. 파일 다운로드 + res.download(file.file_path, file.file_name); +} +``` + +### 12.4 파일 삭제 (논리 삭제) + +```typescript +async deleteFile(req: AuthenticatedRequest, res: Response) { + const fileId = parseInt(req.params.id); + const companyCode = req.user!.companyCode; + + // 1. 논리 삭제 (is_active = 'N') + await query( + `UPDATE file_info + SET is_active = 'N', deleted_at = now(), deleted_by = $3 + WHERE id = $1 AND company_code = $2`, + [fileId, companyCode, req.user!.userId] + ); + + // 2. 물리 파일은 삭제하지 않음 (복구 가능) + // 주기적으로 삭제된 지 30일 지난 파일만 물리 삭제 +} +``` + +--- + +## 13. 에러 핸들링 + +### 13.1 에러 핸들러 미들웨어 + +```typescript +// middleware/errorHandler.ts +export const errorHandler = (err, req, res, next) => { + let error = { ...err }; + error.message = err.message; + + // 1. PostgreSQL 에러 처리 + if (err.code) { + switch (err.code) { + case '23505': // unique_violation + error = new AppError('중복된 데이터가 존재합니다.', 400); + break; + case '23503': // foreign_key_violation + error = new AppError('참조 무결성 제약 조건 위반입니다.', 400); + break; + case '23502': // not_null_violation + error = new AppError('필수 입력값이 누락되었습니다.', 400); + break; + default: + error = new AppError(`데이터베이스 오류: ${err.message}`, 500); + } + } + + // 2. JWT 에러 처리 + if (err.name === 'JsonWebTokenError') { + error = new AppError('유효하지 않은 토큰입니다.', 401); + } + if (err.name === 'TokenExpiredError') { + error = new AppError('토큰이 만료되었습니다.', 401); + } + + // 3. 에러 로깅 + logger.error({ + message: error.message, + stack: error.stack, + url: req.url, + method: req.method, + ip: req.ip + }); + + // 4. 응답 + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ + success: false, + error: { + message: error.message, + ...(process.env.NODE_ENV === 'development' && { stack: error.stack }) + } + }); +}; +``` + +### 13.2 커스텀 에러 클래스 + +```typescript +export class AppError extends Error { + public statusCode: number; + public isOperational: boolean; + + constructor(message: string, statusCode: number = 500) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + Error.captureStackTrace(this, this.constructor); + } +} + +// 사용 예시 +if (!user) { + throw new AppError('사용자를 찾을 수 없습니다.', 404); +} +``` + +### 13.3 프로세스 레벨 예외 처리 + +```typescript +// app.ts +process.on('unhandledRejection', (reason, promise) => { + logger.error('⚠️ Unhandled Promise Rejection:', { + reason: reason?.message || reason, + stack: reason?.stack + }); + // 프로세스 종료하지 않고 로깅만 수행 +}); + +process.on('uncaughtException', (error) => { + logger.error('🔥 Uncaught Exception:', { + message: error.message, + stack: error.stack + }); + // 예외 발생 후에도 서버 유지 (주의: 불안정할 수 있음) +}); + +process.on('SIGTERM', () => { + logger.info('📴 SIGTERM 시그널 수신, graceful shutdown 시작...'); + // 연결 풀 정리, 진행 중인 요청 완료 대기 + process.exit(0); +}); +``` + +--- + +## 14. 로깅 시스템 + +### 14.1 Winston Logger 설정 + +```typescript +// utils/logger.ts +import winston from 'winston'; + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.errors({ stack: true }), + winston.format.splat(), + winston.format.json() + ), + transports: [ + // 파일 로그 + new winston.transports.File({ + filename: 'logs/error.log', + level: 'error', + maxsize: 10485760, // 10MB + maxFiles: 5 + }), + new winston.transports.File({ + filename: 'logs/combined.log', + maxsize: 10485760, + maxFiles: 10 + }), + + // 콘솔 로그 (개발 환경) + ...(process.env.NODE_ENV === 'development' ? [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + }) + ] : []) + ] +}); + +export { logger }; +``` + +### 14.2 로깅 레벨 + +```typescript +// 로그 레벨 (우선순위 높음 → 낮음) +logger.error('에러 발생', { error }); // 0 +logger.warn('경고 메시지'); // 1 +logger.info('정보 메시지'); // 2 +logger.http('HTTP 요청'); // 3 +logger.verbose('상세 정보'); // 4 +logger.debug('디버그 정보', { data }); // 5 +logger.silly('매우 상세한 정보'); // 6 +``` + +### 14.3 로깅 패턴 + +```typescript +// 1. 인증 로그 +logger.info(`인증 성공: ${userInfo.userId} (${req.ip})`); +logger.warn(`인증 실패: ${errorMessage} (${req.ip})`); + +// 2. 쿼리 로그 (디버그 모드) +if (config.debug) { + logger.debug('쿼리 실행:', { + query: sql, + params, + rowCount: result.rowCount, + duration: `${duration}ms` + }); +} + +// 3. 에러 로그 +logger.error('배치 실행 실패:', { + batchId: config.id, + error: error.message, + stack: error.stack +}); + +// 4. 비즈니스 로그 +logger.info(`플로우 데이터 이동: ${fromStepId} → ${toStepId}`, { + flowId, + recordCount: recordIds.length +}); +``` + +--- + +## 15. 보안 및 권한 관리 + +### 15.1 비밀번호 암호화 + +```typescript +// utils/encryptUtil.ts +export class EncryptUtil { + // 비밀번호 해싱 (bcrypt) + static hash(password: string): string { + return bcrypt.hashSync(password, 12); + } + + // 비밀번호 검증 + static matches(plainPassword: string, hashedPassword: string): boolean { + return bcrypt.compareSync(plainPassword, hashedPassword); + } +} + +// 사용 예시 +const hashedPassword = EncryptUtil.hash('mypassword'); +const isValid = EncryptUtil.matches('mypassword', hashedPassword); +``` + +### 15.2 민감 정보 암호화 (AES-256) + +```typescript +// utils/credentialEncryption.ts +import crypto from 'crypto'; + +const ALGORITHM = 'aes-256-cbc'; +const SECRET_KEY = process.env.ENCRYPTION_KEY || 'default-32-char-secret-key!!!!'; +const IV_LENGTH = 16; + +// 암호화 +export function encrypt(text: string): string { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(SECRET_KEY), iv); + + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return iv.toString('hex') + ':' + encrypted; +} + +// 복호화 +export function decrypt(encryptedText: string): string { + const parts = encryptedText.split(':'); + const iv = Buffer.from(parts[0], 'hex'); + const encryptedData = parts[1]; + + const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(SECRET_KEY), iv); + + let decrypted = decipher.update(encryptedData, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} + +// 사용 예시: 외부 DB 비밀번호 저장 +const encryptedPassword = encrypt('db_password'); +await query( + 'INSERT INTO external_db_connections (password) VALUES ($1)', + [encryptedPassword] +); + +// 사용 시 복호화 +const connection = await queryOne('SELECT * FROM external_db_connections WHERE id = $1', [id]); +const plainPassword = decrypt(connection.password); +``` + +### 15.3 SQL Injection 방지 + +```typescript +// ✅ Parameterized Query (항상 사용) +const users = await query( + 'SELECT * FROM user_info WHERE user_id = $1 AND company_code = $2', + [userId, companyCode] +); + +// ❌ 문자열 연결 (절대 사용 금지!) +const users = await query( + `SELECT * FROM user_info WHERE user_id = '${userId}'` +); +``` + +### 15.4 Rate Limiting + +```typescript +// app.ts +const limiter = rateLimit({ + windowMs: 1 * 60 * 1000, // 1분 + max: 10000, // 최대 10000회 (개발: 완화, 운영: 100) + message: { + error: '너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.' + }, + skip: (req) => { + // 헬스 체크는 Rate Limiting 제외 + return req.path === '/health'; + } +}); + +app.use('/api/', limiter); +``` + +### 15.5 CORS 설정 + +```typescript +// config/environment.ts +const getCorsOrigin = () => { + // 개발 환경: 모든 origin 허용 + if (process.env.NODE_ENV === 'development') { + return true; + } + + // 운영 환경: 허용 도메인만 + return [ + 'http://localhost:9771', + 'http://39.117.244.52:5555', + 'https://v1.vexplor.com', + 'https://api.vexplor.com' + ]; +}; + +// app.ts +app.use(cors({ + origin: getCorsOrigin(), + credentials: true, // 쿠키 포함 + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'] +})); +``` + +--- + +## 16. 성능 최적화 + +### 16.1 Connection Pool 모니터링 + +```typescript +// database/db.ts +// 5분마다 Pool 상태 체크 +setInterval(() => { + if (pool) { + const status = { + totalCount: pool.totalCount, // 전체 연결 수 + idleCount: pool.idleCount, // 유휴 연결 수 + waitingCount: pool.waitingCount // 대기 중인 연결 수 + }; + + // 대기 연결이 5개 이상이면 경고 + if (status.waitingCount > 5) { + console.warn('⚠️ PostgreSQL 연결 풀 대기열 증가:', status); + } + } +}, 5 * 60 * 1000); +``` + +### 16.2 쿼리 실행 시간 로깅 + +```typescript +// database/db.ts +export async function query(text: string, params?: any[]) { + const client = await pool.connect(); + + try { + const startTime = Date.now(); + const result = await client.query(text, params); + const duration = Date.now() - startTime; + + // 1초 이상 걸린 쿼리는 경고 + if (duration > 1000) { + logger.warn('⚠️ 느린 쿼리 감지:', { + query: text, + params, + duration: `${duration}ms` + }); + } + + return result.rows; + } finally { + client.release(); + } +} +``` + +### 16.3 캐싱 전략 + +```typescript +// utils/cache.ts (Redis 기반) +import Redis from 'redis'; + +const redis = Redis.createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379' +}); + +// 캐시 조회 (있으면 반환, 없으면 쿼리 후 캐싱) +export async function getCachedData(key: string, fetcher: () => Promise, ttl: number = 300) { + // 1. 캐시 확인 + const cached = await redis.get(key); + if (cached) { + return JSON.parse(cached); + } + + // 2. 캐시 미스 → DB 조회 + const data = await fetcher(); + + // 3. 캐시 저장 (TTL: 5분) + await redis.setEx(key, ttl, JSON.stringify(data)); + + return data; +} + +// 사용 예시: 메뉴 목록 캐싱 +const menuList = await getCachedData( + `menu:${companyCode}:${userId}`, + () => AdminService.getUserMenuList(params), + 600 // 10분 캐싱 +); +``` + +### 16.4 압축 (Gzip) + +```typescript +// app.ts +import compression from 'compression'; + +app.use(compression({ + level: 6, // 압축 레벨 (0~9) + threshold: 1024, // 1KB 이상만 압축 + filter: (req, res) => { + // JSON 응답만 압축 + return req.headers['x-no-compression'] ? false : compression.filter(req, res); + } +})); +``` + +--- + +## 🎯 핵심 요약 + +### 아키텍처 패턴 +- **Layered Architecture**: Controller → Service → Database +- **Multi-tenancy**: `company_code` 기반 완전한 데이터 격리 +- **JWT 인증**: Stateless 토큰 기반 (24시간 만료) +- **Raw Query**: ORM 없이 PostgreSQL 직접 쿼리 + +### 보안 원칙 +1. **모든 쿼리에 company_code 필터 필수** +2. **JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)** +3. **Parameterized Query로 SQL Injection 방지** +4. **비밀번호 bcrypt, 민감정보 AES-256 암호화** +5. **Rate Limiting으로 DDoS 방지** + +### 주요 도메인 +- 관리자 (사용자/메뉴/권한) +- 테이블/화면 메타데이터 +- 플로우 (워크플로우 엔진) +- 데이터플로우 (ERD/관계도) +- 외부 연동 (DB/REST API) +- 배치 (Cron 스케줄러) +- 메일 (발송/수신) +- 파일 (업로드/다운로드) + +### API 개수 +- **총 70+ 라우트** +- **인증/관리자**: 15개 +- **테이블/화면**: 20개 +- **플로우**: 10개 +- **외부 연동**: 10개 +- **배치**: 6개 +- **메일**: 5개 +- **파일**: 4개 + +--- + +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-02-06 diff --git a/docs/backend-architecture-summary.md b/docs/backend-architecture-summary.md new file mode 100644 index 00000000..d2155f24 --- /dev/null +++ b/docs/backend-architecture-summary.md @@ -0,0 +1,342 @@ +# WACE ERP Backend - 아키텍처 요약 + +> **작성일**: 2026-02-06 +> **목적**: 워크플로우 문서 통합용 백엔드 아키텍처 요약 + +--- + +## 1. 기술 스택 + +``` +언어: TypeScript (Node.js 20.10.0+) +프레임워크: Express.js +데이터베이스: PostgreSQL (pg 라이브러리, Raw Query) +인증: JWT (jsonwebtoken) +스케줄러: node-cron +메일: nodemailer + IMAP +파일업로드: multer +외부DB: MySQL, MSSQL, Oracle 지원 +``` + +## 2. 계층 구조 + +``` +┌─────────────────┐ +│ Controller │ ← API 요청 수신, 응답 생성 +└────────┬────────┘ + │ +┌────────▼────────┐ +│ Service │ ← 비즈니스 로직, 트랜잭션 관리 +└────────┬────────┘ + │ +┌────────▼────────┐ +│ Database │ ← PostgreSQL Raw Query +└─────────────────┘ +``` + +## 3. 디렉토리 구조 + +``` +backend-node/src/ +├── app.ts # Express 앱 진입점 +├── config/ # 환경설정 +├── controllers/ # 70+ 컨트롤러 +├── services/ # 80+ 서비스 +├── routes/ # 70+ 라우터 +├── middleware/ # 인증/권한/에러핸들러 +├── database/ # DB 연결 (pg Pool) +├── types/ # TypeScript 타입 (26개) +└── utils/ # 유틸리티 (JWT, 암호화, 로거) +``` + +## 4. 미들웨어 스택 순서 + +```typescript +1. Process Level Exception Handlers (unhandledRejection, uncaughtException) +2. Helmet (보안 헤더) +3. Compression (Gzip) +4. Body Parser (JSON, URL-encoded, 10MB limit) +5. Static Files (/uploads) +6. CORS (credentials: true) +7. Rate Limiting (1분 10000회) +8. Token Auto Refresh (1시간 이내 만료 시 갱신) +9. API Routes (70+개) +10. 404 Handler +11. Error Handler +``` + +## 5. 인증/인가 시스템 + +### 5.1 인증 흐름 + +``` +로그인 요청 + ↓ +AuthController.login() + ↓ +AuthService.processLogin() + ├─ loginPwdCheck() → 비밀번호 검증 (bcrypt) + ├─ getPersonBeanFromSession() → 사용자 정보 조회 + ├─ insertLoginAccessLog() → 로그인 이력 저장 + └─ JwtUtils.generateToken() → JWT 토큰 생성 + ↓ +응답: { token, userInfo, firstMenuPath } +``` + +### 5.2 JWT Payload + +```json +{ + "userId": "user123", + "userName": "홍길동", + "companyCode": "ILSHIN", + "userType": "COMPANY_ADMIN", + "iat": 1234567890, + "exp": 1234654290, + "iss": "PMS-System" +} +``` + +### 5.3 권한 체계 (3단계) + +| 권한 | company_code | userType | 권한 범위 | +|------|--------------|----------|-----------| +| **SUPER_ADMIN** | `*` | `SUPER_ADMIN` | 전체 회사, DDL 실행, 회사 생성/삭제 | +| **COMPANY_ADMIN** | `ILSHIN` | `COMPANY_ADMIN` | 자기 회사만, 사용자/설정 관리 | +| **USER** | `ILSHIN` | `USER` | 자기 회사만, 읽기/쓰기 | + +## 6. 멀티테넌시 구현 + +### 핵심 원칙 +```typescript +// ✅ 올바른 패턴 +const companyCode = req.user!.companyCode; // JWT에서 추출 + +if (companyCode === "*") { + // 슈퍼관리자: 모든 데이터 조회 + query = "SELECT * FROM table ORDER BY company_code"; +} else { + // 일반 사용자: 자기 회사 + 슈퍼관리자 데이터 제외 + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} + +// ❌ 잘못된 패턴 (보안 위험!) +const companyCode = req.body.companyCode; // 클라이언트에서 받음 +``` + +### 슈퍼관리자 숨김 규칙 +```sql +-- 일반 회사 사용자에게 슈퍼관리자(company_code='*')는 보이면 안 됨 +SELECT * FROM user_info +WHERE company_code = $1 + AND company_code != '*' -- 필수! +``` + +## 7. API 라우트 (70+개) + +### 7.1 인증/관리자 +- `POST /api/auth/login` - 로그인 +- `GET /api/auth/me` - 현재 사용자 정보 +- `POST /api/auth/switch-company` - 회사 전환 (슈퍼관리자) +- `GET /api/admin/users` - 사용자 목록 +- `GET /api/admin/menus` - 메뉴 목록 + +### 7.2 테이블/화면 +- `GET /api/table-management/tables` - 테이블 목록 +- `POST /api/table-management/tables/:table/data` - 데이터 조회 +- `POST /api/table-management/multi-table-save` - 다중 테이블 저장 +- `GET /api/screen-management/screens` - 화면 목록 + +### 7.3 플로우 +- `GET /api/flow/definitions` - 플로우 정의 목록 +- `POST /api/flow/move` - 데이터 이동 (단건) +- `POST /api/flow/move-batch` - 데이터 이동 (다건) + +### 7.4 외부 연동 +- `GET /api/external-db-connections` - 외부 DB 연결 목록 +- `POST /api/external-db-connections/:id/test` - 연결 테스트 +- `POST /api/multi-connection/query` - 멀티 DB 쿼리 + +### 7.5 배치 +- `GET /api/batch-configs` - 배치 설정 목록 +- `POST /api/batch-management/:id/execute` - 배치 즉시 실행 + +### 7.6 메일 +- `POST /api/mail/send` - 메일 발송 +- `GET /api/mail/sent` - 발송 이력 + +### 7.7 파일 +- `POST /api/files/upload` - 파일 업로드 +- `GET /uploads/:filename` - 정적 파일 서빙 + +## 8. 비즈니스 도메인 (8개) + +| 도메인 | 컨트롤러 | 주요 기능 | +|--------|----------|-----------| +| **관리자** | `adminController` | 사용자/메뉴/권한 관리 | +| **테이블/화면** | `tableManagementController` | 메타데이터, 동적 화면 생성 | +| **플로우** | `flowController` | 워크플로우 엔진, 데이터 이동 | +| **데이터플로우** | `dataflowController` | ERD, 관계도 | +| **외부 연동** | `externalDbConnectionController` | 외부 DB/REST API | +| **배치** | `batchController` | Cron 스케줄러, 데이터 동기화 | +| **메일** | `mailSendSimpleController` | 메일 발송/수신 | +| **파일** | `fileController` | 파일 업로드/다운로드 | + +## 9. 데이터베이스 접근 + +### Connection Pool 설정 +```typescript +{ + min: 2~5, // 최소 연결 수 + max: 10~20, // 최대 연결 수 + connectionTimeout: 30000, // 30초 + idleTimeout: 600000, // 10분 + statementTimeout: 60000 // 쿼리 실행 60초 +} +``` + +### Raw Query 패턴 +```typescript +// 1. 다중 행 +const users = await query('SELECT * FROM user_info WHERE company_code = $1', [companyCode]); + +// 2. 단일 행 +const user = await queryOne('SELECT * FROM user_info WHERE user_id = $1', [userId]); + +// 3. 트랜잭션 +await transaction(async (client) => { + await client.query('INSERT INTO table1 ...', [...]); + await client.query('INSERT INTO table2 ...', [...]); +}); +``` + +## 10. 외부 시스템 연동 + +### 지원 데이터베이스 +- PostgreSQL +- MySQL +- Microsoft SQL Server +- Oracle + +### Connector Factory Pattern +```typescript +DatabaseConnectorFactory + ├── PostgreSQLConnector + ├── MySQLConnector + ├── MSSQLConnector + └── OracleConnector +``` + +## 11. 배치/스케줄 시스템 + +### Cron 스케줄러 +```typescript +// node-cron 기반 +// 매일 새벽 2시: "0 2 * * *" +// 5분마다: "*/5 * * * *" +// 평일 오전 8시: "0 8 * * 1-5" + +// 서버 시작 시 자동 초기화 +BatchSchedulerService.initializeScheduler(); +``` + +### 배치 실행 흐름 +``` +1. 소스 DB에서 데이터 조회 + ↓ +2. 컬럼 매핑 적용 + ↓ +3. 타겟 DB에 INSERT/UPDATE + ↓ +4. 실행 로그 기록 (batch_execution_logs) +``` + +## 12. 파일 처리 + +### 업로드 경로 +``` +uploads/ + └── {company_code}/ + └── {timestamp}-{uuid}-{filename} +``` + +### Multer 설정 +- 최대 파일 크기: 10MB +- 허용 타입: 이미지, PDF, Office 문서 +- 파일명 중복 방지: 타임스탬프 + UUID + +## 13. 보안 + +### 암호화 +- **비밀번호**: bcrypt (12 rounds) +- **민감정보**: AES-256-CBC (외부 DB 비밀번호 등) +- **JWT Secret**: 환경변수 관리 + +### 보안 헤더 +- Helmet (CSP, X-Frame-Options) +- CORS (credentials: true) +- Rate Limiting (1분 10000회) + +### SQL Injection 방지 +- Parameterized Query 사용 (pg 라이브러리) +- 동적 쿼리 빌더 패턴 + +## 14. 에러 핸들링 + +### PostgreSQL 에러 코드 매핑 +- `23505` → "중복된 데이터" +- `23503` → "참조 무결성 위반" +- `23502` → "필수 입력값 누락" + +### 프로세스 레벨 +- `unhandledRejection` → 로깅 (서버 유지) +- `uncaughtException` → 로깅 (서버 유지, 주의) +- `SIGTERM/SIGINT` → Graceful Shutdown + +## 15. 로깅 (Winston) + +### 로그 파일 +- `logs/error.log` - 에러만 (10MB × 5파일) +- `logs/combined.log` - 전체 로그 (10MB × 10파일) + +### 로그 레벨 +``` +error (0) → warn (1) → info (2) → debug (5) +``` + +## 16. 성능 최적화 + +### Pool 모니터링 +- 5분마다 상태 체크 +- 대기 연결 5개 이상 시 경고 + +### 느린 쿼리 감지 +- 1초 이상 걸린 쿼리 자동 경고 + +### 캐싱 (Redis) +- 메뉴 목록: 10분 TTL +- 공통코드: 30분 TTL + +### Gzip 압축 +- 1KB 이상 응답만 압축 (레벨 6) + +--- + +## 🎯 핵심 체크리스트 + +### 개발 시 반드시 지켜야 할 규칙 + +✅ **모든 쿼리에 `company_code` 필터 추가** +✅ **JWT 토큰에서 `company_code` 추출 (클라이언트 신뢰 금지)** +✅ **Parameterized Query 사용 (SQL Injection 방지)** +✅ **슈퍼관리자 데이터 숨김 (`company_code != '*'`)** +✅ **비밀번호는 bcrypt, 민감정보는 AES-256** +✅ **에러 핸들링 try/catch 필수** +✅ **트랜잭션이 필요한 경우 `transaction()` 사용** +✅ **파일 업로드는 회사별 디렉토리 분리** + +--- + +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-02-06 diff --git a/docs/frontend-architecture-analysis.md b/docs/frontend-architecture-analysis.md new file mode 100644 index 00000000..fb367585 --- /dev/null +++ b/docs/frontend-architecture-analysis.md @@ -0,0 +1,1920 @@ +# WACE ERP 프론트엔드 아키텍처 상세 분석 + +> 작성일: 2026-02-06 +> 작성자: AI Assistant +> 프로젝트: WACE ERP-node +> 목적: 시스템 전체 워크플로우 문서화를 위한 프론트엔드 구조 분석 + +--- + +## 목차 +1. [전체 디렉토리 구조](#1-전체-디렉토리-구조) +2. [Next.js App Router 구조](#2-nextjs-app-router-구조) +3. [컴포넌트 시스템](#3-컴포넌트-시스템) +4. [V2 컴포넌트 시스템](#4-v2-컴포넌트-시스템) +5. [화면 디자이너 워크플로우](#5-화면-디자이너-워크플로우) +6. [API 클라이언트 시스템](#6-api-클라이언트-시스템) +7. [상태 관리](#7-상태-관리) +8. [레지스트리 시스템](#8-레지스트리-시스템) +9. [대시보드 시스템](#9-대시보드-시스템) +10. [다국어 지원](#10-다국어-지원) +11. [인증 플로우](#11-인증-플로우) +12. [사용자 워크플로우](#12-사용자-워크플로우) + +--- + +## 1. 전체 디렉토리 구조 + +``` +frontend/ +├── app/ # Next.js 14 App Router +│ ├── (main)/ # 메인 레이아웃 그룹 +│ ├── (auth)/ # 인증 레이아웃 그룹 +│ ├── (admin)/ # 관리자 레이아웃 그룹 +│ ├── (pop)/ # 팝업 레이아웃 그룹 +│ ├── layout.tsx # 루트 레이아웃 +│ └── registry-provider.tsx # 레지스트리 초기화 프로바이더 +│ +├── components/ # React 컴포넌트 +│ ├── admin/ # 관리자 컴포넌트 (137개) +│ ├── screen/ # 화면 디자이너/뷰어 (139개) +│ ├── dashboard/ # 대시보드 컴포넌트 (32개) +│ ├── dataflow/ # 데이터플로우 관련 (90개) +│ ├── v2/ # V2 컴포넌트 시스템 (28개) +│ ├── common/ # 공통 컴포넌트 (19개) +│ ├── layout/ # 레이아웃 컴포넌트 (10개) +│ ├── ui/ # shadcn/ui 기반 UI (31개) +│ └── ... # 기타 도메인별 컴포넌트 +│ +├── lib/ # 유틸리티 & 라이브러리 +│ ├── api/ # API 클라이언트 (57개 파일) +│ │ ├── client.ts # Axios 기본 설정 +│ │ ├── screen.ts # 화면 관리 API +│ │ ├── user.ts # 사용자 관리 API +│ │ └── ... # 도메인별 API +│ │ +│ ├── registry/ # 컴포넌트 레지스트리 시스템 (540개) +│ │ ├── ComponentRegistry.ts # 컴포넌트 등록 관리 +│ │ ├── WebTypeRegistry.ts # 웹타입 등록 관리 +│ │ ├── LayoutRegistry.ts # 레이아웃 등록 관리 +│ │ ├── init.ts # 레지스트리 초기화 +│ │ ├── DynamicComponentRenderer.tsx # 동적 렌더링 +│ │ ├── components/ # 등록 가능한 컴포넌트들 (288 tsx, 205 ts) +│ │ │ ├── v2-input/ +│ │ │ ├── v2-select/ +│ │ │ ├── text-input/ +│ │ │ ├── entity-search-input/ +│ │ │ ├── modal-repeater-table/ +│ │ │ └── ... +│ │ └── layouts/ # 레이아웃 컴포넌트 +│ │ ├── accordion/ +│ │ ├── grid/ +│ │ ├── flexbox/ +│ │ ├── split/ +│ │ └── tabs/ +│ │ +│ ├── v2-core/ # V2 코어 시스템 +│ │ ├── events/ # 이벤트 버스 +│ │ ├── adapters/ # 레거시 어댑터 +│ │ ├── components/ # V2 공통 컴포넌트 +│ │ ├── services/ # V2 서비스 +│ │ └── init.ts # V2 초기화 +│ │ +│ ├── utils/ # 유틸리티 함수들 (40개+) +│ ├── services/ # 비즈니스 로직 서비스 +│ ├── stores/ # Zustand 스토어 +│ └── constants/ # 상수 정의 +│ +├── contexts/ # React Context (12개) +│ ├── AuthContext.tsx # 인증 컨텍스트 +│ ├── MenuContext.tsx # 메뉴 컨텍스트 +│ ├── ScreenContext.tsx # 화면 컨텍스트 +│ ├── DashboardContext.tsx # 대시보드 컨텍스트 +│ └── ... +│ +├── hooks/ # Custom Hooks (32개) +│ ├── useAuth.ts # 인증 훅 +│ ├── useMenu.ts # 메뉴 관리 훅 +│ ├── useFormValidation.ts # 폼 검증 훅 +│ └── ... +│ +├── types/ # TypeScript 타입 정의 (44개) +│ ├── screen.ts # 화면 관련 타입 +│ ├── component.ts # 컴포넌트 타입 +│ ├── user.ts # 사용자 타입 +│ └── ... +│ +├── providers/ # React Provider +│ └── QueryProvider.tsx # React Query 설정 +│ +├── stores/ # 전역 상태 관리 +│ ├── flowStepStore.ts # 플로우 단계 상태 +│ ├── modalDataStore.ts # 모달 데이터 상태 +│ └── tableDisplayStore.ts # 테이블 표시 상태 +│ +└── public/ # 정적 파일 + ├── favicon.ico + ├── logo.png + └── locales/ # 다국어 파일 +``` + +--- + +## 2. Next.js App Router 구조 + +### 2.1 라우팅 구조 + +WACE ERP는 **Next.js 14 App Router**를 사용하며, 레이아웃 그룹을 활용한 구조화된 라우팅을 제공합니다. + +#### 라우트 그룹 구조 + +``` +app/ +├── layout.tsx # 글로벌 레이아웃 +│ ├── QueryProvider # React Query 초기화 +│ ├── RegistryProvider # 컴포넌트 레지스트리 초기화 +│ ├── Toaster # 토스트 알림 +│ └── ScreenModal # 전역 화면 모달 +│ +├── (main)/ # 메인 애플리케이션 +│ ├── layout.tsx +│ │ ├── AuthProvider # 인증 컨텍스트 +│ │ ├── MenuProvider # 메뉴 컨텍스트 +│ │ └── AppLayout # 사이드바, 헤더 +│ │ +│ ├── main/page.tsx # 메인 페이지 +│ ├── dashboard/ # 대시보드 +│ │ ├── page.tsx # 대시보드 목록 +│ │ └── [dashboardId]/page.tsx # 대시보드 상세 +│ │ +│ ├── screens/[screenId]/page.tsx # 동적 화면 뷰어 +│ │ +│ └── admin/ # 관리자 페이지 +│ ├── page.tsx # 관리자 홈 +│ ├── menu/page.tsx # 메뉴 관리 +│ │ +│ ├── userMng/ # 사용자 관리 +│ │ ├── userMngList/page.tsx +│ │ ├── rolesList/page.tsx +│ │ ├── userAuthList/page.tsx +│ │ └── companyList/page.tsx +│ │ +│ ├── screenMng/ # 화면 관리 +│ │ ├── screenMngList/page.tsx +│ │ ├── dashboardList/page.tsx +│ │ └── reportList/page.tsx +│ │ +│ ├── systemMng/ # 시스템 관리 +│ │ ├── tableMngList/page.tsx +│ │ ├── commonCodeList/page.tsx +│ │ ├── i18nList/page.tsx +│ │ ├── dataflow/page.tsx +│ │ └── collection-managementList/page.tsx +│ │ +│ ├── automaticMng/ # 자동화 관리 +│ │ ├── flowMgmtList/page.tsx +│ │ ├── batchmngList/page.tsx +│ │ ├── exCallConfList/page.tsx +│ │ └── mail/ # 메일 관리 +│ │ ├── accounts/page.tsx +│ │ ├── send/page.tsx +│ │ ├── receive/page.tsx +│ │ └── templates/page.tsx +│ │ +│ └── [...slug]/page.tsx # 동적 관리자 페이지 +│ +├── (auth)/ # 인증 페이지 +│ ├── layout.tsx # 최소 레이아웃 +│ └── login/page.tsx # 로그인 페이지 +│ +├── (admin)/ # 관리자 전용 (별도 레이아웃) +│ └── admin/ +│ ├── vehicle-trips/page.tsx +│ └── vehicle-reports/page.tsx +│ +└── (pop)/ # 팝업 페이지 + ├── layout.tsx # 팝업 레이아웃 + ├── pop/page.tsx + └── work/page.tsx +``` + +### 2.2 페이지 목록 (총 76개) + +**메인 애플리케이션 (50개)** +- `/main` - 메인 페이지 +- `/dashboard` - 대시보드 목록 +- `/dashboard/[dashboardId]` - 대시보드 상세 +- `/screens/[screenId]` - 동적 화면 뷰어 ⭐ 핵심 +- `/multilang` - 다국어 관리 + +**관리자 페이지 (40개+)** +- 사용자 관리: 사용자, 역할, 권한, 회사, 부서 +- 화면 관리: 화면 목록, 대시보드, 리포트 +- 시스템 관리: 테이블, 공통코드, 다국어, 데이터플로우, 컬렉션 +- 자동화 관리: 플로우, 배치, 외부호출, 메일 +- 표준 관리: 웹타입 표준화 +- 캐스케이딩: 관계, 계층, 상호배타, 자동채움 + +**테스트 페이지 (5개)** +- `/test-autocomplete-mapping` +- `/test-entity-search` +- `/test-order-registration` +- `/test-type-safety` +- `/test-flow` + +**인증/기타 (2개)** +- `/login` - 로그인 +- `/pop` - 팝업 페이지 + +### 2.3 Next.js 설정 + +**next.config.mjs** +```javascript +{ + output: "standalone", // Docker 최적화 + eslint: { ignoreDuringBuilds: true }, + typescript: { ignoreBuildErrors: true }, + + // API 프록시: /api/* → http://localhost:8080/api/* + rewrites: [ + { source: "/api/:path*", destination: "http://localhost:8080/api/:path*" } + ], + + // CORS 헤더 + headers: [ + { source: "/api/:path*", headers: [...] } + ], + + // 환경 변수 + env: { + NEXT_PUBLIC_API_URL: "http://localhost:8080/api" + } +} +``` + +--- + +## 3. 컴포넌트 시스템 + +### 3.1 컴포넌트 분류 + +WACE ERP의 컴포넌트는 크게 **4가지 계층**으로 구분됩니다: + +#### 계층 구조 + +``` +1. UI 컴포넌트 (shadcn/ui 기반) - components/ui/ + └─ Button, Input, Select, Dialog, Card 등 기본 UI + +2. 공통 컴포넌트 - components/common/ + └─ ScreenModal, FormValidationIndicator 등 + +3. 도메인 컴포넌트 - components/{domain}/ + ├─ screen/ - 화면 디자이너/뷰어 + ├─ admin/ - 관리자 기능 + ├─ dashboard/ - 대시보드 + └─ dataflow/ - 데이터플로우 + +4. 레지스트리 컴포넌트 - lib/registry/components/ + └─ 동적으로 등록/렌더링되는 컴포넌트들 (90개+) +``` + +### 3.2 주요 컴포넌트 모듈 + +#### 📁 components/screen/ (139개 파일) + +**핵심 컴포넌트** + +| 컴포넌트 | 역할 | 주요 기능 | +|---------|------|----------| +| `ScreenDesigner.tsx` (7095줄) | 화면 디자이너 | - 드래그&드롭으로 컴포넌트 배치
- 그리드 시스템 (12컬럼)
- 실시간 미리보기
- 컴포넌트 설정 패널
- 그룹화/정렬/분산 도구 | +| `InteractiveScreenViewer.tsx` (2472줄) | 화면 뷰어 | - 실제 사용자 화면 렌더링
- 폼 데이터 바인딩
- 버튼 액션 실행
- 검증 처리 | +| `ScreenManagementList.tsx` | 화면 목록 | - 화면 CRUD
- 메뉴 할당
- 다국어 설정 | +| `DynamicWebTypeRenderer.tsx` | 웹타입 렌더러 | - WebTypeRegistry 기반 동적 렌더링 | + +**위젯 컴포넌트 (widgets/types/)** +- TextWidget, NumberWidget, DateWidget +- SelectWidget, CheckboxWidget, RadioWidget +- TextareaWidget, FileWidget, CodeWidget +- ButtonWidget, EntitySearchInputWrapper + +**설정 패널 (config-panels/)** +- 각 웹타입별 설정 패널 (TextConfigPanel, SelectConfigPanel 등) + +**모달 컴포넌트 (modals/)** +- 키보드 단축키, 다국어 설정, 메뉴 할당 등 + +#### 📁 components/admin/ (137개 파일) + +**관리자 기능 컴포넌트** +- 사용자 관리: UserManagementList, RolesManagementList, CompanyList +- 화면 관리: ScreenManagementList, DashboardManagementList +- 테이블 관리: TableManagementList, ColumnEditor +- 공통코드 관리: CommonCodeManagement +- 메뉴 관리: MenuManagement + +#### 📁 components/dashboard/ (32개 파일) + +**대시보드 컴포넌트** +- DashboardViewer: 대시보드 렌더링 +- DashboardWidgets: 차트, 테이블, 카드 등 위젯 +- ChartComponents: 라인, 바, 파이, 도넛 차트 + +#### 📁 components/dataflow/ (90개+ 파일) + +**데이터플로우 시스템** +- DataFlowList: 플로우 목록 +- node-editor/: 노드 편집기 +- connection/: 커넥션 관리 (38개 파일) +- condition/: 조건 설정 +- external-call/: 외부 호출 설정 + +--- + +## 4. V2 컴포넌트 시스템 + +V2는 **통합 컴포넌트 시스템**으로, 유사한 기능을 하나의 컴포넌트로 통합하여 개발 효율성을 높입니다. + +### 4.1 V2 컴포넌트 종류 (9개) + +| ID | 이름 | 설명 | 포함 기능 | +|----|------|------|----------| +| `v2-input` | 통합 입력 | 텍스트, 숫자, 비밀번호, 슬라이더, 컬러 등 | text, number, password, email, tel, textarea, slider, color | +| `v2-select` | 통합 선택 | 드롭다운, 라디오, 체크박스, 태그, 토글 등 | dropdown, radio, checkbox, tagbox, toggle, swap, combobox | +| `v2-date` | 통합 날짜 | 날짜, 시간, 날짜시간, 날짜 범위 | date, time, datetime, daterange | +| `v2-list` | 통합 목록 | 테이블, 카드, 칸반, 리스트 | table, card, kanban, list, grid | +| `v2-layout` | 통합 레이아웃 | 그리드, 분할 패널, 플렉스 | grid, split, flex, masonry | +| `v2-group` | 통합 그룹 | 탭, 아코디언, 섹션, 모달 | tabs, accordion, section, modal, drawer | +| `v2-media` | 통합 미디어 | 이미지, 비디오, 오디오, 파일 업로드 | image, video, audio, file | +| `v2-biz` | 통합 비즈니스 | 플로우, 랙, 채번규칙, 카테고리 | flow, rack, numbering, category | +| `v2-repeater` | 통합 반복 | 인라인 테이블, 모달, 버튼 | inline-table, modal, button, selected-items | + +### 4.2 V2 아키텍처 + +``` +components/v2/ # V2 UI 컴포넌트 +├── V2Input.tsx # 통합 입력 컴포넌트 +├── V2Select.tsx # 통합 선택 컴포넌트 +├── V2Date.tsx # 통합 날짜 컴포넌트 +├── V2List.tsx # 통합 목록 컴포넌트 +├── V2Layout.tsx # 통합 레이아웃 컴포넌트 +├── V2Group.tsx # 통합 그룹 컴포넌트 +├── V2Media.tsx # 통합 미디어 컴포넌트 +├── V2Biz.tsx # 통합 비즈니스 컴포넌트 +├── V2Hierarchy.tsx # 통합 계층 컴포넌트 +├── V2Repeater.tsx # 통합 반복 컴포넌트 +├── V2FormContext.tsx # V2 폼 컨텍스트 +├── V2ComponentRenderer.tsx # V2 렌더러 +├── registerV2Components.ts # V2 등록 함수 +└── config-panels/ # V2 설정 패널 + ├── V2InputConfigPanel.tsx + ├── V2SelectConfigPanel.tsx + └── ... + +lib/v2-core/ # V2 코어 라이브러리 +├── events/ # 이벤트 버스 +│ ├── EventBus.ts # 발행/구독 패턴 +│ └── types.ts # 이벤트 타입 +├── adapters/ # 레거시 어댑터 +│ └── LegacyEventAdapter.ts # 레거시 시스템 연동 +├── components/ # V2 공통 컴포넌트 +│ └── V2ErrorBoundary.tsx # 에러 경계 +├── services/ # V2 서비스 +│ ├── ScheduleGeneratorService.ts +│ └── ScheduleConfirmDialog.tsx +└── init.ts # V2 초기화 + +lib/registry/components/v2-*/ # V2 렌더러 (레지스트리용) +├── v2-input/ +│ ├── V2InputRenderer.tsx # 자동 등록 렌더러 +│ └── index.ts # 컴포넌트 정의 +├── v2-select/ +│ ├── V2SelectRenderer.tsx +│ └── index.ts +└── ... +``` + +### 4.3 V2 초기화 흐름 + +```typescript +// 1. app/registry-provider.tsx +useEffect(() => { + // 레거시 레지스트리 초기화 + initializeRegistries(); + + // V2 Core 초기화 + initV2Core({ + debug: false, + legacyBridge: { legacyToV2: true, v2ToLegacy: true } + }); +}, []); + +// 2. lib/registry/init.ts +export function initializeRegistries() { + // 웹타입 등록 (text, number, date 등) + initializeWebTypeRegistry(); + + // V2 컴포넌트 등록 + registerV2Components(); +} + +// 3. components/v2/registerV2Components.ts +export function registerV2Components() { + for (const definition of v2ComponentDefinitions) { + ComponentRegistry.registerComponent(definition); + } +} +``` + +### 4.4 V2 렌더링 흐름 + +``` +사용자 요청 (screens/[screenId]) + ↓ +InteractiveScreenViewer + ↓ +DynamicComponentRenderer + ↓ +┌─ componentType이 v2-* 인가? ─┐ +│ Yes │ No +↓ ↓ +ComponentRegistry.getComponent LegacyComponentRegistry.get + ↓ ↓ +V2*Renderer (클래스 기반) 레거시 렌더러 (함수) + ↓ ↓ +render() → V2* 컴포넌트 직접 렌더링 + ↓ +V2FormContext 연동 (선택) + ↓ +최종 렌더링 +``` + +### 4.5 V2 vs 레거시 비교 + +| 항목 | 레거시 시스템 | V2 시스템 | +|------|--------------|----------| +| 컴포넌트 수 | 90개+ (분산) | 9개 (통합) | +| 렌더러 패턴 | 함수형 렌더러 | 클래스 기반 자동 등록 | +| 폼 통합 | 개별 구현 | V2FormContext 통합 | +| 이벤트 시스템 | props drilling | V2 EventBus (발행/구독) | +| 에러 처리 | 개별 처리 | V2ErrorBoundary 통합 | +| 설정 패널 | 개별 패널 | 통합 설정 시스템 | +| 핫 리로드 | 미지원 | 개발 모드 지원 | + +--- + +## 5. 화면 디자이너 워크플로우 + +### 5.1 화면 디자이너 구성 + +**ScreenDesigner.tsx** (7095줄) - 핵심 컴포넌트 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Screen Designer │ +├─────────────────────────────────────────────────────────────────┤ +│ 탭1: 디자인 | 탭2: 프리뷰 | 탭3: 다국어 | 탭4: 메뉴할당 | 탭5: 스타일 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ [컴포넌트 팔레트] [캔버스] [속성 패널] │ +│ ┌──────────────┐ ┌────────────────┐ ┌──────────────┐ │ +│ │ 입력 컴포넌트 │ │ │ │ 기본 설정 │ │ +│ │ - Text │ │ 드래그&드롭 │ │ - ID │ │ +│ │ - Number │ │ 컴포넌트 │ │ - 라벨 │ │ +│ │ - Date │ ───▶ │ 배치 영역 │ ◀── │ - 위치/크기 │ │ +│ │ - Select │ │ │ │ - 데이터연결 │ │ +│ │ │ │ 그리드 기반 │ │ │ │ +│ │ 표시 컴포넌트 │ │ 12컬럼 │ │ 고급 설정 │ │ +│ │ - Table │ │ │ │ - 조건부표시 │ │ +│ │ - Card │ │ │ │ - 검증규칙 │ │ +│ │ │ │ │ │ - 버튼액션 │ │ +│ │ 레이아웃 │ └────────────────┘ └──────────────┘ │ +│ │ - Grid │ │ +│ │ - Tabs │ [하단 도구] │ +│ │ - Accordion │ ┌─────────────────────────────────────┐ │ +│ │ │ │ 그룹화 | 정렬 | 분산 | 라벨토글 │ │ +│ └──────────────┘ └─────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 주요 기능 + +#### 그리드 시스템 +```typescript +// 12컬럼 그리드 기반 +const GRID_COLUMNS = 12; +const COLUMN_WIDTH = (containerWidth - gaps) / 12; + +// 컴포넌트 너비 → 컬럼 스팬 변환 +function calculateColumnSpan(width: number): number { + return Math.max(1, Math.min(12, Math.round(width / COLUMN_WIDTH))); +} + +// 반응형 지원 +{ + default: { span: 6 }, // 기본 (50%) + sm: { span: 12 }, // 모바일 (100%) + md: { span: 6 }, // 태블릿 (50%) + lg: { span: 4 } // 데스크톱 (33%) +} +``` + +#### 컴포넌트 배치 흐름 + +``` +1. 컴포넌트 팔레트에서 드래그 시작 + ↓ +2. 캔버스에 드롭 + ↓ +3. 컴포넌트 생성 (generateComponentId) + ↓ +4. 위치 계산 (10px 단위 스냅) + ↓ +5. 그리드 정렬 (12컬럼 기준) + ↓ +6. 레이아웃 데이터 업데이트 + ↓ +7. 리렌더링 (RealtimePreview) +``` + +#### 컴포넌트 설정 + +``` +선택된 컴포넌트 → 우측 속성 패널 표시 + ↓ +┌─────────────────────────────────────┐ +│ 기본 설정 │ +│ - ID: comp_1234 │ +│ - 라벨: "제품명" │ +│ - 컬럼명: product_name │ +│ - 필수: ☑ │ +│ - 읽기전용: ☐ │ +│ │ +│ 크기 & 위치 │ +│ - X: 100px, Y: 200px │ +│ - 너비: 300px, 높이: 40px │ +│ - 컬럼 스팬: 6 │ +│ │ +│ 데이터 연결 │ +│ - 테이블: product_info │ +│ - 컬럼: product_name │ +│ - 웹타입: text │ +│ │ +│ 고급 설정 │ +│ - 조건부 표시: field === 'A' │ +│ - 버튼 액션: [등록], [수정], [삭제] │ +│ - 데이터플로우: flow_123 │ +└─────────────────────────────────────┘ +``` + +### 5.3 저장 구조 + +**ScreenDefinition (JSON)** + +```json +{ + "screenId": 123, + "screenCode": "PRODUCT_MGMT", + "screenName": "제품 관리", + "tableName": "product_info", + "screenType": "form", + "version": 2, + "layoutData": { + "components": [ + { + "id": "comp_text_1", + "type": "text-input", + "componentType": "text-input", + "label": "제품명", + "columnName": "product_name", + "position": { "x": 100, "y": 50, "z": 0 }, + "size": { "width": 300, "height": 40 }, + "componentConfig": { + "required": true, + "placeholder": "제품명을 입력하세요", + "maxLength": 100 + }, + "style": { + "labelDisplay": true, + "labelText": "제품명" + } + }, + { + "id": "comp_table_1", + "type": "table-list", + "componentType": "table-list", + "tableName": "product_info", + "position": { "x": 50, "y": 200, "z": 0 }, + "size": { "width": 800, "height": 400 }, + "componentConfig": { + "columns": ["product_code", "product_name", "price"], + "pagination": true, + "sortable": true + } + } + ], + "layouts": [ + { + "id": "layout_tabs_1", + "layoutType": "tabs", + "children": [ + { "tabId": "tab1", "title": "기본정보", "components": ["comp_text_1"] }, + { "tabId": "tab2", "title": "상세정보", "components": ["comp_table_1"] } + ] + } + ] + }, + "createdBy": "user123", + "createdDate": "2024-01-01T00:00:00Z" +} +``` + +--- + +## 6. API 클라이언트 시스템 + +### 6.1 API 클라이언트 구조 + +**lib/api/** (57개 파일) + +``` +lib/api/ +├── client.ts # Axios 기본 설정 & 인터셉터 +│ +├── 화면 관련 (3개) +│ ├── screen.ts # 화면 CRUD +│ ├── screenGroup.ts # 화면 그룹 +│ └── screenFile.ts # 화면 파일 +│ +├── 사용자 관련 (5개) +│ ├── user.ts # 사용자 관리 +│ ├── role.ts # 역할 관리 +│ ├── company.ts # 회사 관리 +│ └── department.ts # 부서 관리 +│ +├── 테이블 관련 (5개) +│ ├── tableManagement.ts # 테이블 관리 +│ ├── tableSchema.ts # 스키마 조회 +│ ├── tableHistory.ts # 테이블 이력 +│ └── tableCategoryValue.ts # 카테고리 값 +│ +├── 데이터플로우 (6개) +│ ├── dataflow.ts # 데이터플로우 정의 +│ ├── dataflowSave.ts # 저장 로직 +│ ├── nodeFlows.ts # 노드 플로우 +│ ├── nodeExternalConnections.ts # 외부 연결 +│ └── flowExternalDb.ts # 외부 DB 플로우 +│ +├── 자동화 (4개) +│ ├── batch.ts # 배치 작업 +│ ├── batchManagement.ts # 배치 관리 +│ ├── externalCall.ts # 외부 호출 +│ └── externalCallConfig.ts # 외부 호출 설정 +│ +├── 시스템 (8개) +│ ├── menu.ts # 메뉴 관리 +│ ├── commonCode.ts # 공통코드 +│ ├── multilang.ts # 다국어 +│ ├── layout.ts # 레이아웃 +│ ├── collection.ts # 컬렉션 +│ └── ... +│ +└── 기타 (26개) + ├── data.ts # 동적 데이터 CRUD + ├── dynamicForm.ts # 동적 폼 + ├── file.ts # 파일 업로드 + ├── dashboard.ts # 대시보드 + ├── mail.ts # 메일 + ├── reportApi.ts # 리포트 + └── ... +``` + +### 6.2 API 클라이언트 기본 설정 + +**lib/api/client.ts** + +```typescript +// 1. 동적 API URL 설정 +const getApiBaseUrl = (): string => { + // 환경변수 우선 + if (process.env.NEXT_PUBLIC_API_URL) return process.env.NEXT_PUBLIC_API_URL; + + // 프로덕션: v1.vexplor.com → api.vexplor.com + if (currentHost === "v1.vexplor.com") { + return "https://api.vexplor.com/api"; + } + + // 로컬: localhost:9771 → localhost:8080 + return "http://localhost:8080/api"; +}; + +export const API_BASE_URL = getApiBaseUrl(); + +// 2. Axios 인스턴스 생성 +export const apiClient = axios.create({ + baseURL: API_BASE_URL, + timeout: 30000, + headers: { "Content-Type": "application/json" }, + withCredentials: true +}); + +// 3. 요청 인터셉터 (JWT 토큰 자동 추가) +apiClient.interceptors.request.use((config) => { + const token = localStorage.getItem("authToken"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + // 다국어: GET 요청에 userLang 파라미터 추가 + if (config.method === "GET") { + config.params = { + ...config.params, + userLang: window.__GLOBAL_USER_LANG || "KR" + }; + } + + return config; +}); + +// 4. 응답 인터셉터 (토큰 갱신, 401 처리) +apiClient.interceptors.response.use( + (response) => { + // 서버에서 새 토큰 전송 시 자동 갱신 + const newToken = response.headers["x-new-token"]; + if (newToken) { + localStorage.setItem("authToken", newToken); + } + return response; + }, + async (error) => { + // 401 에러: 토큰 갱신 시도 → 실패 시 로그인 페이지 + if (error.response?.status === 401) { + const newToken = await refreshToken(); + if (newToken) { + error.config.headers.Authorization = `Bearer ${newToken}`; + return apiClient.request(error.config); // 재시도 + } + window.location.href = "/login"; + } + return Promise.reject(error); + } +); +``` + +### 6.3 API 사용 패턴 + +#### ✅ 올바른 사용법 (lib/api 클라이언트 사용) + +```typescript +import { screenApi } from "@/lib/api/screen"; +import { dataApi } from "@/lib/api/data"; + +// 화면 목록 조회 +const screens = await screenApi.getScreens({ page: 1, size: 20 }); + +// 데이터 생성 +const result = await dataApi.createData("product_info", { + product_name: "제품A", + price: 10000 +}); +``` + +#### ❌ 잘못된 사용법 (fetch 직접 사용 금지!) + +```typescript +// 🚫 금지! JWT 토큰, 다국어, 에러 처리 누락 +const res = await fetch('/api/screen-management/screens'); +``` + +### 6.4 주요 API 예시 + +#### 화면 관리 API (screen.ts) + +```typescript +export const screenApi = { + // 화면 목록 조회 + getScreens: async (params) => { + const response = await apiClient.get("/screen-management/screens", { params }); + return response.data; + }, + + // 화면 상세 조회 + getScreen: async (screenId: number) => { + const response = await apiClient.get(`/screen-management/screens/${screenId}`); + return response.data.data; + }, + + // 화면 생성 + createScreen: async (screen: CreateScreenRequest) => { + const response = await apiClient.post("/screen-management/screens", screen); + return response.data; + }, + + // 화면 수정 + updateScreen: async (screenId: number, screen: UpdateScreenRequest) => { + const response = await apiClient.put(`/screen-management/screens/${screenId}`, screen); + return response.data; + }, + + // 화면 삭제 + deleteScreen: async (screenId: number) => { + const response = await apiClient.delete(`/screen-management/screens/${screenId}`); + return response.data; + } +}; +``` + +#### 동적 데이터 API (data.ts) + +```typescript +export const dataApi = { + // 데이터 목록 조회 (페이징, 필터링, 정렬) + getDataList: async (tableName: string, params: { + page?: number; + size?: number; + filters?: Record; + sortBy?: string; + sortOrder?: "asc" | "desc"; + }) => { + const response = await apiClient.get(`/data/${tableName}`, { params }); + return response.data; + }, + + // 데이터 생성 + createData: async (tableName: string, data: Record) => { + const response = await apiClient.post(`/data/${tableName}`, data); + return response.data; + }, + + // 데이터 수정 + updateData: async (tableName: string, id: number, data: Record) => { + const response = await apiClient.put(`/data/${tableName}/${id}`, data); + return response.data; + }, + + // 데이터 삭제 + deleteData: async (tableName: string, id: number) => { + const response = await apiClient.delete(`/data/${tableName}/${id}`); + return response.data; + } +}; +``` + +--- + +## 7. 상태 관리 + +### 7.1 상태 관리 전략 + +WACE ERP는 **하이브리드 상태 관리 전략**을 사용합니다: + +| 관리 방식 | 사용 시나리오 | 예시 | +|----------|-------------|------| +| **React Query** | 서버 상태 (캐싱, 자동 갱신) | 화면 목록, 데이터 목록 | +| **React Context** | 전역 상태 (공유 데이터) | 인증, 메뉴, 화면 | +| **Zustand** | 클라이언트 상태 (간단한 전역) | 플로우 단계, 모달 데이터 | +| **Local State** | 컴포넌트 로컬 상태 | 폼 입력, UI 토글 | + +### 7.2 React Context (12개) + +``` +contexts/ +├── AuthContext.tsx # 인증 상태 & 세션 관리 +│ - 사용자 정보 +│ - 로그인/로그아웃 +│ - 세션 타이머 (30분) +│ +├── MenuContext.tsx # 메뉴 트리 & 네비게이션 +│ - 메뉴 구조 +│ - 현재 선택된 메뉴 +│ - 메뉴 접근 권한 +│ +├── ScreenContext.tsx # 화면 편집 상태 +│ - 현재 편집 중인 화면 +│ - 선택된 컴포넌트 +│ - 실행 취소/다시 실행 +│ +├── ScreenPreviewContext.tsx # 화면 미리보기 +│ - 반응형 모드 (데스크톱/태블릿/모바일) +│ - 미리보기 데이터 +│ +├── DashboardContext.tsx # 대시보드 상태 +│ - 대시보드 레이아웃 +│ - 위젯 설정 +│ +├── TableOptionsContext.tsx # 테이블 옵션 +│ - 컬럼 순서 +│ - 필터 +│ - 정렬 +│ +├── ActiveTabContext.tsx # 탭 활성화 상태 +├── LayerContext.tsx # 레이어 관리 +├── ReportDesignerContext.tsx # 리포트 디자이너 +├── ScreenMultiLangContext.tsx # 화면 다국어 +├── SplitPanelContext.tsx # 분할 패널 상태 +└── TableSearchWidgetHeightContext.tsx # 테이블 검색 높이 +``` + +### 7.3 React Query 설정 + +**providers/QueryProvider.tsx** + +```typescript +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5분간 fresh + cacheTime: 10 * 60 * 1000, // 10분간 캐시 유지 + refetchOnWindowFocus: false, // 창 포커스 시 자동 갱신 비활성화 + retry: 1, // 실패 시 1회 재시도 + }, + }, +}); + +export function QueryProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +``` + +**사용 예시** + +```typescript +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { screenApi } from "@/lib/api/screen"; + +// 화면 목록 조회 (자동 캐싱) +const { data: screens, isLoading, error } = useQuery({ + queryKey: ["screens", { page: 1 }], + queryFn: () => screenApi.getScreens({ page: 1, size: 20 }) +}); + +// 화면 생성 (생성 후 목록 자동 갱신) +const queryClient = useQueryClient(); +const createMutation = useMutation({ + mutationFn: (screen: CreateScreenRequest) => screenApi.createScreen(screen), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["screens"] }); + } +}); +``` + +### 7.4 Zustand 스토어 (3개) + +``` +stores/ +├── flowStepStore.ts # 플로우 단계 상태 +│ - 현재 단계 +│ - 단계별 데이터 +│ - 진행률 +│ +├── modalDataStore.ts # 모달 데이터 공유 +│ - 모달 ID별 데이터 +│ - 모달 간 데이터 전달 +│ +└── tableDisplayStore.ts # 테이블 표시 상태 + - 선택된 행 + - 정렬 상태 + - 페이지네이션 +``` + +**사용 예시** + +```typescript +import { create } from "zustand"; + +interface ModalDataStore { + modalData: Record; + setModalData: (modalId: string, data: any) => void; + getModalData: (modalId: string) => any; +} + +export const useModalDataStore = create((set, get) => ({ + modalData: {}, + + setModalData: (modalId, data) => { + set((state) => ({ + modalData: { ...state.modalData, [modalId]: data } + })); + }, + + getModalData: (modalId) => { + return get().modalData[modalId]; + } +})); +``` + +--- + +## 8. 레지스트리 시스템 + +레지스트리 시스템은 **컴포넌트를 동적으로 등록하고 렌더링**하는 핵심 아키텍처입니다. + +### 8.1 레지스트리 종류 + +``` +lib/registry/ +├── ComponentRegistry.ts # 컴포넌트 레지스트리 (신규) +│ - 90개+ 컴포넌트 관리 +│ - 자동 등록 시스템 +│ - 핫 리로드 지원 +│ +├── WebTypeRegistry.ts # 웹타입 레지스트리 (레거시) +│ - text, number, date 등 기본 웹타입 +│ - 위젯 컴포넌트 + 설정 패널 +│ +└── LayoutRegistry.ts # 레이아웃 레지스트리 + - grid, tabs, accordion 등 레이아웃 +``` + +### 8.2 ComponentRegistry 상세 + +**등록 프로세스** + +```typescript +// 1. 컴포넌트 정의 (lib/registry/components/v2-input/index.ts) +export const V2InputDefinition: ComponentDefinition = { + id: "v2-input", + name: "통합 입력", + category: ComponentCategory.V2, + component: V2InputRenderer, + configPanel: V2InputConfigPanel, + defaultSize: { width: 200, height: 40 }, + defaultConfig: { inputType: "text", format: "none" } +}; + +// 2. 자동 등록 렌더러 (lib/registry/components/v2-input/V2InputRenderer.tsx) +export class V2InputRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2InputDefinition; + + render(): React.ReactElement { + return ; + } +} + +// 자동 등록 실행 +V2InputRenderer.registerSelf(); + +// 3. 레지스트리 조회 & 렌더링 (DynamicComponentRenderer.tsx) +const newComponent = ComponentRegistry.getComponent("v2-input"); +if (newComponent) { + const Renderer = newComponent.component; + return ; +} +``` + +### 8.3 등록된 컴포넌트 목록 (90개+) + +**입력 컴포넌트 (25개)** +- text-input, number-input, date-input +- select-basic, checkbox-basic, radio-basic, toggle-switch +- textarea-basic, slider-basic +- v2-input, v2-select, v2-date + +**표시 컴포넌트 (15개)** +- text-display, image-display, card-display +- table-list, v2-table-list, pivot-grid +- aggregation-widget, v2-aggregation-widget + +**레이아웃 컴포넌트 (10개)** +- grid-layout, flexbox-layout +- tabs-widget, accordion-basic +- split-panel-layout, v2-split-panel-layout +- section-card, section-paper +- v2-divider-line + +**비즈니스 컴포넌트 (20개)** +- entity-search-input, autocomplete-search-input +- modal-repeater-table, repeat-screen-modal +- selected-items-detail-input, simple-repeater-table +- repeater-field-group, repeat-container +- category-manager, v2-category-manager +- numbering-rule, v2-numbering-rule +- rack-structure, v2-rack-structure +- location-swap-selector, customer-item-mapping + +**특수 컴포넌트 (20개)** +- button-primary, v2-button-primary +- file-upload, v2-file-upload +- mail-recipient-selector +- conditional-container, universal-form-modal +- related-data-buttons +- flow-widget +- map + +### 8.4 DynamicComponentRenderer 동작 원리 + +```typescript +// DynamicComponentRenderer.tsx (770줄) + +export const DynamicComponentRenderer: React.FC = ({ component, ...props }) => { + // 1. 컴포넌트 타입 추출 + const componentType = component.componentType || component.type; + + // 2. 레거시 → V2 자동 매핑 + const v2Type = componentType.startsWith("v2-") + ? componentType + : ComponentRegistry.hasComponent(`v2-${componentType}`) + ? `v2-${componentType}` + : componentType; + + // 3. 조건부 렌더링 체크 + if (component.conditionalConfig?.enabled) { + const conditionMet = evaluateCondition(props.formData); + if (!conditionMet) return null; + } + + // 4. 카테고리 타입 우선 처리 + if (component.inputType === "category" || component.webType === "category") { + return ; + } + + // 5. 레이아웃 컴포넌트 + if (componentType === "layout") { + return ; + } + + // 6. ComponentRegistry에서 조회 (신규) + const newComponent = ComponentRegistry.getComponent(v2Type); + if (newComponent) { + const Renderer = newComponent.component; + // 클래스 기반: new Renderer(props).render() + // 함수형: + return isClass(Renderer) + ? new Renderer(props).render() + : ; + } + + // 7. LegacyComponentRegistry에서 조회 (레거시) + const legacyRenderer = legacyComponentRegistry.get(componentType); + if (legacyRenderer) { + return legacyRenderer({ component, ...props }); + } + + // 8. 폴백: 미등록 컴포넌트 플레이스홀더 + return ; +}; +``` + +--- + +## 9. 대시보드 시스템 + +### 9.1 대시보드 구조 + +``` +components/dashboard/ +├── DashboardViewer.tsx # 대시보드 렌더링 +├── DashboardGrid.tsx # 그리드 레이아웃 +├── DashboardWidget.tsx # 위젯 래퍼 +│ +├── widgets/ # 위젯 컴포넌트 +│ ├── ChartWidget.tsx # 차트 위젯 +│ ├── TableWidget.tsx # 테이블 위젯 +│ ├── CardWidget.tsx # 카드 위젯 +│ ├── StatWidget.tsx # 통계 위젯 +│ └── CustomWidget.tsx # 커스텀 위젯 +│ +└── charts/ # 차트 컴포넌트 + ├── LineChart.tsx + ├── BarChart.tsx + ├── PieChart.tsx + ├── DonutChart.tsx + └── AreaChart.tsx +``` + +### 9.2 대시보드 JSON 구조 + +```json +{ + "dashboardId": 1, + "dashboardName": "영업 대시보드", + "layout": { + "type": "grid", + "columns": 12, + "rows": 6, + "gap": 16 + }, + "widgets": [ + { + "widgetId": "widget_1", + "widgetType": "chart", + "chartType": "line", + "title": "월별 매출 추이", + "position": { "x": 0, "y": 0, "w": 6, "h": 3 }, + "dataSource": { + "type": "api", + "endpoint": "/api/data/sales_monthly", + "filters": { "year": 2024 } + }, + "chartConfig": { + "xAxis": "month", + "yAxis": "sales_amount", + "showLegend": true + } + }, + { + "widgetId": "widget_2", + "widgetType": "stat", + "title": "총 매출", + "position": { "x": 6, "y": 0, "w": 3, "h": 2 }, + "dataSource": { + "type": "sql", + "query": "SELECT SUM(amount) FROM sales WHERE year = 2024" + }, + "statConfig": { + "format": "currency", + "comparison": "lastYear" + } + } + ] +} +``` + +### 9.3 대시보드 라우팅 + +``` +/dashboard # 대시보드 목록 +/dashboard/[dashboardId] # 대시보드 보기 +/admin/screenMng/dashboardList # 대시보드 관리 (편집) +``` + +--- + +## 10. 다국어 지원 + +### 10.1 다국어 시스템 구조 + +WACE ERP는 **동적 다국어 시스템**을 제공합니다 (DB 기반): + +``` +다국어 흐름 + ↓ +1. 사용자 로그인 + ↓ +2. 사용자 로케일 조회 (GET /api/admin/user-locale) + → 결과: "KR" | "EN" | "CN" + ↓ +3. 전역 상태에 저장 + - window.__GLOBAL_USER_LANG + - localStorage.userLocale + ↓ +4. API 요청 시 자동 주입 + - GET 요청: ?userLang=KR (apiClient 인터셉터) + ↓ +5. 백엔드에서 다국어 데이터 반환 + - label_KR, label_EN, label_CN + ↓ +6. 프론트엔드에서 표시 + - extractMultilangLabel(label, "KR") +``` + +### 10.2 다국어 API + +**lib/api/multilang.ts** + +```typescript +export const multilangApi = { + // 다국어 데이터 조회 + getMultilangData: async (params: { + target_table: string; + target_pk: string; + target_lang: string; + }) => { + const response = await apiClient.get("/admin/multilang", { params }); + return response.data; + }, + + // 다국어 데이터 저장 + saveMultilangData: async (data: { + target_table: string; + target_pk: string; + target_field: string; + target_lang: string; + translated_text: string; + }) => { + const response = await apiClient.post("/admin/multilang", data); + return response.data; + } +}; +``` + +### 10.3 다국어 유틸리티 + +**lib/utils/multilang.ts** + +```typescript +// 다국어 라벨 추출 +export function extractMultilangLabel( + label: string | Record | undefined, + locale: string = "KR" +): string { + if (!label) return ""; + + // 문자열인 경우 그대로 반환 + if (typeof label === "string") return label; + + // 객체인 경우 로케일에 맞는 값 반환 + return label[locale] || label["KR"] || label["EN"] || ""; +} + +// 화면 컴포넌트 라벨 추출 +export function getComponentLabel(component: ComponentData, locale: string): string { + // 우선순위: multiLangLabel > label > columnName > id + if (component.multiLangLabel) { + return extractMultilangLabel(component.multiLangLabel, locale); + } + return component.label || component.columnName || component.id; +} +``` + +### 10.4 화면 디자이너 다국어 탭 + +``` +ScreenDesigner + ↓ +┌───────────────────────────────────────────────────┐ +│ 탭: 다국어 설정 │ +├───────────────────────────────────────────────────┤ +│ │ +│ 화면명 다국어 │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 한국어(KR): 제품 관리 │ │ +│ │ 영어(EN): Product Management │ │ +│ │ 중국어(CN): 产品管理 │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ 컴포넌트별 다국어 │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ [comp_text_1] 제품명 │ │ +│ │ 한국어(KR): 제품명 │ │ +│ │ 영어(EN): Product Name │ │ +│ │ 중국어(CN): 产品名称 │ │ +│ │ │ │ +│ │ [comp_text_2] 가격 │ │ +│ │ 한국어(KR): 가격 │ │ +│ │ 영어(EN): Price │ │ +│ │ 중국어(CN): 价格 │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ [자동 번역] [일괄 적용] [저장] │ +└───────────────────────────────────────────────────┘ +``` + +--- + +## 11. 인증 플로우 + +### 11.1 인증 아키텍처 + +``` +┌─────────────────────────────────────────────────────┐ +│ Frontend │ +├─────────────────────────────────────────────────────┤ +│ │ +│ useAuth Hook │ +│ ├─ login(userId, password) │ +│ ├─ logout() │ +│ ├─ refreshUserData() │ +│ ├─ checkAuthStatus() │ +│ └─ switchCompany(companyCode) ⭐ 신규 │ +│ │ +│ AuthContext Provider │ +│ ├─ SessionManager (30분 타임아웃) │ +│ ├─ Session Warning (5분 전 알림) │ +│ └─ Auto Refresh (활동 감지) │ +│ │ +│ TokenManager │ +│ ├─ getToken(): localStorage.authToken │ +│ ├─ setToken(token): 저장 + 쿠키 설정 │ +│ ├─ removeToken(): 삭제 + 쿠키 삭제 │ +│ └─ isTokenExpired(token): JWT 검증 │ +│ │ +│ API Client Interceptor │ +│ ├─ Request: JWT 토큰 자동 추가 │ +│ └─ Response: 401 시 토큰 갱신 또는 로그아웃 │ +│ │ +└─────────────────────────────────────────────────────┘ + │ + │ HTTP Request + │ Authorization: Bearer + ↓ +┌─────────────────────────────────────────────────────┐ +│ Backend (8080) │ +├─────────────────────────────────────────────────────┤ +│ │ +│ POST /api/auth/login │ +│ → JWT 토큰 발급 (24시간) │ +│ │ +│ POST /api/auth/refresh │ +│ → JWT 토큰 갱신 │ +│ │ +│ POST /api/auth/logout │ +│ → 세션 무효화 │ +│ │ +│ GET /api/auth/me │ +│ → 현재 사용자 정보 │ +│ │ +│ GET /api/auth/status │ +│ → 인증 상태 확인 │ +│ │ +│ POST /api/auth/switch-company ⭐ 신규 │ +│ → 회사 전환 (WACE 관리자 전용) │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +### 11.2 로그인 흐름 + +``` +1. 사용자가 ID/PW 입력 → /login 페이지 + ↓ +2. POST /api/auth/login { userId, password } + ↓ +3. 백엔드 검증 (DB: user_info 테이블) + ├─ 성공: JWT 토큰 발급 (payload: userId, companyCode, isAdmin, exp) + └─ 실패: 401 에러 + ↓ +4. 프론트엔드: 토큰 저장 + - localStorage.setItem("authToken", token) + - document.cookie = "authToken=..." + ↓ +5. 사용자 정보 조회 + - GET /api/auth/me + - GET /api/admin/user-locale + ↓ +6. 전역 상태 업데이트 + - AuthContext.user + - window.__GLOBAL_USER_LANG + ↓ +7. 메인 페이지로 리다이렉트 (/main) +``` + +### 11.3 세션 관리 + +**SessionManager (lib/sessionManager.ts)** + +```typescript +// 설정 +{ + checkInterval: 60000, // 1분마다 체크 + maxInactiveTime: 1800000, // 30분 (데스크톱) + warningTime: 300000, // 5분 전 경고 +} + +// 이벤트 +{ + onWarning: (remainingTime) => { + // "세션이 5분 후 만료됩니다" 알림 표시 + }, + onExpiry: () => { + // 자동 로그아웃 → 로그인 페이지 + }, + onActivity: () => { + // 사용자 활동 감지 → 타이머 리셋 + } +} +``` + +### 11.4 토큰 갱신 전략 + +``` +자동 토큰 갱신 트리거: +1. 10분마다 토큰 상태 확인 (타이머) +2. 사용자 활동 감지 (클릭, 키보드, 스크롤) +3. API 401 응답 (토큰 만료) + +갱신 로직: + ↓ +1. 현재 토큰 만료까지 30분 미만? + ↓ Yes +2. POST /api/auth/refresh + ├─ 성공: 새 토큰 저장 + └─ 실패: 로그아웃 +``` + +### 11.5 회사 전환 (WACE 관리자 전용) ⭐ + +``` +1. WACE 관리자 로그인 + ↓ +2. 헤더에서 회사 선택 (CompanySwitcher) + ↓ +3. POST /api/auth/switch-company { companyCode: "AAA" } + ↓ +4. 백엔드: 새 JWT 발급 (payload.companyCode = "AAA") + ↓ +5. 프론트엔드: 새 토큰 저장 + ↓ +6. 페이지 새로고침 → 화면/데이터가 AAA 회사 기준으로 표시 +``` + +--- + +## 12. 사용자 워크플로우 + +### 12.1 전체 워크플로우 개요 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 관리자 (화면 생성) │ +└──────────────────────────────────────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 1. 로그인 (WACE 관리자) │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 2. 화면 디자이너 접속 │ + │ /admin/screenMng/ │ + │ screenMngList │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 3. 화면 생성 & 편집 │ + │ - 컴포넌트 배치 │ + │ - 데이터 연결 │ + │ - 버튼 액션 설정 │ + │ - 다국어 설정 │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 4. 메뉴에 화면 할당 │ + │ /admin/menu │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 5. 사용자에게 권한 부여 │ + │ /admin/userMng/ │ + │ userAuthList │ + └─────────────────────────────┘ + │ + ↓ +┌──────────────────────────────────────────────────────────────┐ +│ 사용자 (화면 사용) │ +└──────────────────────────────────────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 1. 로그인 (일반 사용자) │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 2. 메뉴에서 화면 선택 │ + │ 사이드바 → 제품 관리 │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 3. 화면 렌더링 │ + │ /screens/[screenId] │ + │ InteractiveScreenViewer │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 4. 데이터 조회/등록/수정 │ + │ - 테이블에서 행 선택 │ + │ - 폼에 데이터 입력 │ + │ - [등록]/[수정]/[삭제] 버튼│ + └─────────────────────────────┘ +``` + +### 12.2 관리자 워크플로우 (화면 생성) + +#### 단계 1: 화면 디자이너 접속 + +``` +/admin/screenMng/screenMngList + ↓ +[새 화면 만들기] 버튼 클릭 + ↓ +ScreenDesigner 컴포넌트 로드 +``` + +#### 단계 2: 화면 디자인 + +**2-1. 기본 정보 설정** + +``` +화면 정보 입력: +- 화면 코드: PRODUCT_MGMT +- 화면명: 제품 관리 +- 테이블명: product_info +- 화면 타입: form (단일 폼) | list (목록) +``` + +**2-2. 컴포넌트 배치** + +``` +좌측 팔레트에서 컴포넌트 선택 + ↓ +캔버스에 드래그&드롭 + ↓ +위치/크기 조정 (10px 단위 스냅) + ↓ +우측 속성 패널에서 설정: + - 컬럼명: product_name + - 라벨: 제품명 + - 필수: ☑ + - 웹타입: text +``` + +**2-3. 버튼 액션 설정** + +``` +버튼 컴포넌트 추가 + ↓ +액션 타입 선택: + - 데이터 저장 (POST) + - 데이터 수정 (PUT) + - 데이터 삭제 (DELETE) + - 데이터플로우 실행 + - 외부 API 호출 + - 화면 이동 + ↓ +액션 설정: + - 대상 테이블: product_info + - 성공 시: 목록 새로고침 + - 실패 시: 에러 메시지 표시 +``` + +**2-4. 다국어 설정** + +``` +다국어 탭 클릭 + ↓ +컴포넌트별 다국어 입력: + - 한국어(KR): 제품명 + - 영어(EN): Product Name + - 중국어(CN): 产品名称 +``` + +**2-5. 저장** + +``` +[저장] 버튼 클릭 + ↓ +POST /api/screen-management/screens + ↓ +화면 정의 JSON 저장 (DB: screen_definition) +``` + +#### 단계 3: 메뉴에 화면 할당 + +``` +/admin/menu + ↓ +메뉴 트리에서 위치 선택 + ↓ +[화면 할당] 버튼 클릭 + ↓ +방금 생성한 화면 선택 (PRODUCT_MGMT) + ↓ +저장 → menu_screen 테이블에 연결 +``` + +#### 단계 4: 권한 부여 + +``` +/admin/userMng/userAuthList + ↓ +사용자 선택 + ↓ +메뉴 권한 설정 + ↓ +"제품 관리" 메뉴 체크 + ↓ +저장 → user_menu_auth 테이블 +``` + +### 12.3 사용자 워크플로우 (화면 사용) + +#### 단계 1: 로그인 + +``` +/login + ↓ +사용자 ID/PW 입력 + ↓ +POST /api/auth/login + ↓ +JWT 토큰 발급 + ↓ +메인 페이지 (/main) +``` + +#### 단계 2: 메뉴 선택 + +``` +좌측 사이드바 메뉴 트리 + ↓ +"제품 관리" 메뉴 클릭 + ↓ +MenuContext에서 메뉴 정보 조회: + - menuId, screenId, screenCode + ↓ +화면 뷰어로 이동 (/screens/[screenId]) +``` + +#### 단계 3: 화면 렌더링 + +``` +InteractiveScreenViewer 컴포넌트 로드 + ↓ +1. GET /api/screen-management/screens/[screenId] + → 화면 정의 JSON 조회 + ↓ +2. GET /api/data/product_info?page=1&size=20 + → 데이터 조회 + ↓ +3. DynamicComponentRenderer로 컴포넌트 렌더링 + ↓ +4. 폼 데이터 바인딩 (formData 상태) +``` + +#### 단계 4: 데이터 조작 + +**4-1. 신규 등록** + +``` +[등록] 버튼 클릭 + ↓ +빈 폼 표시 (ScreenModal 또는 EditModal) + ↓ +사용자가 데이터 입력: + - 제품명: "제품A" + - 가격: 10000 + ↓ +[저장] 버튼 클릭 + ↓ +버튼 액션 실행: + - POST /api/data/product_info + - Body: { product_name: "제품A", price: 10000 } + ↓ +성공 응답 + ↓ +목록 새로고침 (React Query invalidateQueries) +``` + +**4-2. 수정** + +``` +테이블에서 행 선택 (클릭) + ↓ +선택된 데이터 → selectedRowsData 상태 업데이트 + ↓ +[수정] 버튼 클릭 + ↓ +폼에 기존 데이터 표시 (EditModal) + ↓ +사용자가 데이터 수정: + - 가격: 10000 → 12000 + ↓ +[저장] 버튼 클릭 + ↓ +버튼 액션 실행: + - PUT /api/data/product_info/123 + - Body: { price: 12000 } + ↓ +목록 새로고침 +``` + +**4-3. 삭제** + +``` +테이블에서 행 선택 + ↓ +[삭제] 버튼 클릭 + ↓ +확인 다이얼로그 표시 + ↓ +확인 클릭 + ↓ +버튼 액션 실행: + - DELETE /api/data/product_info/123 + ↓ +목록 새로고침 +``` + +### 12.4 데이터플로우 실행 워크플로우 + +``` +1. 관리자가 데이터플로우 정의 + /admin/systemMng/dataflow + ↓ +2. 화면에 버튼 추가 & 플로우 연결 + 버튼 액션: "데이터플로우 실행" + 플로우 ID: flow_123 + ↓ +3. 사용자가 버튼 클릭 + ↓ +4. 프론트엔드: POST /api/dataflow/execute + Body: { flowId: 123, inputData: {...} } + ↓ +5. 백엔드: 플로우 실행 + - 노드 순회 + - 조건 분기 + - 외부 API 호출 + - 데이터 변환 + ↓ +6. 결과 반환 + ↓ +7. 프론트엔드: 결과 표시 (toast 또는 화면 갱신) +``` + +--- + +## 13. 요약 & 핵심 포인트 + +### 13.1 아키텍처 핵심 + +1. **Next.js 14 App Router** - 라우트 그룹 기반 구조화 +2. **컴포넌트 레지스트리** - 동적 등록/렌더링 시스템 +3. **V2 통합 시스템** - 9개 통합 컴포넌트로 단순화 +4. **화면 디자이너** - 노코드 화면 생성 도구 +5. **API 클라이언트** - Axios 기반 통일된 API 호출 +6. **다국어 지원** - DB 기반 동적 다국어 +7. **JWT 인증** - 토큰 기반 인증/세션 관리 + +### 13.2 파일 통계 + +| 항목 | 개수 | +|------|------| +| 총 파일 수 | 1,480개 | +| TypeScript/TSX | 1,395개 (946 tsx + 449 ts) | +| Markdown 문서 | 63개 | +| CSS | 22개 | +| 페이지 (라우트) | 76개 | +| 컴포넌트 | 500개+ | +| API 클라이언트 | 57개 | +| Context | 12개 | +| Custom Hooks | 32개 | +| 타입 정의 | 44개 | + +### 13.3 주요 경로 + +``` +화면 디자이너: /admin/screenMng/screenMngList +화면 뷰어: /screens/[screenId] +메뉴 관리: /admin/menu +사용자 관리: /admin/userMng/userMngList +테이블 관리: /admin/systemMng/tableMngList +다국어 관리: /admin/systemMng/i18nList +데이터플로우: /admin/systemMng/dataflow +대시보드: /dashboard/[dashboardId] +``` + +### 13.4 기술 스택 + +| 분류 | 기술 | +|------|------| +| 프레임워크 | Next.js 14 | +| UI 라이브러리 | React 18 | +| 언어 | TypeScript (strict mode) | +| 스타일링 | Tailwind CSS + shadcn/ui | +| 상태 관리 | React Query + Zustand + Context API | +| HTTP 클라이언트 | Axios | +| 폼 검증 | Zod | +| 날짜 처리 | date-fns | +| 아이콘 | lucide-react | +| 알림 | sonner | + +--- + +## 마무리 + +이 문서는 WACE ERP 프론트엔드의 전체 아키텍처를 분석한 결과입니다. + +**핵심 인사이트:** +- ✅ 높은 수준의 모듈화 (레지스트리 시스템) +- ✅ 노코드 화면 디자이너 (관리자가 직접 화면 생성) +- ✅ V2 통합 컴포넌트 시스템 (개발 효율성 향상) +- ✅ 동적 다국어 지원 (DB 기반) +- ✅ 완전한 TypeScript 타입 안정성 + +**개선 기회:** +- 일부 레거시 컴포넌트의 V2 마이그레이션 +- 테스트 코드 추가 (현재 거의 없음) +- 성능 최적화 (코드 스플리팅, 레이지 로딩) + +--- + +**작성자 노트** + +야... 진짜 엄청난 프로젝트네. 파일이 1,500개가 넘고 컴포넌트만 500개야. 특히 화면 디자이너가 7,000줄이 넘는 거 보고 놀랐어. 이 정도 규모면 엔터프라이즈급 ERP 시스템이라고 봐도 되겠어. + +가장 인상적이었던 건 **레지스트리 시스템**이랑 **V2 통합 아키텍처**야. 컴포넌트를 동적으로 등록하고 렌더링하는 구조가 꽤 잘 설계되어 있어. 다만 레거시 코드가 아직 많이 남아있어서 V2로 완전히 전환하면 코드 베이스가 훨씬 깔끔해질 것 같아. + +어쨌든 분석하느라 꽤 걸렸는데, 이 문서가 전체 워크플로우 문서 작성하는 데 도움이 되면 좋겠어! 🎉 diff --git a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx new file mode 100644 index 00000000..d9e289ca --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Plus, + RefreshCw, + Search, + Smartphone, + Eye, + Settings, + LayoutGrid, + GitBranch, +} from "lucide-react"; +import { PopDesigner } from "@/components/pop/designer"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; +import { ScreenDefinition } from "@/types/screen"; +import { screenApi } from "@/lib/api/screen"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import CreateScreenModal from "@/components/screen/CreateScreenModal"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { + PopCategoryTree, + PopScreenPreview, + PopScreenFlowView, + PopScreenSettingModal, +} from "@/components/pop/management"; +import { PopScreenGroup } from "@/lib/api/popScreenGroup"; + +// ============================================================ +// 타입 정의 +// ============================================================ + +type Step = "list" | "design"; +type DevicePreview = "mobile" | "tablet"; +type RightPanelView = "preview" | "flow"; + +// ============================================================ +// 메인 컴포넌트 +// ============================================================ + +export default function PopScreenManagementPage() { + const searchParams = useSearchParams(); + + // 단계 및 화면 상태 + const [currentStep, setCurrentStep] = useState("list"); + const [selectedScreen, setSelectedScreen] = useState(null); + const [selectedGroup, setSelectedGroup] = useState(null); + const [stepHistory, setStepHistory] = useState(["list"]); + + // 화면 데이터 + const [screens, setScreens] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + + // POP 레이아웃 존재 화면 ID + const [popLayoutScreenIds, setPopLayoutScreenIds] = useState>(new Set()); + + // UI 상태 + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); + const [devicePreview, setDevicePreview] = useState("tablet"); + const [rightPanelView, setRightPanelView] = useState("preview"); + + // ============================================================ + // 데이터 로드 + // ============================================================ + + const loadScreens = useCallback(async () => { + try { + setLoading(true); + const [result, popScreenIds] = await Promise.all([ + screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" }), + screenApi.getScreenIdsWithPopLayout(), + ]); + + if (result.data && result.data.length > 0) { + setScreens(result.data); + } + setPopLayoutScreenIds(new Set(popScreenIds)); + } catch (error) { + console.error("POP 화면 목록 로드 실패:", error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadScreens(); + }, [loadScreens]); + + // 화면 목록 새로고침 이벤트 리스너 + useEffect(() => { + const handleScreenListRefresh = () => { + console.log("POP 화면 목록 새로고침 이벤트 수신"); + loadScreens(); + }; + + window.addEventListener("screen-list-refresh", handleScreenListRefresh); + return () => { + window.removeEventListener("screen-list-refresh", handleScreenListRefresh); + }; + }, [loadScreens]); + + // URL 쿼리 파라미터로 화면 디자이너 자동 열기 + useEffect(() => { + const openDesignerId = searchParams.get("openDesigner"); + if (openDesignerId && screens.length > 0) { + const screenId = parseInt(openDesignerId, 10); + const targetScreen = screens.find((s) => s.screenId === screenId); + if (targetScreen) { + setSelectedScreen(targetScreen); + setCurrentStep("design"); + setStepHistory(["list", "design"]); + } + } + }, [searchParams, screens]); + + // ============================================================ + // 핸들러 + // ============================================================ + + const goToNextStep = (nextStep: Step) => { + setStepHistory((prev) => [...prev, nextStep]); + setCurrentStep(nextStep); + }; + + const goToStep = (step: Step) => { + setCurrentStep(step); + const stepIndex = stepHistory.findIndex((s) => s === step); + if (stepIndex !== -1) { + setStepHistory(stepHistory.slice(0, stepIndex + 1)); + } + }; + + // 화면 선택 + const handleScreenSelect = (screen: ScreenDefinition) => { + setSelectedScreen(screen); + setSelectedGroup(null); + }; + + // 그룹 선택 + const handleGroupSelect = (group: PopScreenGroup | null) => { + setSelectedGroup(group); + // 그룹 선택 시 화면 선택 해제하지 않음 (미리보기 유지) + }; + + // 화면 디자인 모드 진입 + const handleDesignScreen = (screen: ScreenDefinition) => { + setSelectedScreen(screen); + goToNextStep("design"); + }; + + // POP 화면 미리보기 (새 탭에서 열기) + const handlePreviewScreen = (screen: ScreenDefinition) => { + const previewUrl = `/pop/screens/${screen.screenId}?preview=true&device=${devicePreview}`; + window.open(previewUrl, "_blank", "width=800,height=900"); + }; + + // 화면 설정 모달 열기 + const handleOpenSettings = () => { + if (selectedScreen) { + setIsSettingModalOpen(true); + } + }; + + // ============================================================ + // 필터링된 데이터 + // ============================================================ + + // POP 레이아웃이 있는 화면만 필터링 + const popScreens = screens.filter((screen) => popLayoutScreenIds.has(screen.screenId)); + + // 검색어 필터링 + const filteredScreens = popScreens.filter((screen) => { + if (!searchTerm) return true; + return ( + screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || + screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }); + + const popScreenCount = popLayoutScreenIds.size; + + // ============================================================ + // 디자인 모드 + // ============================================================ + + const isDesignMode = currentStep === "design"; + + if (isDesignMode && selectedScreen) { + return ( +
+ goToStep("list")} + onScreenUpdate={(updatedFields) => { + setSelectedScreen({ + ...selectedScreen, + ...updatedFields, + }); + }} + /> +
+ ); + } + + // ============================================================ + // 목록 모드 렌더링 + // ============================================================ + + return ( +
+ {/* 페이지 헤더 */} +
+
+
+
+
+

POP 화면 관리

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

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

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

POP 화면이 없습니다

+

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

+ +
+ ) : ( +
+ {/* 왼쪽: 카테고리 트리 + 화면 목록 */} +
+ {/* 검색 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9 h-9" + /> +
+
+ POP 화면 + + {popScreenCount}개 + +
+
+ + {/* 카테고리 트리 */} + +
+ + {/* 오른쪽: 미리보기 / 화면 흐름 */} +
+ {/* 오른쪽 패널 헤더 */} +
+ setRightPanelView(v as RightPanelView)}> + + + + 미리보기 + + + + 화면 흐름 + + + + + {selectedScreen && ( +
+ + + +
+ )} +
+ + {/* 오른쪽 패널 콘텐츠 */} +
+ {rightPanelView === "preview" ? ( + + ) : ( + + )} +
+
+
+ )} + + {/* 화면 생성 모달 */} + { + setIsCreateOpen(open); + if (!open) loadScreens(); + }} + onCreated={() => { + setIsCreateOpen(false); + loadScreens(); + }} + isPop={true} + /> + + {/* 화면 설정 모달 */} + { + if (selectedScreen) { + setSelectedScreen({ ...selectedScreen, ...updatedFields }); + } + loadScreens(); + }} + /> + + {/* Scroll to Top 버튼 */} + +
+ ); +} diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 17c52897..cf89df73 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -19,6 +19,7 @@ import { Copy, Check, ChevronsUpDown, + Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; @@ -140,11 +141,22 @@ export default function TableManagementPage() { const [logViewerOpen, setLogViewerOpen] = useState(false); const [logViewerTableName, setLogViewerTableName] = useState(""); + // 저장 중 상태 (중복 실행 방지) + const [isSaving, setIsSaving] = useState(false); + // 테이블 삭제 확인 다이얼로그 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableToDelete, setTableToDelete] = useState(""); const [isDeleting, setIsDeleting] = useState(false); + // PK/인덱스 관리 상태 + const [constraints, setConstraints] = useState<{ + primaryKey: { name: string; columns: string[] }; + indexes: Array<{ name: string; columns: string[]; isUnique: boolean }>; + }>({ primaryKey: { name: "", columns: [] }, indexes: [] }); + const [pkDialogOpen, setPkDialogOpen] = useState(false); + const [pendingPkColumns, setPendingPkColumns] = useState([]); + // 선택된 테이블 목록 (체크박스) const [selectedTableIds, setSelectedTableIds] = useState>(new Set()); @@ -397,6 +409,19 @@ export default function TableManagementPage() { } }, []); + // PK/인덱스 제약조건 로드 + const loadConstraints = useCallback(async (tableName: string) => { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/constraints`); + if (response.data.success) { + setConstraints(response.data.data); + } + } catch (error) { + console.error("제약조건 로드 실패:", error); + setConstraints({ primaryKey: { name: "", columns: [] }, indexes: [] }); + } + }, []); + // 테이블 선택 const handleTableSelect = useCallback( (tableName: string) => { @@ -410,8 +435,9 @@ export default function TableManagementPage() { setTableDescription(tableInfo?.description || ""); loadColumnTypes(tableName, 1, pageSize); + loadConstraints(tableName); }, - [loadColumnTypes, pageSize, tables], + [loadColumnTypes, loadConstraints, pageSize, tables], ); // 입력 타입 변경 @@ -757,7 +783,9 @@ export default function TableManagementPage() { // 전체 저장 (테이블 라벨 + 모든 컬럼 설정) const saveAllSettings = async () => { if (!selectedTable) return; + if (isSaving) return; // 저장 중 중복 실행 방지 + setIsSaving(true); try { // 1. 테이블 라벨 저장 (변경된 경우에만) if (tableLabel !== selectedTable || tableDescription) { @@ -952,9 +980,30 @@ export default function TableManagementPage() { } catch (error) { // console.error("설정 저장 실패:", error); toast.error("설정 저장 중 오류가 발생했습니다."); + } finally { + setIsSaving(false); } }; + // Ctrl+S 단축키: 테이블 설정 전체 저장 + // saveAllSettings를 ref로 참조하여 useEffect 의존성 문제 방지 + const saveAllSettingsRef = useRef(saveAllSettings); + saveAllSettingsRef.current = saveAllSettings; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); // 브라우저 기본 저장 동작 방지 + if (selectedTable && columns.length > 0) { + saveAllSettingsRef.current(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedTable, columns.length]); + // 필터링된 테이블 목록 (메모이제이션) const filteredTables = useMemo( () => @@ -1000,6 +1049,123 @@ export default function TableManagementPage() { } }, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]); + // PK 체크박스 변경 핸들러 + const handlePkToggle = useCallback( + (columnName: string, checked: boolean) => { + const currentPkCols = [...constraints.primaryKey.columns]; + let newPkCols: string[]; + if (checked) { + newPkCols = [...currentPkCols, columnName]; + } else { + newPkCols = currentPkCols.filter((c) => c !== columnName); + } + // PK 변경은 확인 다이얼로그 표시 + setPendingPkColumns(newPkCols); + setPkDialogOpen(true); + }, + [constraints.primaryKey.columns], + ); + + // PK 변경 확인 + const handlePkConfirm = async () => { + if (!selectedTable) return; + try { + if (pendingPkColumns.length === 0) { + toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다."); + setPkDialogOpen(false); + return; + } + const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, { + columns: pendingPkColumns, + }); + if (response.data.success) { + toast.success(response.data.message); + await loadConstraints(selectedTable); + } else { + toast.error(response.data.message || "PK 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || "PK 설정 중 오류가 발생했습니다."); + } finally { + setPkDialogOpen(false); + } + }; + + // 인덱스 토글 핸들러 + const handleIndexToggle = useCallback( + async (columnName: string, indexType: "index" | "unique", checked: boolean) => { + if (!selectedTable) return; + const action = checked ? "create" : "drop"; + try { + const response = await apiClient.post(`/table-management/tables/${selectedTable}/indexes`, { + columnName, + indexType, + action, + }); + if (response.data.success) { + toast.success(response.data.message); + await loadConstraints(selectedTable); + } else { + toast.error(response.data.message || "인덱스 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || error?.response?.data?.error || "인덱스 설정 중 오류가 발생했습니다."); + } + }, + [selectedTable, loadConstraints], + ); + + // 컬럼별 인덱스 상태 헬퍼 + const getColumnIndexState = useCallback( + (columnName: string) => { + const isPk = constraints.primaryKey.columns.includes(columnName); + const hasIndex = constraints.indexes.some( + (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, + ); + const hasUnique = constraints.indexes.some( + (idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, + ); + return { isPk, hasIndex, hasUnique }; + }, + [constraints], + ); + + // NOT NULL 토글 핸들러 + const handleNullableToggle = useCallback( + async (columnName: string, currentIsNullable: string) => { + if (!selectedTable) return; + // isNullable이 "YES"면 nullable, "NO"면 NOT NULL + // 체크박스 체크 = NOT NULL 설정 (nullable: false) + // 체크박스 해제 = NOT NULL 해제 (nullable: true) + const isCurrentlyNotNull = currentIsNullable === "NO"; + const newNullable = isCurrentlyNotNull; // NOT NULL이면 해제, NULL이면 설정 + try { + const response = await apiClient.put( + `/table-management/tables/${selectedTable}/columns/${columnName}/nullable`, + { nullable: newNullable }, + ); + if (response.data.success) { + toast.success(response.data.message); + // 컬럼 상태 로컬 업데이트 + setColumns((prev) => + prev.map((col) => + col.columnName === columnName + ? { ...col, isNullable: newNullable ? "YES" : "NO" } + : col, + ), + ); + } else { + toast.error(response.data.message || "NOT NULL 설정 실패"); + } + } catch (error: any) { + toast.error( + error?.response?.data?.message || "NOT NULL 설정 중 오류가 발생했습니다.", + ); + } + }, + [selectedTable], + ); + // 테이블 삭제 확인 const handleDeleteTableClick = (tableName: string) => { setTableToDelete(tableName); @@ -1367,11 +1533,15 @@ export default function TableManagementPage() { {/* 저장 버튼 (항상 보이도록 상단에 배치) */} @@ -1391,12 +1561,16 @@ export default function TableManagementPage() { {/* 컬럼 헤더 (고정) */}
-
컬럼명
-
라벨
+
라벨
+
컬럼명
입력 타입
설명
+
Primary
+
NotNull
+
Index
+
Unique
{/* 컬럼 리스트 (스크롤 영역) */} @@ -1410,16 +1584,15 @@ export default function TableManagementPage() { } }} > - {columns.map((column, index) => ( + {columns.map((column, index) => { + const idxState = getColumnIndexState(column.columnName); + return (
-
-
{column.columnName}
-
-
+
handleLabelChange(column.columnName, e.target.value)} @@ -1427,6 +1600,9 @@ export default function TableManagementPage() { className="h-8 text-xs" />
+
+
{column.columnName}
+
{/* 입력 타입 선택 */} @@ -1689,141 +1865,11 @@ export default function TableManagementPage() {
)} - {/* 표시 컬럼 - 검색 가능한 Combobox */} - {column.referenceTable && - column.referenceTable !== "none" && - column.referenceColumn && - column.referenceColumn !== "none" && ( -
- - - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: open, - }, - })) - } - > - - - - - - - - - 컬럼을 찾을 수 없습니다. - - - { - handleDetailSettingsChange( - column.columnName, - "entity_display_column", - "none", - ); - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: false, - }, - })); - }} - className="text-xs" - > - - -- 선택 안함 -- - - {referenceTableColumns[column.referenceTable]?.map((refCol) => ( - { - handleDetailSettingsChange( - column.columnName, - "entity_display_column", - refCol.columnName, - ); - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: false, - }, - })); - }} - className="text-xs" - > - -
- {refCol.columnName} - {refCol.columnLabel && ( - - {refCol.columnLabel} - - )} -
-
- ))} -
-
-
-
-
-
- )} - {/* 설정 완료 표시 */} {column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && - column.referenceColumn !== "none" && - column.displayColumn && - column.displayColumn !== "none" && ( + column.referenceColumn !== "none" && (
설정 완료 @@ -1953,8 +1999,49 @@ export default function TableManagementPage() { className="h-8 w-full text-xs" />
+ {/* PK 체크박스 */} +
+ + handlePkToggle(column.columnName, checked as boolean) + } + aria-label={`${column.columnName} PK 설정`} + /> +
+ {/* NN (NOT NULL) 체크박스 */} +
+ + handleNullableToggle(column.columnName, column.isNullable) + } + aria-label={`${column.columnName} NOT NULL 설정`} + /> +
+ {/* IDX 체크박스 */} +
+ + handleIndexToggle(column.columnName, "index", checked as boolean) + } + aria-label={`${column.columnName} 인덱스 설정`} + /> +
+ {/* UQ 체크박스 */} +
+ + handleIndexToggle(column.columnName, "unique", checked as boolean) + } + aria-label={`${column.columnName} 유니크 설정`} + /> +
- ))} + ); + })} {/* 로딩 표시 */} {columnsLoading && ( @@ -2120,6 +2207,52 @@ export default function TableManagementPage() { )} + {/* PK 변경 확인 다이얼로그 */} + + + + PK 변경 확인 + + PK를 변경하면 기존 제약조건이 삭제되고 새로 생성됩니다. +
데이터 무결성에 영향을 줄 수 있습니다. +
+
+ +
+
+

변경될 PK 컬럼:

+ {pendingPkColumns.length > 0 ? ( +
+ {pendingPkColumns.map((col) => ( + + {col} + + ))} +
+ ) : ( +

PK가 모두 제거됩니다

+ )} +
+
+ + + + + +
+
+ {/* Scroll to Top 버튼 */}
diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 9f043adf..95305aaf 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen"; +import { LayerDefinition } from "@/types/screen-management"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { initializeComponents } from "@/lib/registry/components"; @@ -86,6 +87,11 @@ function ScreenViewPage() { // 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이) const [conditionalContainerHeights, setConditionalContainerHeights] = useState>({}); + // 🆕 레이어 시스템 지원 + const [conditionalLayers, setConditionalLayers] = useState([]); + // 🆕 조건부 영역(Zone) 목록 + const [zones, setZones] = useState([]); + // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); const [editModalConfig, setEditModalConfig] = useState<{ @@ -204,6 +210,177 @@ function ScreenViewPage() { } }, [screenId]); + // 🆕 조건부 레이어 + Zone 로드 + useEffect(() => { + const loadConditionalLayersAndZones = async () => { + if (!screenId || !layout) return; + + try { + // 1. Zone 로드 + const loadedZones = await screenApi.getScreenZones(screenId); + setZones(loadedZones); + + // 2. 모든 레이어 목록 조회 + const allLayers = await screenApi.getScreenLayers(screenId); + const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1); + + if (nonBaseLayers.length === 0) { + setConditionalLayers([]); + return; + } + + // 3. 각 레이어의 레이아웃 데이터 로드 + const layerDefinitions: LayerDefinition[] = []; + + for (const layerInfo of nonBaseLayers) { + try { + const layerData = await screenApi.getLayerLayout(screenId, layerInfo.layer_id); + const condConfig = layerInfo.condition_config || layerData?.conditionConfig || {}; + + // 레이어 컴포넌트 변환 (V2 → Legacy) + let layerComponents: any[] = []; + const rawComponents = layerData?.components; + if (rawComponents && Array.isArray(rawComponents) && rawComponents.length > 0) { + const tempV2 = { + version: "2.0" as const, + components: rawComponents, + gridSettings: layerData.gridSettings, + screenResolution: layerData.screenResolution, + }; + if (isValidV2Layout(tempV2)) { + const converted = convertV2ToLegacy(tempV2); + if (converted) { + layerComponents = converted.components || []; + } + } + } + + // Zone 기반 condition_config 처리 + const zoneId = condConfig.zone_id; + const conditionValue = condConfig.condition_value; + const zone = zoneId ? loadedZones.find((z: any) => z.zone_id === zoneId) : null; + + // LayerDefinition 생성 + const layerDef: LayerDefinition = { + id: String(layerInfo.layer_id), + name: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`, + type: "conditional", + zIndex: layerInfo.layer_id * 10, + isVisible: false, + isLocked: false, + // Zone 기반 조건 (Zone에서 트리거 정보를 가져옴) + condition: zone ? { + targetComponentId: zone.trigger_component_id || "", + operator: (zone.trigger_operator as "eq" | "neq" | "in") || "eq", + value: conditionValue, + } : condConfig.targetComponentId ? { + targetComponentId: condConfig.targetComponentId, + operator: condConfig.operator || "eq", + value: condConfig.value, + } : undefined, + // Zone 기반: displayRegion은 Zone에서 가져옴 + zoneId: zoneId || undefined, + conditionValue: conditionValue || undefined, + displayRegion: zone ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } : condConfig.displayRegion || undefined, + components: layerComponents, + }; + + layerDefinitions.push(layerDef); + } catch (layerError) { + console.warn(`레이어 ${layerInfo.layer_id} 로드 실패:`, layerError); + } + } + + console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({ + id: l.id, name: l.name, zoneId: l.zoneId, conditionValue: l.conditionValue, + componentCount: l.components.length, + condition: l.condition ? { + targetComponentId: l.condition.targetComponentId, + operator: l.condition.operator, + value: l.condition.value, + } : "없음", + }))); + console.log("🗺️ Zone 정보:", loadedZones.map(z => ({ + zone_id: z.zone_id, + trigger_component_id: z.trigger_component_id, + trigger_operator: z.trigger_operator, + }))); + setConditionalLayers(layerDefinitions); + } catch (error) { + console.error("레이어/Zone 로드 실패:", error); + } + }; + + loadConditionalLayersAndZones(); + }, [screenId, layout]); + + // 🆕 조건부 레이어 조건 평가 (formData 변경 시 동기적으로 즉시 계산) + const activeLayerIds = useMemo(() => { + if (conditionalLayers.length === 0 || !layout) return [] as string[]; + + const allComponents = layout.components || []; + const newActiveIds: string[] = []; + + conditionalLayers.forEach((layer) => { + if (layer.condition) { + const { targetComponentId, operator, value } = layer.condition; + + // 빈 targetComponentId는 무시 + if (!targetComponentId) return; + + // 트리거 컴포넌트 찾기 (기본 레이어에서) + const targetComponent = allComponents.find((c) => c.id === targetComponentId); + + // columnName으로 formData에서 값 조회 + const fieldKey = + (targetComponent as any)?.columnName || + (targetComponent as any)?.componentConfig?.columnName || + targetComponentId; + + const targetValue = formData[fieldKey]; + + let isMatch = false; + switch (operator) { + case "eq": + // 문자열로 변환하여 비교 (타입 불일치 방지) + isMatch = String(targetValue ?? "") === String(value ?? ""); + break; + case "neq": + isMatch = String(targetValue ?? "") !== String(value ?? ""); + break; + case "in": + if (Array.isArray(value)) { + isMatch = value.some(v => String(v) === String(targetValue ?? "")); + } else if (typeof value === "string" && value.includes(",")) { + // 쉼표로 구분된 문자열도 지원 + isMatch = value.split(",").map(v => v.trim()).includes(String(targetValue ?? "")); + } + break; + } + + // 디버그 로깅 (값이 존재할 때만) + if (targetValue !== undefined && targetValue !== "") { + console.log("🔍 [레이어 조건 평가]", { + layerId: layer.id, + layerName: layer.name, + targetComponentId, + fieldKey, + targetValue: String(targetValue), + conditionValue: String(value), + operator, + isMatch, + }); + } + + if (isMatch) { + newActiveIds.push(layer.id); + } + } + }); + + return newActiveIds; + }, [formData, conditionalLayers, layout]); + // 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼) // 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움 useEffect(() => { @@ -513,6 +690,7 @@ function ScreenViewPage() { {layoutReady && layout && layout.components.length > 0 ? (
0) { + // 🆕 Zone 기반 높이 조정 + // Zone 단위로 활성 여부를 판단하여 Y 오프셋 계산 + // Zone은 겹치지 않으므로 merge 로직이 불필요 (단순 boolean 판단) + for (const zone of zones) { + const zoneBottom = zone.y + zone.height; + // 컴포넌트가 Zone 하단보다 아래에 있는 경우 + if (component.position.y >= zoneBottom) { + // Zone에 매칭되는 활성 레이어가 있는지 확인 + const hasActiveLayer = conditionalLayers.some( + l => l.zoneId === zone.zone_id && activeLayerIds.includes(l.id) + ); + if (!hasActiveLayer) { + // Zone에 활성 레이어 없음: Zone 높이만큼 위로 당김 (빈 공간 제거) + totalHeightAdjustment -= zone.height; + } + } + } + + if (totalHeightAdjustment !== 0) { return { ...component, position: { @@ -950,6 +1146,81 @@ function ScreenViewPage() {
); })} + + {/* 🆕 조건부 레이어 컴포넌트 렌더링 (Zone 기반) */} + {conditionalLayers.map((layer) => { + const isActive = activeLayerIds.includes(layer.id); + if (!isActive || !layer.components || layer.components.length === 0) return null; + + // Zone 기반: zoneId로 Zone 찾아서 위치/크기 결정 + const zone = layer.zoneId ? zones.find(z => z.zone_id === layer.zoneId) : null; + const region = zone + ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } + : layer.displayRegion; + + return ( +
+ {layer.components + .filter((comp) => !comp.parentId) + .map((comp) => ( + {}} + menuObjid={menuObjid} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + tableDisplayData={tableDisplayData} + onSelectedRowsChange={( + _, + selectedData, + sortBy, + sortOrder, + columnOrder, + tableDisplayData, + ) => { + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + setTableDisplayData(tableDisplayData || []); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> + ))} +
+ ); + })} ); })()} diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx new file mode 100644 index 00000000..f578b30e --- /dev/null +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -0,0 +1,340 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useParams, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react"; +import { screenApi } from "@/lib/api/screen"; +import { ScreenDefinition } from "@/types/screen"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; +import { useAuth } from "@/hooks/useAuth"; +import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; +import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext"; +import { ScreenContextProvider } from "@/contexts/ScreenContext"; +import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; +import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; +import { + PopLayoutDataV5, + GridMode, + isV5Layout, + createEmptyPopLayoutV5, + GAP_PRESETS, + GRID_BREAKPOINTS, + detectGridMode, +} from "@/components/pop/designer/types/pop-layout"; +import PopRenderer from "@/components/pop/designer/renderers/PopRenderer"; +import { + useResponsiveModeWithOverride, + type DeviceType, +} from "@/hooks/useDeviceOrientation"; + +// 디바이스별 크기 (너비만, 높이는 콘텐츠 기반) +const DEVICE_SIZES: Record> = { + mobile: { + landscape: { width: 600, label: "모바일 가로" }, + portrait: { width: 375, label: "모바일 세로" }, + }, + tablet: { + landscape: { width: 1024, label: "태블릿 가로" }, + portrait: { width: 820, label: "태블릿 세로" }, + }, +}; + +// 모드 키 변환 +const getModeKey = (device: DeviceType, isLandscape: boolean): GridMode => { + if (device === "tablet") { + return isLandscape ? "tablet_landscape" : "tablet_portrait"; + } + return isLandscape ? "mobile_landscape" : "mobile_portrait"; +}; + +// ======================================== +// 메인 컴포넌트 (v5 그리드 시스템 전용) +// ======================================== + +function PopScreenViewPage() { + const params = useParams(); + const searchParams = useSearchParams(); + const router = useRouter(); + const screenId = parseInt(params.screenId as string); + + const isPreviewMode = searchParams.get("preview") === "true"; + + // 반응형 모드 감지 (화면 크기에 따라 tablet/mobile, landscape/portrait 자동 전환) + // 프리뷰 모드에서는 수동 전환 가능 + const { mode, setDevice, setOrientation, isAutoDetect } = useResponsiveModeWithOverride( + isPreviewMode ? "tablet" : undefined, + isPreviewMode ? true : undefined + ); + + // 현재 모드 정보 + const deviceType = mode.device; + const isLandscape = mode.isLandscape; + + const { user } = useAuth(); + + const [screen, setScreen] = useState(null); + const [layout, setLayout] = useState(createEmptyPopLayoutV5()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px) + const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로 + + // 모드 결정: + // - 프리뷰 모드: 수동 선택한 device/orientation 사용 + // - 일반 모드: 화면 너비 기준으로 자동 결정 (GRID_BREAKPOINTS와 일치) + const currentModeKey = isPreviewMode + ? getModeKey(deviceType, isLandscape) + : detectGridMode(viewportWidth); + + useEffect(() => { + const updateViewportWidth = () => { + setViewportWidth(Math.min(window.innerWidth, 1366)); + }; + + updateViewportWidth(); + window.addEventListener("resize", updateViewportWidth); + return () => window.removeEventListener("resize", updateViewportWidth); + }, []); + + // 화면 및 POP 레이아웃 로드 + useEffect(() => { + const loadScreen = async () => { + try { + setLoading(true); + setError(null); + + const screenData = await screenApi.getScreen(screenId); + setScreen(screenData); + + try { + const popLayout = await screenApi.getLayoutPop(screenId); + + if (popLayout && isV5Layout(popLayout)) { + // v5 레이아웃 로드 + setLayout(popLayout); + const componentCount = Object.keys(popLayout.components).length; + console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`); + } else if (popLayout) { + // 다른 버전 레이아웃은 빈 v5로 처리 + console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version); + setLayout(createEmptyPopLayoutV5()); + } else { + console.log("[POP] 레이아웃 없음"); + setLayout(createEmptyPopLayoutV5()); + } + } catch (layoutError) { + console.warn("[POP] 레이아웃 로드 실패:", layoutError); + setLayout(createEmptyPopLayoutV5()); + } + } catch (error) { + console.error("[POP] 화면 로드 실패:", error); + setError("화면을 불러오는데 실패했습니다."); + toast.error("화면을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + if (screenId) { + loadScreen(); + } + }, [screenId]); + + const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"]; + const hasComponents = Object.keys(layout.components).length > 0; + + if (loading) { + return ( +
+
+ +

POP 화면 로딩 중...

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

화면을 찾을 수 없습니다

+

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

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

+ 화면이 비어있습니다 +

+

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

+
+ )} +
+
+
+
+
+
+ ); +} + +// Provider 래퍼 +export default function PopScreenViewPageWrapper() { + return ( + + + + + + + + ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index a252eaff..7276f5b0 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -263,12 +263,20 @@ input, textarea, select { transition-property: - color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, + color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, filter, backdrop-filter; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } +/* 런타임 화면에서 컴포넌트 위치 변경 시 모든 애니메이션/트랜지션 완전 제거 */ +[data-screen-runtime] [id^="component-"] { + transition: none !important; +} +[data-screen-runtime] [data-conditional-layer] { + transition: none !important; +} + /* Disable animations for users who prefer reduced motion */ @media (prefers-reduced-motion: reduce) { *, @@ -281,6 +289,20 @@ select { } } +/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */ +[data-sonner-toaster] [data-sonner-toast] { + animation: none !important; + transition: none !important; + opacity: 1 !important; + transform: none !important; +} +[data-sonner-toaster] [data-sonner-toast][data-mounted="true"] { + animation: none !important; +} +[data-sonner-toaster] [data-sonner-toast][data-removed="true"] { + animation: none !important; +} + /* ===== Print Styles ===== */ @media print { * { diff --git a/frontend/components/admin/UserFormModal.tsx b/frontend/components/admin/UserFormModal.tsx index a70e82b9..39bbefbc 100644 --- a/frontend/components/admin/UserFormModal.tsx +++ b/frontend/components/admin/UserFormModal.tsx @@ -145,13 +145,12 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF const isFormValid = useMemo(() => { // 수정 모드에서는 비밀번호 선택 사항 (변경할 경우만 입력) const requiredFields = isEditMode - ? [formData.userId.trim(), formData.userName.trim(), formData.companyCode, formData.deptCode] + ? [formData.userId.trim(), formData.userName.trim(), formData.companyCode] : [ formData.userId.trim(), formData.userPassword.trim(), formData.userName.trim(), formData.companyCode, - formData.deptCode, ]; // 모든 필수 필드가 입력되었는지 확인 @@ -327,11 +326,6 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF return false; } - if (!formData.deptCode) { - showAlert("입력 오류", "부서를 선택해주세요.", "error"); - return false; - } - // 이메일 형식 검사 (입력된 경우만) if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { showAlert("입력 오류", "올바른 이메일 형식을 입력해주세요.", "error"); @@ -581,7 +575,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
{/* 중복 체크 체크박스 */} @@ -1371,6 +1501,38 @@ export const ExcelUploadModal: React.FC = ({
+ {/* 미매핑 필수(NOT NULL) 컬럼 경고 */} + {(() => { + const mappedCols = new Set(); + columnMappings.filter((m) => m.systemColumn).forEach((m) => { + const n = m.systemColumn!; + mappedCols.add(n); + if (n.includes(".")) mappedCols.add(n.split(".")[1]); + }); + const missing = systemColumns.filter((col) => { + const rawName = col.name.includes(".") ? col.name.split(".")[1] : col.name; + if (AUTO_GENERATED_COLUMNS.includes(rawName.toLowerCase())) return false; + if (col.nullable) return false; + if (mappedCols.has(col.name) || mappedCols.has(rawName)) return false; + if ((col as any).isNumbering) return false; + return true; + }); + if (missing.length === 0) return null; + return ( +
+
+ +
+

필수(NOT NULL) 컬럼이 매핑되지 않았습니다:

+

+ {missing.map((c) => c.label || c.name).join(", ")} +

+
+
+
+ ); + })()} + {/* 중복 체크 안내 */} {duplicateCheckCount > 0 ? (
diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 49fb3355..138f560c 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1,7 +1,17 @@ "use client"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@/components/ui/alert-dialog"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; @@ -14,6 +24,7 @@ import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHei import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; +import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; interface ScreenModalState { isOpen: boolean; @@ -61,12 +72,21 @@ export const ScreenModal: React.FC = ({ className }) => { // 🆕 선택된 데이터 상태 (RepeatScreenModal 등에서 사용) const [selectedData, setSelectedData] = useState[]>([]); + // 🆕 조건부 레이어 상태 (Zone 기반) + const [conditionalLayers, setConditionalLayers] = useState<(LayerDefinition & { components: ComponentData[]; zone?: ConditionalZone })[]>([]); + // 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해) const [continuousMode, setContinuousMode] = useState(false); // 화면 리셋 키 (컴포넌트 강제 리마운트용) const [resetKey, setResetKey] = useState(0); + // 모달 닫기 확인 다이얼로그 표시 상태 + const [showCloseConfirm, setShowCloseConfirm] = useState(false); + + // 사용자가 폼 데이터를 실제로 변경했는지 추적 (변경 없으면 경고 없이 바로 닫기) + const formDataChangedRef = useRef(false); + // localStorage에서 연속 모드 상태 복원 useEffect(() => { const savedMode = localStorage.getItem("screenModal_continuousMode"); @@ -109,9 +129,9 @@ export const ScreenModal: React.FC = ({ className }) => { const contentWidth = maxX - minX; const contentHeight = maxY - minY; - // 적절한 여백 추가 - const paddingX = 40; - const paddingY = 40; + // 여백 없이 컨텐츠 크기 그대로 사용 + const paddingX = 0; + const paddingY = 0; const finalWidth = Math.max(contentWidth + paddingX, 400); const finalHeight = Math.max(contentHeight + paddingY, 300); @@ -119,8 +139,8 @@ export const ScreenModal: React.FC = ({ className }) => { return { width: Math.min(finalWidth, window.innerWidth * 0.95), height: Math.min(finalHeight, window.innerHeight * 0.9), - offsetX: Math.max(0, minX - paddingX / 2), // 좌측 여백 고려 - offsetY: Math.max(0, minY - paddingY / 2), // 상단 여백 고려 + offsetX: Math.max(0, minX), // 여백 없이 컨텐츠 시작점 기준 + offsetY: Math.max(0, minY), // 여백 없이 컨텐츠 시작점 기준 }; }; @@ -159,11 +179,15 @@ export const ScreenModal: React.FC = ({ className }) => { selectedData: eventSelectedData, selectedIds, isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함) + fieldMappings, // 🆕 필드 매핑 정보 (명시적 매핑이 있으면 모든 매핑된 필드 전달) } = event.detail; // 🆕 모달 열린 시간 기록 modalOpenedAtRef.current = Date.now(); + // 폼 변경 추적 초기화 + formDataChangedRef.current = false; + // 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용) if (eventSelectedData && Array.isArray(eventSelectedData)) { setSelectedData(eventSelectedData); @@ -218,10 +242,33 @@ export const ScreenModal: React.FC = ({ className }) => { const parentDataMapping = splitPanelContext?.parentDataMapping || []; // 부모 데이터 소스 - const rawParentData = - splitPanelParentData && Object.keys(splitPanelParentData).length > 0 - ? splitPanelParentData - : splitPanelContext?.selectedLeftData || {}; + // 🔧 수정: 여러 소스를 병합 (우선순위: splitPanelParentData > selectedLeftData > 기존 formData의 링크 필드) + // 예: screen 150→226→227 전환 시: + // - splitPanelParentData: item_info 데이터 (screen 226에서 전달) + // - selectedLeftData: customer_mng 데이터 (SplitPanel 좌측 선택) + // - 기존 formData: 이전 모달에서 설정된 link 필드 (customer_code 등) + const contextData = splitPanelContext?.selectedLeftData || {}; + const eventData = splitPanelParentData && Object.keys(splitPanelParentData).length > 0 + ? splitPanelParentData + : {}; + + // 🆕 기존 formData에서 link 필드(_code, _id)를 가져와 base로 사용 + // 모달 체인(226→227)에서 이전 모달의 연결 필드가 유지됨 + const previousLinkFields: Record = {}; + if (formData && typeof formData === "object" && !Array.isArray(formData)) { + const linkFieldPatterns = ["_code", "_id"]; + const excludeFields = ["id", "created_date", "updated_date", "created_at", "updated_at", "writer"]; + for (const [key, value] of Object.entries(formData)) { + if (excludeFields.includes(key)) continue; + if (value === undefined || value === null) continue; + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); + if (isLinkField) { + previousLinkFields[key] = value; + } + } + } + + const rawParentData = { ...previousLinkFields, ...contextData, ...eventData }; // 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달 const parentData: Record = {}; @@ -231,6 +278,17 @@ export const ScreenModal: React.FC = ({ className }) => { parentData.company_code = rawParentData.company_code; } + // 🆕 명시적 필드 매핑이 있으면 매핑된 타겟 필드를 모두 보존 + // (버튼 설정에서 fieldMappings로 지정한 필드는 link 필드가 아니어도 전달) + const mappedTargetFields = new Set(); + if (fieldMappings && Array.isArray(fieldMappings)) { + for (const mapping of fieldMappings) { + if (mapping.targetField) { + mappedTargetFields.add(mapping.targetField); + } + } + } + // parentDataMapping에 정의된 필드만 전달 for (const mapping of parentDataMapping) { const sourceValue = rawParentData[mapping.sourceColumn]; @@ -239,8 +297,17 @@ export const ScreenModal: React.FC = ({ className }) => { } } - // parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴) - if (parentDataMapping.length === 0) { + // 🆕 명시적 필드 매핑이 있으면 해당 필드를 모두 전달 + if (mappedTargetFields.size > 0) { + for (const [key, value] of Object.entries(rawParentData)) { + if (mappedTargetFields.has(key) && value !== undefined && value !== null) { + parentData[key] = value; + } + } + } + + // parentDataMapping이 비어있고 명시적 필드 매핑도 없으면 연결 필드 자동 감지 + if (parentDataMapping.length === 0 && mappedTargetFields.size === 0) { const linkFieldPatterns = ["_code", "_id"]; const excludeFields = [ "id", @@ -257,6 +324,29 @@ export const ScreenModal: React.FC = ({ className }) => { if (value === undefined || value === null) continue; // 연결 필드 패턴 확인 + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); + if (isLinkField) { + parentData[key] = value; + } + } + } else if (parentDataMapping.length === 0 && mappedTargetFields.size > 0) { + // 🆕 명시적 매핑이 있어도 연결 필드(_code, _id)는 추가로 전달 + const linkFieldPatterns = ["_code", "_id"]; + const excludeFields = [ + "id", + "company_code", + "created_date", + "updated_date", + "created_at", + "updated_at", + "writer", + ]; + + for (const [key, value] of Object.entries(rawParentData)) { + if (excludeFields.includes(key)) continue; + if (parentData[key] !== undefined) continue; // 이미 매핑된 필드는 스킵 + if (value === undefined || value === null) continue; + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); if (isLinkField) { parentData[key] = value; @@ -317,6 +407,7 @@ export const ScreenModal: React.FC = ({ className }) => { if (isContinuousMode) { // 연속 모드: 폼만 초기화하고 모달은 유지 + formDataChangedRef.current = false; setFormData({}); setResetKey((prev) => prev + 1); @@ -483,6 +574,9 @@ export const ScreenModal: React.FC = ({ className }) => { components, screenInfo: screenInfo, }); + + // 🆕 조건부 레이어/존 로드 + loadConditionalLayersAndZones(screenId); } else { throw new Error("화면 데이터가 없습니다"); } @@ -495,14 +589,262 @@ export const ScreenModal: React.FC = ({ className }) => { } }; - const handleClose = () => { - // 🔧 URL 파라미터 제거 (mode, editId, tableName 등) + // 🆕 조건부 레이어 & 존 로드 함수 + const loadConditionalLayersAndZones = async (screenId: number) => { + try { + const [layersRes, zonesRes] = await Promise.all([ + screenApi.getScreenLayers(screenId), + screenApi.getScreenZones(screenId), + ]); + + const loadedLayers = layersRes || []; + const loadedZones: ConditionalZone[] = zonesRes || []; + + // 기본 레이어(layer_id=1) 제외 + const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1); + + if (nonBaseLayers.length === 0) { + setConditionalLayers([]); + return; + } + + const layerDefs: (LayerDefinition & { components: ComponentData[] })[] = []; + + for (const layer of nonBaseLayers) { + try { + const layerLayout = await screenApi.getLayerLayout(screenId, layer.layer_id); + + let layerComponents: ComponentData[] = []; + if (layerLayout && isValidV2Layout(layerLayout)) { + const legacyLayout = convertV2ToLegacy(layerLayout); + layerComponents = (legacyLayout?.components || []) as unknown as ComponentData[]; + } else if (layerLayout?.components) { + layerComponents = layerLayout.components; + } + + // condition_config에서 zone_id, condition_value 추출 + const cc = layer.condition_config || {}; + const zone = loadedZones.find((z) => z.zone_id === cc.zone_id); + + layerDefs.push({ + id: `layer-${layer.layer_id}`, + name: layer.layer_name || `레이어 ${layer.layer_id}`, + type: "conditional", + zIndex: layer.layer_id, + isVisible: false, + isLocked: false, + zoneId: cc.zone_id, + conditionValue: cc.condition_value, + condition: zone + ? { + targetComponentId: zone.trigger_component_id || "", + operator: (zone.trigger_operator || "eq") as any, + value: cc.condition_value || "", + } + : undefined, + components: layerComponents, + zone: zone || undefined, // 🆕 Zone 위치 정보 포함 (오프셋 계산용) + } as any); + } catch (err) { + console.warn(`[ScreenModal] 레이어 ${layer.layer_id} 로드 실패:`, err); + } + } + + console.log("[ScreenModal] 조건부 레이어 로드 완료:", layerDefs.length, "개", + layerDefs.map((l) => ({ + id: l.id, name: l.name, conditionValue: l.conditionValue, + componentCount: l.components.length, + condition: l.condition, + })) + ); + + setConditionalLayers(layerDefs); + } catch (error) { + console.error("[ScreenModal] 조건부 레이어 로드 실패:", error); + } + }; + + // 🆕 조건부 레이어 활성화 평가 (formData 변경 시) + const activeConditionalComponents = useMemo(() => { + if (conditionalLayers.length === 0) return []; + + const allComponents = screenData?.components || []; + const activeComps: ComponentData[] = []; + + conditionalLayers.forEach((layer) => { + if (!layer.condition) return; + const { targetComponentId, operator, value } = layer.condition; + if (!targetComponentId) return; + + // V2 레이아웃: overrides.columnName 우선 + const comp = allComponents.find((c: any) => c.id === targetComponentId); + const fieldKey = + (comp as any)?.overrides?.columnName || + (comp as any)?.columnName || + (comp as any)?.componentConfig?.columnName || + targetComponentId; + + const targetValue = formData[fieldKey]; + + let isMatch = false; + switch (operator) { + case "eq": + isMatch = String(targetValue ?? "") === String(value ?? ""); + break; + case "neq": + isMatch = String(targetValue ?? "") !== String(value ?? ""); + break; + case "in": + if (Array.isArray(value)) { + isMatch = value.some((v) => String(v) === String(targetValue ?? "")); + } else if (typeof value === "string" && value.includes(",")) { + isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? "")); + } + break; + } + + console.log("[ScreenModal] 레이어 조건 평가:", { + layerName: layer.name, fieldKey, + targetValue: String(targetValue ?? "(없음)"), + conditionValue: String(value), operator, isMatch, + }); + + if (isMatch) { + // Zone 오프셋 적용 (레이어 2 컴포넌트는 Zone 상대 좌표로 저장됨) + const zoneX = layer.zone?.x || 0; + const zoneY = layer.zone?.y || 0; + + const offsetComponents = layer.components.map((c: any) => ({ + ...c, + position: { + ...c.position, + x: parseFloat(c.position?.x?.toString() || "0") + zoneX, + y: parseFloat(c.position?.y?.toString() || "0") + zoneY, + }, + })); + + activeComps.push(...offsetComponents); + } + }); + + return activeComps; + }, [formData, conditionalLayers, screenData?.components]); + + // 🆕 이전 활성 레이어 ID 추적 (레이어 전환 감지용) + const prevActiveLayerIdsRef = useRef([]); + + // 🆕 레이어 전환 시 비활성화된 레이어의 필드값을 formData에서 제거 + // (품목우선 → 공급업체우선 전환 시, 품목우선 레이어의 데이터가 남지 않도록) + useEffect(() => { + if (conditionalLayers.length === 0) return; + + // 현재 활성 레이어 ID 목록 + const currentActiveLayerIds = conditionalLayers + .filter((layer) => { + if (!layer.condition) return false; + const { targetComponentId, operator, value } = layer.condition; + if (!targetComponentId) return false; + + const allComponents = screenData?.components || []; + const comp = allComponents.find((c: any) => c.id === targetComponentId); + const fieldKey = + (comp as any)?.overrides?.columnName || + (comp as any)?.columnName || + (comp as any)?.componentConfig?.columnName || + targetComponentId; + + const targetValue = formData[fieldKey]; + switch (operator) { + case "eq": + return String(targetValue ?? "") === String(value ?? ""); + case "neq": + return String(targetValue ?? "") !== String(value ?? ""); + case "in": + if (Array.isArray(value)) { + return value.some((v) => String(v) === String(targetValue ?? "")); + } else if (typeof value === "string" && value.includes(",")) { + return value.split(",").map((v) => v.trim()).includes(String(targetValue ?? "")); + } + return false; + default: + return false; + } + }) + .map((l) => l.id); + + const prevIds = prevActiveLayerIdsRef.current; + + // 이전에 활성이었는데 이번에 비활성이 된 레이어 찾기 + const deactivatedLayerIds = prevIds.filter((id) => !currentActiveLayerIds.includes(id)); + + if (deactivatedLayerIds.length > 0) { + // 비활성화된 레이어의 컴포넌트 필드명 수집 + const fieldsToRemove: string[] = []; + deactivatedLayerIds.forEach((layerId) => { + const layer = conditionalLayers.find((l) => l.id === layerId); + if (!layer) return; + + layer.components.forEach((comp: any) => { + const fieldName = + comp?.overrides?.columnName || + comp?.columnName || + comp?.componentConfig?.columnName; + if (fieldName) { + fieldsToRemove.push(fieldName); + } + }); + }); + + if (fieldsToRemove.length > 0) { + console.log("[ScreenModal] 레이어 전환 감지 - 비활성 레이어 필드 제거:", { + deactivatedLayerIds, + fieldsToRemove, + }); + + setFormData((prev) => { + const cleaned = { ...prev }; + fieldsToRemove.forEach((field) => { + delete cleaned[field]; + }); + return cleaned; + }); + } + } + + // 현재 상태 저장 + prevActiveLayerIdsRef.current = currentActiveLayerIds; + }, [formData, conditionalLayers, screenData?.components]); + + // 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때 + // 폼 데이터 변경이 있으면 확인 다이얼로그, 없으면 바로 닫기 + const handleCloseAttempt = useCallback(() => { + if (formDataChangedRef.current) { + setShowCloseConfirm(true); + } else { + handleCloseInternal(); + } + }, []); + + // 확인 후 실제로 모달을 닫는 함수 + const handleConfirmClose = useCallback(() => { + setShowCloseConfirm(false); + handleCloseInternal(); + }, []); + + // 닫기 취소 (계속 작업) + const handleCancelClose = useCallback(() => { + setShowCloseConfirm(false); + }, []); + + const handleCloseInternal = () => { + // 🔧 URL 파라미터 제거 (mode, editId, tableName, groupByColumns, dataSourceId 등) if (typeof window !== "undefined") { const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete("mode"); currentUrl.searchParams.delete("editId"); currentUrl.searchParams.delete("tableName"); currentUrl.searchParams.delete("groupByColumns"); + currentUrl.searchParams.delete("dataSourceId"); window.history.pushState({}, "", currentUrl.toString()); } @@ -514,42 +856,35 @@ export const ScreenModal: React.FC = ({ className }) => { }); setScreenData(null); setFormData({}); // 폼 데이터 초기화 + setOriginalData(null); // 원본 데이터 초기화 + setSelectedData([]); // 선택된 데이터 초기화 + setConditionalLayers([]); // 🆕 조건부 레이어 초기화 + setContinuousMode(false); + localStorage.setItem("screenModal_continuousMode", "false"); }; + // 기존 handleClose를 유지 (이벤트 핸들러 등에서 사용) + const handleClose = handleCloseInternal; + // 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터 const getModalStyle = () => { if (!screenDimensions) { return { - className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0", - style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용 - needsScroll: false, + className: "w-fit min-w-[400px] max-w-4xl overflow-hidden", + style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" }, }; } - // 화면관리에서 설정한 크기 = 컨텐츠 영역 크기 - // 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩 - // 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함 - const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3) - const footerHeight = 44; // 연속 등록 모드 체크박스 영역 - const dialogGap = 32; // gap-4 × 2 (header-content, content-footer 사이) - const contentTopPadding = 24; // pt-6 (컨텐츠 영역 상단 패딩) - const horizontalPadding = 16; // 좌우 패딩 최소화 - - const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + contentTopPadding; - const maxAvailableHeight = window.innerHeight * 0.95; - - // 콘텐츠가 화면 높이를 초과할 때만 스크롤 필요 - const needsScroll = totalHeight > maxAvailableHeight; - return { - className: "overflow-hidden p-0", + className: "overflow-hidden", style: { - width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`, - // 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦 - maxHeight: `${maxAvailableHeight}px`, + width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`, + // CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한 + maxHeight: "calc(100dvh - 8px)", maxWidth: "98vw", + padding: 0, + gap: 0, }, - needsScroll, }; }; @@ -615,10 +950,28 @@ export const ScreenModal: React.FC = ({ className }) => { ]); return ( - + { + // X 버튼 클릭 시에도 확인 다이얼로그 표시 + if (!open) { + handleCloseAttempt(); + } + }} + > { + e.preventDefault(); + handleCloseAttempt(); + }} + // ESC 키 누를 때도 바로 닫히지 않도록 방지 + onEscapeKeyDown={(e) => { + e.preventDefault(); + handleCloseAttempt(); + }} >
@@ -633,7 +986,7 @@ export const ScreenModal: React.FC = ({ className }) => {
{loading ? (
@@ -649,8 +1002,22 @@ export const ScreenModal: React.FC = ({ className }) => { className="relative bg-white" style={{ width: `${screenDimensions?.width || 800}px`, - height: `${screenDimensions?.height || 600}px`, - // 🆕 라벨이 위로 튀어나갈 수 있도록 overflow visible 설정 + // 🆕 조건부 레이어 활성화 시 높이 자동 확장 + minHeight: `${screenDimensions?.height || 600}px`, + height: (() => { + const baseHeight = screenDimensions?.height || 600; + if (activeConditionalComponents.length > 0) { + const offsetY = screenDimensions?.offsetY || 0; + let maxBottom = 0; + activeConditionalComponents.forEach((comp: any) => { + const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY; + const h = parseFloat(comp.size?.height?.toString() || "40"); + maxBottom = Math.max(maxBottom, y + h); + }); + return `${Math.max(baseHeight, maxBottom + 20)}px`; + } + return `${baseHeight}px`; + })(), overflow: "visible", }} > @@ -786,6 +1153,8 @@ export const ScreenModal: React.FC = ({ className }) => { formData={formData} originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용) onFormDataChange={(fieldName, value) => { + // 사용자가 실제로 데이터를 변경한 것으로 표시 + formDataChangedRef.current = true; setFormData((prev) => { const newFormData = { ...prev, @@ -810,6 +1179,48 @@ export const ScreenModal: React.FC = ({ className }) => { ); }); })()} + + {/* 🆕 조건부 레이어 컴포넌트 렌더링 */} + {activeConditionalComponents.map((component: any) => { + const offsetX = screenDimensions?.offsetX || 0; + const offsetY = screenDimensions?.offsetY || 0; + + const adjustedComponent = { + ...component, + position: { + ...component.position, + x: parseFloat(component.position?.x?.toString() || "0") - offsetX, + y: parseFloat(component.position?.y?.toString() || "0") - offsetY, + }, + }; + + return ( + { + formDataChangedRef.current = true; + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }} + onRefresh={() => { + window.dispatchEvent(new CustomEvent("refreshTable")); + }} + screenInfo={{ + id: modalState.screenId!, + tableName: screenData?.screenInfo?.tableName, + }} + userId={userId} + userName={userName} + companyCode={user?.companyCode} + /> + ); + })}
@@ -838,6 +1249,36 @@ export const ScreenModal: React.FC = ({ className }) => {
+ + {/* 모달 닫기 확인 다이얼로그 */} + + + + + 화면을 닫으시겠습니까? + + + 지금 나가시면 진행 중인 데이터가 저장되지 않습니다. +
+ 계속 작업하시려면 '계속 작업' 버튼을 눌러주세요. +
+
+ + + 계속 작업 + + + 나가기 + + +
+
); }; diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx index f136d216..607886f3 100644 --- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -4,7 +4,7 @@ * 플로우 에디터 상단 툴바 */ -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -42,6 +42,27 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro const [showSaveDialog, setShowSaveDialog] = useState(false); + // Ctrl+S 단축키: 플로우 저장 + const handleSaveRef = useRef<() => void>(); + + useEffect(() => { + handleSaveRef.current = handleSave; + }); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); + if (!isSaving) { + handleSaveRef.current?.(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isSaving]); + const handleSave = async () => { // 검증 수행 const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges); diff --git a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx index a2d060d4..76354925 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx @@ -251,6 +251,14 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND"); const [availableFields, setAvailableFields] = useState([]); + // 타겟 조회 설정 (DB 기존값 비교용) + const [targetLookup, setTargetLookup] = useState<{ + tableName: string; + tableLabel?: string; + lookupKeys: Array<{ sourceField: string; targetField: string; sourceFieldLabel?: string }>; + } | undefined>(data.targetLookup); + const [targetLookupColumns, setTargetLookupColumns] = useState([]); + // EXISTS 연산자용 상태 const [allTables, setAllTables] = useState([]); const [tableColumnsCache, setTableColumnsCache] = useState>({}); @@ -262,8 +270,20 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) setDisplayName(data.displayName || "조건 분기"); setConditions(data.conditions || []); setLogic(data.logic || "AND"); + setTargetLookup(data.targetLookup); }, [data]); + // targetLookup 테이블 변경 시 컬럼 목록 로드 + useEffect(() => { + if (targetLookup?.tableName) { + loadTableColumns(targetLookup.tableName).then((cols) => { + setTargetLookupColumns(cols); + }); + } else { + setTargetLookupColumns([]); + } + }, [targetLookup?.tableName]); + // 전체 테이블 목록 로드 (EXISTS 연산자용) useEffect(() => { const loadAllTables = async () => { @@ -559,6 +579,47 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) }); }; + // 타겟 조회 테이블 변경 + const handleTargetLookupTableChange = async (tableName: string) => { + await ensureTablesLoaded(); + const tableInfo = allTables.find((t) => t.tableName === tableName); + const newLookup = { + tableName, + tableLabel: tableInfo?.tableLabel || tableName, + lookupKeys: targetLookup?.lookupKeys || [], + }; + setTargetLookup(newLookup); + updateNode(nodeId, { targetLookup: newLookup }); + + // 컬럼 로드 + const cols = await loadTableColumns(tableName); + setTargetLookupColumns(cols); + }; + + // 타겟 조회 키 필드 변경 + const handleTargetLookupKeyChange = (sourceField: string, targetField: string) => { + if (!targetLookup) return; + const sourceFieldInfo = availableFields.find((f) => f.name === sourceField); + const newLookup = { + ...targetLookup, + lookupKeys: [{ sourceField, targetField, sourceFieldLabel: sourceFieldInfo?.label || sourceField }], + }; + setTargetLookup(newLookup); + updateNode(nodeId, { targetLookup: newLookup }); + }; + + // 타겟 조회 제거 + const handleRemoveTargetLookup = () => { + setTargetLookup(undefined); + updateNode(nodeId, { targetLookup: undefined }); + // target 타입 조건들을 field로 변경 + const newConditions = conditions.map((c) => + (c as any).valueType === "target" ? { ...c, valueType: "field" } : c + ); + setConditions(newConditions); + updateNode(nodeId, { conditions: newConditions }); + }; + return (
@@ -597,6 +658,119 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
+ {/* 타겟 조회 (DB 기존값 비교) */} +
+
+

+ + 타겟 조회 (DB 기존값) +

+
+ + {!targetLookup ? ( +
+
+ DB의 기존값과 비교하려면 타겟 테이블을 설정하세요. +
+ +
+ ) : ( +
+
+ 타겟 테이블 + +
+ + {/* 테이블 선택 */} + {allTables.length > 0 ? ( + + ) : ( +
+ 테이블 로딩 중... +
+ )} + + {/* 키 필드 매핑 */} + {targetLookup.tableName && ( +
+ +
+ + = + {targetLookupColumns.length > 0 ? ( + + ) : ( +
+ 컬럼 로딩 중... +
+ )} +
+
+ 비교 값 타입에서 "타겟 필드 (DB 기존값)"을 선택하면 이 테이블의 기존값과 비교합니다. +
+
+ )} +
+ )} +
+ {/* 조건식 */}
@@ -738,15 +912,46 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) 고정값 필드 참조 + {targetLookup?.tableName && ( + 타겟 필드 (DB 기존값) + )}
- {(condition as any).valueType === "field" ? ( + {(condition as any).valueType === "target" ? ( + // 타겟 필드 (DB 기존값): 타겟 테이블 컬럼에서 선택 + targetLookupColumns.length > 0 ? ( + + ) : ( +
+ 타겟 조회를 먼저 설정하세요 +
+ ) + ) : (condition as any).valueType === "field" ? ( // 필드 참조: 드롭다운으로 선택 availableFields.length > 0 ? ( onChangeGapPreset?.(value as GapPreset)} + > + + + + + {(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => ( + + {GAP_PRESETS[preset].label} + + ))} + + +
+ +
+ + {/* 줌 컨트롤 */} +
+ + {Math.round(canvasScale * 100)}% + + + + +
+ +
+ + {/* 그리드 가이드 토글 */} + +
+ + {/* 캔버스 영역 */} +
+
+ {/* 그리드 + 라벨 영역 */} +
+ {/* 그리드 라벨 영역 */} + {showGridGuide && ( + <> + {/* 열 라벨 (상단) */} +
+ {gridLabels.columnLabels.map((num) => ( +
+ {num} +
+ ))} +
+ + {/* 행 라벨 (좌측) */} +
+ {gridLabels.rowLabels.map((num) => ( +
+ {num} +
+ ))} +
+ + )} + + {/* 디바이스 스크린 */} +
+ {isEmpty ? ( + // 빈 상태 +
+
+
+ 컴포넌트를 드래그하여 배치하세요 +
+
+ {breakpoint.label} - {breakpoint.columns}칸 그리드 +
+
+
+ ) : ( + // 그리드 렌더러 + onSelectComponent(null)} + onComponentMove={onMoveComponent} + onComponentResize={onResizeComponent} + onComponentResizeEnd={onResizeEnd} + overrideGap={adjustedGap} + overridePadding={adjustedPadding} + /> + )} +
+
+ + {/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */} + {showRightPanel && ( +
+ {/* 검토 필요 패널 */} + {showReviewPanel && ( + + )} + + {/* 숨김 컴포넌트 패널 */} + {showHiddenPanel && ( + + )} +
+ )} +
+
+ + {/* 하단 정보 */} +
+
+ {breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px) +
+
+ Space + 드래그: 패닝 | Ctrl + 휠: 줌 +
+
+
+ ); +} + +// ======================================== +// 검토 필요 영역 (오른쪽 패널) +// ======================================== + +interface ReviewPanelProps { + components: PopComponentDefinitionV5[]; + selectedComponentId: string | null; + onSelectComponent: (id: string | null) => void; +} + +function ReviewPanel({ + components, + selectedComponentId, + onSelectComponent, +}: ReviewPanelProps) { + return ( +
+ {/* 헤더 */} +
+ + + 검토 필요 ({components.length}개) + +
+ + {/* 컴포넌트 목록 */} +
+ {components.map((comp) => ( + onSelectComponent(comp.id)} + /> + ))} +
+ + {/* 안내 문구 */} +
+

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

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

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

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

{selectedScreen?.screenName}

+

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

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

속성

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

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

+

{component.type}

+ {!isDefaultMode && ( +

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

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

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

+

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

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

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

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

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

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

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

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

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

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

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

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

데이터 바인딩

+

+ Phase 4에서 구현 예정 +

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

컴포넌트

+

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

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

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

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

POP 카테고리

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

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

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

화면 흐름

+
+
+
+ +

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

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

화면 흐름

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

화면 흐름

+
+
+
+ +

POP 레이아웃이 없습니다.

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

화면 흐름

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

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

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

미리보기

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

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

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

레이아웃 확인 중...

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

POP 레이아웃이 없습니다.

+

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

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