diff --git a/PLAN_RENEWAL.md b/PLAN_RENEWAL.md new file mode 100644 index 00000000..7d5575a6 --- /dev/null +++ b/PLAN_RENEWAL.md @@ -0,0 +1,680 @@ +# Screen Designer 2.0 리뉴얼 계획: 컴포넌트 통합 및 속성 기반 고도화 + +## 1. 개요 + +현재 **68개 이상**으로 파편화된 화면 관리 컴포넌트들을 **9개의 핵심 통합 컴포넌트(Unified Components)**로 재편합니다. +각 컴포넌트는 **속성(Config)** 설정을 통해 다양한 형태(View Mode)와 기능(Behavior)을 수행하도록 설계되어, 유지보수성과 확장성을 극대화합니다. + +### 현재 컴포넌트 현황 (AS-IS) + +| 카테고리 | 파일 수 | 주요 파일들 | +| :------------- | :-----: | :------------------------------------------------------------------ | +| Widget 타입별 | 14개 | TextWidget, NumberWidget, SelectWidget, DateWidget, EntityWidget 등 | +| Config Panel | 28개 | TextConfigPanel, SelectConfigPanel, DateConfigPanel 등 | +| WebType Config | 11개 | TextTypeConfigPanel, SelectTypeConfigPanel 등 | +| 기타 패널 | 15개+ | PropertiesPanel, DetailSettingsPanel 등 | + +--- + +## 2. 통합 전략: 9 Core Widgets + +### A. 입력 위젯 (Input Widgets) - 5종 + +단순 데이터 입력 필드를 통합합니다. + +| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | +| :-------------------- | :------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------- | +| **1. Unified Select** | Select, Radio, Checkbox, Boolean, Code, Entity, Combobox, Toggle | **`mode`**: "dropdown" / "radio" / "check" / "tag"
**`source`**: "static" / "code" / "db" / "api"
**`dependency`**: { parentField: "..." } | +| **2. Unified Input** | Text, Number, Email, Tel, Password, Color, Search, Integer, Decimal | **`type`**: "text" / "number" / "password"
**`format`**: "email", "currency", "biz_no"
**`mask`**: "000-0000-0000" | +| **3. Unified Date** | Date, Time, DateTime, DateRange, Month, Year | **`type`**: "date" / "time" / "datetime"
**`range`**: true/false | +| **4. Unified Text** | Textarea, RichEditor, Markdown, HTML | **`mode`**: "simple" / "rich" / "code"
**`rows`**: number | +| **5. Unified Media** | File, Image, Video, Audio, Attachment | **`type`**: "file" / "image"
**`multiple`**: true/false
**`preview`**: true/false | + +### B. 구조/데이터 위젯 (Structure & Data Widgets) - 4종 + +레이아웃 배치와 데이터 시각화를 담당합니다. + +| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | 활용 예시 | +| :-------------------- | :-------------------------------------------------- | :------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------- | +| **6. Unified List** | **Table, Card List, Repeater, DataGrid, List View** | **`viewMode`**: "table" / "card" / "kanban"
**`editable`**: true/false | - `viewMode='table'`: 엑셀형 리스트
- `viewMode='card'`: **카드 디스플레이**
- `editable=true`: **반복 필드 그룹** | +| **7. Unified Layout** | **Row, Col, Split Panel, Grid, Spacer** | **`type`**: "grid" / "split" / "flex"
**`columns`**: number | - `type='split'`: **화면 분할 패널**
- `type='grid'`: 격자 레이아웃 | +| **8. Unified Group** | Tab, Accordion, FieldSet, Modal, Section | **`type`**: "tab" / "accordion" / "modal" | - 탭이나 아코디언으로 내용 그룹화 | +| **9. Unified Biz** | **Rack Structure**, Calendar, Gantt | **`type`**: "rack" / "calendar" / "gantt" | - `type='rack'`: **랙 구조 설정**
- 특수 비즈니스 로직 플러그인 탑재 | + +### C. Config Panel 통합 전략 (핵심) + +현재 28개의 ConfigPanel을 **1개의 DynamicConfigPanel**로 통합합니다. + +| AS-IS | TO-BE | 방식 | +| :-------------------- | :--------------------- | :------------------------------- | +| TextConfigPanel.tsx | | | +| SelectConfigPanel.tsx | **DynamicConfigPanel** | DB의 `sys_input_type` 테이블에서 | +| DateConfigPanel.tsx | (단일 컴포넌트) | JSON Schema를 읽어 | +| NumberConfigPanel.tsx | | 속성 UI를 동적 생성 | +| ... 24개 더 | | | + +--- + +## 3. 구현 시나리오 (속성 기반 변신) + +### Case 1: "테이블을 카드 리스트로 변경" + +- **AS-IS**: `DataTable` 컴포넌트를 삭제하고 `CardList` 컴포넌트를 새로 추가해야 함. +- **TO-BE**: `UnifiedList`의 속성창에서 **[View Mode]**를 `Table` → `Card`로 변경하면 즉시 반영. + +### Case 2: "단일 선택을 라디오 버튼으로 변경" + +- **AS-IS**: `SelectWidget`을 삭제하고 `RadioWidget` 추가. +- **TO-BE**: `UnifiedSelect` 속성창에서 **[Display Mode]**를 `Dropdown` → `Radio`로 변경. + +### Case 3: "입력 폼에 반복 필드(Repeater) 추가" + +- **TO-BE**: `UnifiedList` 컴포넌트 배치 후 `editable: true`, `viewMode: "table"` 설정. + +--- + +## 4. 실행 로드맵 (Action Plan) + +### Phase 0: 준비 단계 (1주) + +통합 작업 전 필수 분석 및 설계를 진행합니다. + +- [ ] 기존 컴포넌트 사용 현황 분석 (화면별 위젯 사용 빈도 조사) +- [ ] 데이터 마이그레이션 전략 설계 (`widgetType` → `UnifiedWidget.type` 매핑 정의) +- [ ] `sys_input_type` 테이블 JSON Schema 설계 +- [ ] DynamicConfigPanel 프로토타입 설계 + +### Phase 1: 입력 위젯 통합 (2주) + +가장 중복이 많고 효과가 즉각적인 입력 필드부터 통합합니다. + +- [ ] **UnifiedInput 구현**: Text, Number, Email, Tel, Password 통합 +- [ ] **UnifiedSelect 구현**: Select, Radio, Checkbox, Boolean 통합 +- [ ] **UnifiedDate 구현**: Date, DateTime, Time 통합 +- [ ] 기존 위젯과 **병행 운영** (deprecated 마킹, 삭제하지 않음) + +### Phase 2: Config Panel 통합 (2주) + +28개의 ConfigPanel을 단일 동적 패널로 통합합니다. + +- [ ] **DynamicConfigPanel 구현**: DB 스키마 기반 속성 UI 자동 생성 +- [ ] `sys_input_type` 테이블에 위젯별 JSON Schema 정의 저장 +- [ ] 기존 ConfigPanel과 **병행 운영** (삭제하지 않음) + +### Phase 3: 데이터/레이아웃 위젯 통합 (2주) + +프로젝트의 데이터를 보여주는 핵심 뷰를 통합합니다. + +- [ ] **UnifiedList 구현**: Table, Card, Repeater 통합 렌더러 개발 +- [ ] **UnifiedLayout 구현**: Split Panel, Grid, Flex 통합 +- [ ] **UnifiedGroup 구현**: Tab, Accordion, Modal 통합 + +### Phase 4: 안정화 및 마이그레이션 (2주) + +신규 컴포넌트 안정화 후 점진적 전환을 진행합니다. + +- [ ] 신규 화면은 Unified 컴포넌트만 사용하도록 가이드 +- [ ] 기존 화면 데이터 마이그레이션 스크립트 개발 +- [ ] 마이그레이션 테스트 (스테이징 환경) +- [ ] 문서화 및 개발 가이드 작성 + +### Phase 5: 레거시 정리 (추후 결정) + +충분한 안정화 기간 후 레거시 컴포넌트 정리를 검토합니다. + +- [ ] 사용 현황 재분석 (Unified 전환율 확인) +- [ ] 미전환 화면 목록 정리 +- [ ] 레거시 컴포넌트 삭제 여부 결정 (별도 회의) + +--- + +## 5. 데이터 마이그레이션 전략 + +### 5.1 위젯 타입 매핑 테이블 + +기존 `widgetType`을 신규 Unified 컴포넌트로 매핑합니다. + +| 기존 widgetType | 신규 컴포넌트 | 속성 설정 | +| :-------------- | :------------ | :------------------------------ | +| `text` | UnifiedInput | `type: "text"` | +| `number` | UnifiedInput | `type: "number"` | +| `email` | UnifiedInput | `type: "text", format: "email"` | +| `tel` | UnifiedInput | `type: "text", format: "tel"` | +| `select` | UnifiedSelect | `mode: "dropdown"` | +| `radio` | UnifiedSelect | `mode: "radio"` | +| `checkbox` | UnifiedSelect | `mode: "check"` | +| `date` | UnifiedDate | `type: "date"` | +| `datetime` | UnifiedDate | `type: "datetime"` | +| `textarea` | UnifiedText | `mode: "simple"` | +| `file` | UnifiedMedia | `type: "file"` | +| `image` | UnifiedMedia | `type: "image"` | + +### 5.2 마이그레이션 원칙 + +1. **비파괴적 전환**: 기존 데이터 구조 유지, 신규 필드 추가 방식 +2. **하위 호환성**: 기존 `widgetType` 필드는 유지, `unifiedType` 필드 추가 +3. **점진적 전환**: 화면 수정 시점에 자동 또는 수동 전환 + +--- + +## 6. 기대 효과 + +1. **컴포넌트 수 감소**: 68개 → **9개** (관리 포인트 87% 감소) +2. **Config Panel 통합**: 28개 → **1개** (DynamicConfigPanel) +3. **유연한 UI 변경**: 컴포넌트 교체 없이 속성 변경만으로 UI 모드 전환 가능 +4. **Low-Code 확장성**: 새로운 유형의 입력 방식이 필요할 때 코딩 없이 DB 설정만으로 추가 가능 + +--- + +## 7. 리스크 및 대응 방안 + +| 리스크 | 영향도 | 대응 방안 | +| :----------------------- | :----: | :-------------------------------- | +| 기존 화면 호환성 깨짐 | 높음 | 병행 운영 + 하위 호환성 유지 | +| 마이그레이션 데이터 손실 | 높음 | 백업 필수 + 롤백 스크립트 준비 | +| 개발자 학습 곡선 | 중간 | 상세 가이드 문서 + 예제 코드 제공 | +| 성능 저하 (동적 렌더링) | 중간 | 메모이제이션 + 지연 로딩 적용 | + +--- + +## 8. 현재 컴포넌트 매핑 분석 + +### 8.1 Registry 등록 컴포넌트 전수 조사 (44개) + +현재 `frontend/lib/registry/components/`에 등록된 모든 컴포넌트의 통합 가능 여부를 분석했습니다. + +#### UnifiedInput으로 통합 (4개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------- | :--------------- | :------------- | +| text-input | `type: "text"` | | +| number-input | `type: "number"` | | +| slider-basic | `type: "slider"` | 속성 추가 필요 | +| button-primary | `type: "button"` | 별도 검토 | + +#### UnifiedSelect로 통합 (8개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------------------ | :----------------------------------- | :------------- | +| select-basic | `mode: "dropdown"` | | +| checkbox-basic | `mode: "check"` | | +| radio-basic | `mode: "radio"` | | +| toggle-switch | `mode: "toggle"` | 속성 추가 필요 | +| autocomplete-search-input | `mode: "dropdown", searchable: true` | | +| entity-search-input | `source: "entity"` | | +| mail-recipient-selector | `mode: "multi", type: "email"` | 복합 컴포넌트 | +| location-swap-selector | `mode: "swap"` | 특수 UI | + +#### UnifiedDate로 통합 (1개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------ | :------------- | :--- | +| date-input | `type: "date"` | | + +#### UnifiedText로 통합 (1개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------- | :--------------- | :--- | +| textarea-basic | `mode: "simple"` | | + +#### UnifiedMedia로 통합 (3개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------ | :------------------------------ | :--- | +| file-upload | `type: "file"` | | +| image-widget | `type: "image"` | | +| image-display | `type: "image", readonly: true` | | + +#### UnifiedList로 통합 (8개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :-------------------- | :------------------------------------ | :------------ | +| table-list | `viewMode: "table"` | | +| card-display | `viewMode: "card"` | | +| repeater-field-group | `editable: true` | | +| modal-repeater-table | `viewMode: "table", modal: true` | | +| simple-repeater-table | `viewMode: "table", simple: true` | | +| repeat-screen-modal | `viewMode: "card", modal: true` | | +| table-search-widget | `viewMode: "table", searchable: true` | | +| tax-invoice-list | `viewMode: "table", bizType: "tax"` | 특수 비즈니스 | + +#### UnifiedLayout으로 통합 (4개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------------ | :-------------------------- | :------------- | +| split-panel-layout | `type: "split"` | | +| split-panel-layout2 | `type: "split", version: 2` | | +| divider-line | `type: "divider"` | 속성 추가 필요 | +| screen-split-panel | `type: "screen-embed"` | 화면 임베딩 | + +#### UnifiedGroup으로 통합 (5개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------------- | :--------------------- | :------------ | +| accordion-basic | `type: "accordion"` | | +| tabs | `type: "tabs"` | | +| section-paper | `type: "section"` | | +| section-card | `type: "card-section"` | | +| universal-form-modal | `type: "form-modal"` | 복합 컴포넌트 | + +#### UnifiedBiz로 통합 (7개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :-------------------- | :------------------------ | :--------------- | +| flow-widget | `type: "flow"` | 플로우 관리 | +| rack-structure | `type: "rack"` | 창고 렉 구조 | +| map | `type: "map"` | 지도 | +| numbering-rule | `type: "numbering"` | 채번 규칙 | +| category-manager | `type: "category"` | 카테고리 관리 | +| customer-item-mapping | `type: "mapping"` | 거래처-품목 매핑 | +| related-data-buttons | `type: "related-buttons"` | 연관 데이터 | + +#### 별도 검토 필요 (3개) + +| 현재 컴포넌트 | 문제점 | 제안 | +| :-------------------------- | :------------------- | :------------------------------ | +| conditional-container | 조건부 렌더링 로직 | 공통 속성으로 분리 | +| selected-items-detail-input | 복합 (선택+상세입력) | UnifiedList + UnifiedGroup 조합 | +| text-display | 읽기 전용 텍스트 | UnifiedInput (readonly: true) | + +### 8.2 매핑 분석 결과 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 전체 44개 컴포넌트 분석 결과 │ +├─────────────────────────────────────────────────────────┤ +│ ✅ 즉시 통합 가능 : 36개 (82%) │ +│ ⚠️ 속성 추가 후 통합 : 5개 (11%) │ +│ 🔄 별도 검토 필요 : 3개 (7%) │ +└─────────────────────────────────────────────────────────┘ +``` + +### 8.3 속성 확장 필요 사항 + +#### UnifiedInput 속성 확장 + +```typescript +// 기존 +type: "text" | "number" | "password"; + +// 확장 +type: "text" | "number" | "password" | "slider" | "color" | "button"; +``` + +#### UnifiedSelect 속성 확장 + +```typescript +// 기존 +mode: "dropdown" | "radio" | "check" | "tag"; + +// 확장 +mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap"; +``` + +#### UnifiedLayout 속성 확장 + +```typescript +// 기존 +type: "grid" | "split" | "flex"; + +// 확장 +type: "grid" | "split" | "flex" | "divider" | "screen-embed"; +``` + +### 8.4 조건부 렌더링 공통화 + +`conditional-container`의 기능을 모든 컴포넌트에서 사용 가능한 공통 속성으로 분리합니다. + +```typescript +// 모든 Unified 컴포넌트에 적용 가능한 공통 속성 +interface BaseUnifiedProps { + // ... 기존 속성 + + /** 조건부 렌더링 설정 */ + conditional?: { + enabled: boolean; + field: string; // 참조할 필드명 + operator: "=" | "!=" | ">" | "<" | "in" | "notIn"; + value: any; // 비교 값 + hideOnFalse?: boolean; // false일 때 숨김 (기본: true) + }; +} +``` + +--- + +## 9. 계층 구조(Hierarchy) 컴포넌트 전략 + +### 9.1 현재 계층 구조 지원 현황 + +DB 테이블 `cascading_hierarchy_group`에서 4가지 계층 타입을 지원합니다: + +| 타입 | 설명 | 예시 | +| :----------------- | :---------------------- | :--------------- | +| **MULTI_TABLE** | 다중 테이블 계층 | 국가 > 도시 > 구 | +| **SELF_REFERENCE** | 자기 참조 (단일 테이블) | 조직도, 메뉴 | +| **BOM** | 자재명세서 구조 | 부품 > 하위부품 | +| **TREE** | 일반 트리 | 카테고리 | + +### 9.2 통합 방안: UnifiedHierarchy 신설 (10번째 컴포넌트) + +계층 구조는 일반 입력/표시 위젯과 성격이 다르므로 **별도 컴포넌트로 분리**합니다. + +```typescript +interface UnifiedHierarchyProps { + /** 계층 유형 */ + type: "tree" | "org" | "bom" | "cascading"; + + /** 표시 방식 */ + viewMode: "tree" | "table" | "indent" | "dropdown"; + + /** 계층 그룹 코드 (cascading_hierarchy_group 연동) */ + source: string; + + /** 편집 가능 여부 */ + editable?: boolean; + + /** 드래그 정렬 가능 */ + draggable?: boolean; + + /** BOM 수량 표시 (BOM 타입 전용) */ + showQty?: boolean; + + /** 최대 레벨 제한 */ + maxLevel?: number; +} +``` + +### 9.3 활용 예시 + +| 설정 | 결과 | +| :---------------------------------------- | :------------------------- | +| `type: "tree", viewMode: "tree"` | 카테고리 트리뷰 | +| `type: "org", viewMode: "tree"` | 조직도 | +| `type: "bom", viewMode: "indent"` | BOM 들여쓰기 테이블 | +| `type: "cascading", viewMode: "dropdown"` | 연쇄 셀렉트 (국가>도시>구) | + +--- + +## 10. 최종 통합 컴포넌트 목록 (10개) + +| # | 컴포넌트 | 역할 | 커버 범위 | +| :-: | :------------------- | :------------- | :----------------------------------- | +| 1 | **UnifiedInput** | 단일 값 입력 | text, number, slider, button 등 | +| 2 | **UnifiedSelect** | 선택 입력 | dropdown, radio, checkbox, toggle 등 | +| 3 | **UnifiedDate** | 날짜/시간 입력 | date, datetime, time, range | +| 4 | **UnifiedText** | 다중 행 텍스트 | textarea, rich editor, markdown | +| 5 | **UnifiedMedia** | 파일/미디어 | file, image, video, audio | +| 6 | **UnifiedList** | 데이터 목록 | table, card, repeater, kanban | +| 7 | **UnifiedLayout** | 레이아웃 배치 | grid, split, flex, divider | +| 8 | **UnifiedGroup** | 콘텐츠 그룹화 | tabs, accordion, section, modal | +| 9 | **UnifiedBiz** | 비즈니스 특화 | flow, rack, map, numbering 등 | +| 10 | **UnifiedHierarchy** | 계층 구조 | tree, org, bom, cascading | + +--- + +## 11. 연쇄관계 관리 메뉴 통합 전략 + +### 11.1 현재 연쇄관계 관리 현황 + +**관리 메뉴**: `연쇄 드롭다운 통합 관리` (6개 탭) + +| 탭 | DB 테이블 | 실제 데이터 | 복잡도 | +| :--------------- | :--------------------------------------- | :---------: | :----: | +| 2단계 연쇄관계 | `cascading_relation` | 2건 | 낮음 | +| 다단계 계층 | `cascading_hierarchy_group/level` | 1건 | 높음 | +| 조건부 필터 | `cascading_condition` | 0건 | 중간 | +| 자동 입력 | `cascading_auto_fill_group/mapping` | 0건 | 낮음 | +| 상호 배제 | `cascading_mutual_exclusion` | 0건 | 낮음 | +| 카테고리 값 연쇄 | `category_value_cascading_group/mapping` | 2건 | 중간 | + +### 11.2 통합 방향: 속성 기반 vs 공통 정의 + +#### 판단 기준 + +| 기능 | 재사용 빈도 | 설정 복잡도 | 권장 방식 | +| :--------------- | :---------: | :---------: | :----------------------- | +| 2단계 연쇄 | 낮음 | 간단 | **속성에 inline 정의** | +| 다단계 계층 | 높음 | 복잡 | **공통 정의 유지** | +| 조건부 필터 | 낮음 | 간단 | **속성에 inline 정의** | +| 자동 입력 | 낮음 | 간단 | **속성에 inline 정의** | +| 상호 배제 | 낮음 | 간단 | **속성에 inline 정의** | +| 카테고리 값 연쇄 | 중간 | 중간 | **카테고리 관리와 통합** | + +### 11.3 속성 통합 설계 + +#### 2단계 연쇄 → UnifiedSelect 속성 + +```typescript +// AS-IS: 별도 관리 메뉴에서 정의 후 참조 + + +// TO-BE: 컴포넌트 속성에서 직접 정의 + +``` + +#### 조건부 필터 → 공통 conditional 속성 + +```typescript +// AS-IS: 별도 관리 메뉴에서 조건 정의 +// cascading_condition 테이블에 저장 + +// TO-BE: 모든 컴포넌트에 공통 속성으로 적용 + +``` + +#### 자동 입력 → autoFill 속성 + +```typescript +// AS-IS: cascading_auto_fill_group 테이블에 정의 + +// TO-BE: 컴포넌트 속성에서 직접 정의 + +``` + +#### 상호 배제 → mutualExclusion 속성 + +```typescript +// AS-IS: cascading_mutual_exclusion 테이블에 정의 + +// TO-BE: 컴포넌트 속성에서 직접 정의 + +``` + +### 11.4 관리 메뉴 정리 계획 + +| 현재 메뉴 | TO-BE | 비고 | +| :-------------------------- | :----------------------- | :-------------------- | +| **연쇄 드롭다운 통합 관리** | **삭제** | 6개 탭 전체 제거 | +| ├─ 2단계 연쇄관계 | UnifiedSelect 속성 | inline 정의 | +| ├─ 다단계 계층 | **테이블관리로 이동** | 복잡한 구조 유지 필요 | +| ├─ 조건부 필터 | 공통 conditional 속성 | 모든 컴포넌트에 적용 | +| ├─ 자동 입력 | autoFill 속성 | 컴포넌트별 정의 | +| ├─ 상호 배제 | mutualExclusion 속성 | 컴포넌트별 정의 | +| └─ 카테고리 값 연쇄 | **카테고리 관리로 이동** | 기존 메뉴 통합 | + +### 11.5 DB 테이블 정리 (Phase 5) + +| 테이블 | 조치 | 시점 | +| :--------------------------- | :----------------------- | :------ | +| `cascading_relation` | 마이그레이션 후 삭제 | Phase 5 | +| `cascading_condition` | 삭제 (데이터 없음) | Phase 5 | +| `cascading_auto_fill_*` | 삭제 (데이터 없음) | Phase 5 | +| `cascading_mutual_exclusion` | 삭제 (데이터 없음) | Phase 5 | +| `cascading_hierarchy_*` | **유지** | - | +| `category_value_cascading_*` | **유지** (카테고리 관리) | - | + +### 11.6 마이그레이션 스크립트 필요 항목 + +```sql +-- cascading_relation → 화면 레이아웃 데이터로 마이그레이션 +-- 기존 2건의 연쇄관계를 사용하는 화면을 찾아서 +-- 해당 컴포넌트의 cascading 속성으로 변환 + +-- 예시: WAREHOUSE_LOCATION 연쇄관계 +-- 이 관계를 사용하는 화면의 컴포넌트에 +-- cascading: { parentField: "warehouse_code", filterColumn: "warehouse_code" } +-- 속성 추가 +``` + +--- + +## 12. 최종 아키텍처 요약 + +### 12.1 통합 컴포넌트 (10개) + +| # | 컴포넌트 | 역할 | +| :-: | :------------------- | :--------------------------------------- | +| 1 | **UnifiedInput** | 단일 값 입력 (text, number, slider 등) | +| 2 | **UnifiedSelect** | 선택 입력 (dropdown, radio, checkbox 등) | +| 3 | **UnifiedDate** | 날짜/시간 입력 | +| 4 | **UnifiedText** | 다중 행 텍스트 (textarea, rich editor) | +| 5 | **UnifiedMedia** | 파일/미디어 (file, image) | +| 6 | **UnifiedList** | 데이터 목록 (table, card, repeater) | +| 7 | **UnifiedLayout** | 레이아웃 배치 (grid, split, flex) | +| 8 | **UnifiedGroup** | 콘텐츠 그룹화 (tabs, accordion, section) | +| 9 | **UnifiedBiz** | 비즈니스 특화 (flow, rack, map 등) | +| 10 | **UnifiedHierarchy** | 계층 구조 (tree, org, bom, cascading) | + +### 12.2 공통 속성 (모든 컴포넌트에 적용) + +```typescript +interface BaseUnifiedProps { + // 기본 속성 + id: string; + label?: string; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + + // 스타일 + style?: ComponentStyle; + className?: string; + + // 조건부 렌더링 (conditional-container 대체) + conditional?: { + enabled: boolean; + field: string; + operator: + | "=" + | "!=" + | ">" + | "<" + | "in" + | "notIn" + | "isEmpty" + | "isNotEmpty"; + value: any; + action: "show" | "hide" | "disable" | "enable"; + }; + + // 자동 입력 (autoFill 대체) + autoFill?: { + enabled: boolean; + sourceTable: string; + filterColumn: string; + userField: "companyCode" | "userId" | "deptCode"; + displayColumn: string; + }; + + // 유효성 검사 + validation?: ValidationRule[]; +} +``` + +### 12.3 UnifiedSelect 전용 속성 + +```typescript +interface UnifiedSelectProps extends BaseUnifiedProps { + // 표시 모드 + mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap"; + + // 데이터 소스 + source: "static" | "code" | "db" | "api" | "entity"; + + // static 소스 + options?: Array<{ value: string; label: string }>; + + // db 소스 + table?: string; + valueColumn?: string; + labelColumn?: string; + + // code 소스 + codeGroup?: string; + + // 연쇄 관계 (cascading_relation 대체) + cascading?: { + parentField: string; // 부모 필드명 + filterColumn: string; // 필터링할 컬럼 + clearOnChange?: boolean; // 부모 변경 시 초기화 + }; + + // 상호 배제 (mutual_exclusion 대체) + mutualExclusion?: { + enabled: boolean; + targetField: string; // 상호 배제 대상 + type: "exclusive" | "inclusive"; + }; + + // 다중 선택 + multiple?: boolean; + maxSelect?: number; +} +``` + +### 12.4 관리 메뉴 정리 결과 + +| AS-IS | TO-BE | +| :---------------------------- | :----------------------------------- | +| 연쇄 드롭다운 통합 관리 (6탭) | **삭제** | +| - 2단계 연쇄관계 | → UnifiedSelect.cascading 속성 | +| - 다단계 계층 | → 테이블관리 > 계층 구조 설정 | +| - 조건부 필터 | → 공통 conditional 속성 | +| - 자동 입력 | → 공통 autoFill 속성 | +| - 상호 배제 | → UnifiedSelect.mutualExclusion 속성 | +| - 카테고리 값 연쇄 | → 카테고리 관리와 통합 | + +--- + +## 13. 주의사항 + +> **기존 컴포넌트 삭제 금지** +> 모든 Phase에서 기존 컴포넌트는 삭제하지 않고 **병행 운영**합니다. +> 레거시 정리는 Phase 5에서 충분한 안정화 후 별도 검토합니다. + +> **연쇄관계 마이그레이션 필수** +> 관리 메뉴 삭제 전 기존 `cascading_relation` 데이터(2건)를 +> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다. diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index b9528ee0..5b5eb7d7 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -14,10 +14,12 @@ "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cors": "^2.8.5", + "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", + "html-to-docx": "^1.8.0", "iconv-lite": "^0.7.0", "imap": "^0.8.19", "joi": "^17.11.0", @@ -2256,6 +2258,93 @@ "node": ">= 8" } }, + "node_modules/@oozcitak/dom": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.6.tgz", + "integrity": "sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g==", + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "1.0.5", + "@oozcitak/url": "1.0.0", + "@oozcitak/util": "8.3.4" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@oozcitak/infra": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.5.tgz", + "integrity": "sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ==", + "license": "MIT", + "dependencies": { + "@oozcitak/util": "8.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@oozcitak/infra/node_modules/@oozcitak/util": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz", + "integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@oozcitak/url": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.0.tgz", + "integrity": "sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ==", + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "1.0.3", + "@oozcitak/util": "1.0.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@oozcitak/url/node_modules/@oozcitak/infra": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.3.tgz", + "integrity": "sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag==", + "license": "MIT", + "dependencies": { + "@oozcitak/util": "1.0.1" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@oozcitak/url/node_modules/@oozcitak/infra/node_modules/@oozcitak/util": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.1.tgz", + "integrity": "sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@oozcitak/url/node_modules/@oozcitak/util": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.2.tgz", + "integrity": "sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@oozcitak/util": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.4.tgz", + "integrity": "sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", @@ -4326,6 +4415,12 @@ "node": ">=8" } }, + "node_modules/browser-split": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz", + "integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.26.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", @@ -4521,6 +4616,15 @@ "node": ">=6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001745", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", @@ -5202,6 +5306,56 @@ "node": ">=6.0.0" } }, + "node_modules/docx": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz", + "integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^24.0.1", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "24.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", + "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -5216,6 +5370,11 @@ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, + "node_modules/dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" + }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", @@ -5349,6 +5508,27 @@ "node": ">=8.10.0" } }, + "node_modules/ent": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", + "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "punycode": "^1.4.1", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ent/node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "license": "MIT" + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -5361,6 +5541,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz", + "integrity": "sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==", + "dependencies": { + "camelize": "^1.0.0", + "string-template": "~0.2.0", + "xtend": "~4.0.0" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5643,6 +5833,14 @@ "node": ">= 0.6" } }, + "node_modules/ev-store": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ev-store/-/ev-store-7.0.0.tgz", + "integrity": "sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==", + "dependencies": { + "individual": "^3.0.0" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -6279,6 +6477,16 @@ "node": "*" } }, + "node_modules/global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "license": "MIT", + "dependencies": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -6413,6 +6621,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -6443,6 +6661,22 @@ "node": ">=16.0.0" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -6450,6 +6684,27 @@ "dev": true, "license": "MIT" }, + "node_modules/html-to-docx": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/html-to-docx/-/html-to-docx-1.8.0.tgz", + "integrity": "sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA==", + "license": "MIT", + "dependencies": { + "@oozcitak/dom": "1.15.6", + "@oozcitak/util": "8.3.4", + "color-name": "^1.1.4", + "html-entities": "^2.3.3", + "html-to-vdom": "^0.7.0", + "image-size": "^1.0.0", + "image-to-base64": "^2.2.0", + "jszip": "^3.7.1", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "nanoid": "^3.1.25", + "virtual-dom": "^2.1.1", + "xmlbuilder2": "2.1.2" + } + }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", @@ -6466,6 +6721,106 @@ "node": ">=14" } }, + "node_modules/html-to-vdom": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/html-to-vdom/-/html-to-vdom-0.7.0.tgz", + "integrity": "sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ==", + "license": "ISC", + "dependencies": { + "ent": "^2.0.0", + "htmlparser2": "^3.8.2" + } + }, + "node_modules/html-to-vdom/node_modules/dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + } + }, + "node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-to-vdom/node_modules/domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "license": "BSD-2-Clause" + }, + "node_modules/html-to-vdom/node_modules/domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "1" + } + }, + "node_modules/html-to-vdom/node_modules/domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "node_modules/html-to-vdom/node_modules/entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "license": "BSD-2-Clause" + }, + "node_modules/html-to-vdom/node_modules/htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", + "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", + "license": "MIT", + "dependencies": { + "domelementtype": "^1.3.1", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.1.1" + } + }, + "node_modules/html-to-vdom/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -6590,6 +6945,30 @@ "dev": true, "license": "ISC" }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/image-to-base64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/image-to-base64/-/image-to-base64-2.2.0.tgz", + "integrity": "sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.0" + } + }, "node_modules/imap": { "version": "0.8.19", "resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz", @@ -6626,6 +7005,12 @@ "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", "license": "MIT" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6673,6 +7058,11 @@ "node": ">=0.8.19" } }, + "node_modules/individual": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/individual/-/individual-3.0.0.tgz", + "integrity": "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==" + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -6854,6 +7244,15 @@ "node": ">=0.12.0" } }, + "node_modules/is-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", + "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -7696,6 +8095,18 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/jwa": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", @@ -7812,6 +8223,15 @@ "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8177,6 +8597,21 @@ "node": ">=6" } }, + "node_modules/min-document": { + "version": "2.19.2", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz", + "integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==", + "license": "MIT", + "dependencies": { + "dom-walk": "^0.1.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -8300,6 +8735,24 @@ "node": ">=12" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/native-duplexpair": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", @@ -8329,6 +8782,12 @@ "dev": true, "license": "MIT" }, + "node_modules/next-tick": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.2.2.tgz", + "integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==", + "license": "MIT" + }, "node_modules/node-cron": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", @@ -8670,6 +9129,12 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parchment": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", @@ -9179,6 +9644,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9595,6 +10069,23 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -9610,6 +10101,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", + "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", + "license": "BlueOak-1.0.0" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -9744,6 +10241,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -10020,6 +10523,11 @@ "node": ">=10" } }, + "node_modules/string-template": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz", + "integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -10685,6 +11193,22 @@ "node": ">= 0.8" } }, + "node_modules/virtual-dom": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/virtual-dom/-/virtual-dom-2.1.1.tgz", + "integrity": "sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==", + "license": "MIT", + "dependencies": { + "browser-split": "0.0.1", + "error": "^4.3.0", + "ev-store": "^7.0.0", + "global": "^4.3.0", + "is-object": "^1.0.1", + "next-tick": "^0.2.2", + "x-is-array": "0.1.0", + "x-is-string": "0.1.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -10862,6 +11386,80 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/x-is-array": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/x-is-array/-/x-is-array-0.1.0.tgz", + "integrity": "sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA==" + }, + "node_modules/x-is-string": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz", + "integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w==" + }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/xmlbuilder2": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-2.1.2.tgz", + "integrity": "sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==", + "license": "MIT", + "dependencies": { + "@oozcitak/dom": "1.15.5", + "@oozcitak/infra": "1.0.5", + "@oozcitak/util": "8.3.3" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xmlbuilder2/node_modules/@oozcitak/dom": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.5.tgz", + "integrity": "sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A==", + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "1.0.5", + "@oozcitak/url": "1.0.0", + "@oozcitak/util": "8.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xmlbuilder2/node_modules/@oozcitak/dom/node_modules/@oozcitak/util": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz", + "integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/xmlbuilder2/node_modules/@oozcitak/util": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.3.tgz", + "integrity": "sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index bacd9fb3..e078043c 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -28,10 +28,12 @@ "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cors": "^2.8.5", + "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", + "html-to-docx": "^1.8.0", "iconv-lite": "^0.7.0", "imap": "^0.8.19", "joi": "^17.11.0", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 652677ca..e928f96c 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -80,6 +80,7 @@ import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자 import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리 import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리 import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리 +import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -255,6 +256,7 @@ app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리 app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리 app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리 +app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index c8e8ce82..231a7cdc 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3394,13 +3394,23 @@ export async function copyMenu( } : undefined; + // 추가 복사 옵션 (카테고리, 코드, 채번규칙 등) + const additionalCopyOptions = req.body.additionalCopyOptions + ? { + copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true, + copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true, + copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true, + } + : undefined; + // 메뉴 복사 실행 const menuCopyService = new MenuCopyService(); const result = await menuCopyService.copyMenu( parseInt(menuObjid, 10), targetCompanyCode, userId, - screenNameConfig + screenNameConfig, + additionalCopyOptions ); logger.info("✅ 메뉴 복사 API 성공"); diff --git a/backend-node/src/controllers/cascadingRelationController.ts b/backend-node/src/controllers/cascadingRelationController.ts index 27f03c71..c40c6aa5 100644 --- a/backend-node/src/controllers/cascadingRelationController.ts +++ b/backend-node/src/controllers/cascadingRelationController.ts @@ -662,6 +662,10 @@ export const getParentOptions = async ( /** * 연쇄 관계로 자식 옵션 조회 * 실제 연쇄 드롭다운에서 사용하는 API + * + * 다중 부모값 지원: + * - parentValue: 단일 값 (예: "공정검사") + * - parentValues: 다중 값 (예: "공정검사,출하검사" 또는 배열) */ export const getCascadingOptions = async ( req: AuthenticatedRequest, @@ -669,10 +673,26 @@ export const getCascadingOptions = async ( ) => { try { const { code } = req.params; - const { parentValue } = req.query; + const { parentValue, parentValues } = req.query; const companyCode = req.user?.companyCode || "*"; - if (!parentValue) { + // 다중 부모값 파싱 + let parentValueArray: string[] = []; + + if (parentValues) { + // parentValues가 있으면 우선 사용 (다중 선택) + if (Array.isArray(parentValues)) { + parentValueArray = parentValues.map(v => String(v)); + } else { + // 콤마로 구분된 문자열 + parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v); + } + } else if (parentValue) { + // 기존 단일 값 호환 + parentValueArray = [String(parentValue)]; + } + + if (parentValueArray.length === 0) { return res.json({ success: true, data: [], @@ -714,13 +734,17 @@ export const getCascadingOptions = async ( const relation = relationResult.rows[0]; - // 자식 옵션 조회 + // 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용 + // SQL Injection 방지를 위해 파라미터화된 쿼리 사용 + const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', '); + let optionsQuery = ` - SELECT + SELECT DISTINCT ${relation.child_value_column} as value, - ${relation.child_label_column} as label + ${relation.child_label_column} as label, + ${relation.child_filter_column} as parent_value FROM ${relation.child_table} - WHERE ${relation.child_filter_column} = $1 + WHERE ${relation.child_filter_column} IN (${placeholders}) `; // 멀티테넌시 적용 (테이블에 company_code가 있는 경우) @@ -730,7 +754,8 @@ export const getCascadingOptions = async ( [relation.child_table] ); - const optionsParams: any[] = [parentValue]; + const optionsParams: any[] = [...parentValueArray]; + let paramIndex = parentValueArray.length + 1; // company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만 if ( @@ -738,8 +763,9 @@ export const getCascadingOptions = async ( tableInfoResult.rowCount > 0 && companyCode !== "*" ) { - optionsQuery += ` AND company_code = $2`; + optionsQuery += ` AND company_code = $${paramIndex}`; optionsParams.push(companyCode); + paramIndex++; } // 정렬 @@ -751,9 +777,9 @@ export const getCascadingOptions = async ( const optionsResult = await pool.query(optionsQuery, optionsParams); - logger.info("연쇄 옵션 조회", { + logger.info("연쇄 옵션 조회 (다중 부모값 지원)", { relationCode: code, - parentValue, + parentValues: parentValueArray, optionsCount: optionsResult.rowCount, }); diff --git a/backend-node/src/controllers/categoryValueCascadingController.ts b/backend-node/src/controllers/categoryValueCascadingController.ts new file mode 100644 index 00000000..66250bf9 --- /dev/null +++ b/backend-node/src/controllers/categoryValueCascadingController.ts @@ -0,0 +1,1062 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +const pool = getPool(); + +// ============================================ +// 카테고리 값 연쇄관계 그룹 CRUD +// ============================================ + +/** + * 카테고리 값 연쇄관계 그룹 목록 조회 + */ +export const getCategoryValueCascadingGroups = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive } = req.query; + + let query = ` + SELECT + group_id, + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active, + created_by, + created_date, + updated_by, + updated_date + FROM category_value_cascading_group + WHERE 1=1 + `; + + const params: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 필터링 + if (companyCode !== "*") { + query += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + params.push(companyCode); + paramIndex++; + } + + if (isActive !== undefined) { + query += ` AND is_active = $${paramIndex}`; + params.push(isActive); + paramIndex++; + } + + query += ` ORDER BY relation_name ASC`; + + const result = await pool.query(query, params); + + logger.info("카테고리 값 연쇄관계 그룹 목록 조회", { + companyCode, + count: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 목록 조회 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 상세 조회 + */ +export const getCategoryValueCascadingGroupById = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 그룹 정보 조회 + let groupQuery = ` + SELECT + group_id, + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active + FROM category_value_cascading_group + WHERE group_id = $1 + `; + + const groupParams: any[] = [groupId]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + // 매핑 정보 조회 + const mappingQuery = ` + SELECT + mapping_id, + parent_value_code, + parent_value_label, + child_value_code, + child_value_label, + display_order, + is_active + FROM category_value_cascading_mapping + WHERE group_id = $1 AND is_active = 'Y' + ORDER BY parent_value_code, display_order, child_value_label + `; + + const mappingResult = await pool.query(mappingQuery, [groupId]); + + // 부모 값별로 자식 값 그룹화 + const mappingsByParent: Record = {}; + for (const row of mappingResult.rows) { + const parentKey = row.parent_value_code; + if (!mappingsByParent[parentKey]) { + mappingsByParent[parentKey] = []; + } + mappingsByParent[parentKey].push({ + childValueCode: row.child_value_code, + childValueLabel: row.child_value_label, + displayOrder: row.display_order, + }); + } + + return res.json({ + success: true, + data: { + ...groupResult.rows[0], + mappings: mappingResult.rows, + mappingsByParent, + }, + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 상세 조회 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 관계 코드로 조회 + */ +export const getCategoryValueCascadingByCode = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let query = ` + SELECT + group_id, + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const params: any[] = [code]; + + if (companyCode !== "*") { + query += ` AND (company_code = $2 OR company_code = '*')`; + params.push(companyCode); + } + + query += ` LIMIT 1`; + + const result = await pool.query(query, params); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 코드 조회 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 생성 + */ +export const createCategoryValueCascadingGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + const { + relationCode, + relationName, + description, + parentTableName, + parentColumnName, + parentMenuObjid, + childTableName, + childColumnName, + childMenuObjid, + clearOnParentChange = true, + showGroupLabel = true, + emptyParentMessage, + noOptionsMessage, + } = req.body; + + // 필수 필드 검증 + if ( + !relationCode || + !relationName || + !parentTableName || + !parentColumnName || + !childTableName || + !childColumnName + ) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + // 중복 코드 체크 + const duplicateCheck = await pool.query( + `SELECT group_id FROM category_value_cascading_group + WHERE relation_code = $1 AND (company_code = $2 OR company_code = '*')`, + [relationCode, companyCode] + ); + + if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) { + return res.status(400).json({ + success: false, + message: "이미 존재하는 관계 코드입니다.", + }); + } + + const query = ` + INSERT INTO category_value_cascading_group ( + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active, + created_by, + created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 'Y', $15, NOW()) + RETURNING * + `; + + const result = await pool.query(query, [ + relationCode, + relationName, + description || null, + parentTableName, + parentColumnName, + parentMenuObjid || null, + childTableName, + childColumnName, + childMenuObjid || null, + clearOnParentChange ? "Y" : "N", + showGroupLabel ? "Y" : "N", + emptyParentMessage || "상위 항목을 먼저 선택하세요", + noOptionsMessage || "선택 가능한 항목이 없습니다", + companyCode, + userId, + ]); + + logger.info("카테고리 값 연쇄관계 그룹 생성", { + groupId: result.rows[0].group_id, + relationCode, + companyCode, + userId, + }); + + return res.status(201).json({ + success: true, + data: result.rows[0], + message: "카테고리 값 연쇄관계 그룹이 생성되었습니다.", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 생성 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 수정 + */ +export const updateCategoryValueCascadingGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + const { + relationName, + description, + parentTableName, + parentColumnName, + parentMenuObjid, + childTableName, + childColumnName, + childMenuObjid, + clearOnParentChange, + showGroupLabel, + emptyParentMessage, + noOptionsMessage, + isActive, + } = req.body; + + // 권한 체크 + const existingCheck = await pool.query( + `SELECT group_id, company_code FROM category_value_cascading_group WHERE group_id = $1`, + [groupId] + ); + + if (existingCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + const existingCompanyCode = existingCheck.rows[0].company_code; + if ( + companyCode !== "*" && + existingCompanyCode !== companyCode && + existingCompanyCode !== "*" + ) { + return res.status(403).json({ + success: false, + message: "수정 권한이 없습니다.", + }); + } + + const query = ` + UPDATE category_value_cascading_group SET + relation_name = COALESCE($1, relation_name), + description = COALESCE($2, description), + parent_table_name = COALESCE($3, parent_table_name), + parent_column_name = COALESCE($4, parent_column_name), + parent_menu_objid = COALESCE($5, parent_menu_objid), + child_table_name = COALESCE($6, child_table_name), + child_column_name = COALESCE($7, child_column_name), + child_menu_objid = COALESCE($8, child_menu_objid), + clear_on_parent_change = COALESCE($9, clear_on_parent_change), + show_group_label = COALESCE($10, show_group_label), + empty_parent_message = COALESCE($11, empty_parent_message), + no_options_message = COALESCE($12, no_options_message), + is_active = COALESCE($13, is_active), + updated_by = $14, + updated_date = NOW() + WHERE group_id = $15 + RETURNING * + `; + + const result = await pool.query(query, [ + relationName, + description, + parentTableName, + parentColumnName, + parentMenuObjid, + childTableName, + childColumnName, + childMenuObjid, + clearOnParentChange !== undefined + ? clearOnParentChange + ? "Y" + : "N" + : null, + showGroupLabel !== undefined ? (showGroupLabel ? "Y" : "N") : null, + emptyParentMessage, + noOptionsMessage, + isActive !== undefined ? (isActive ? "Y" : "N") : null, + userId, + groupId, + ]); + + logger.info("카테고리 값 연쇄관계 그룹 수정", { + groupId, + companyCode, + userId, + }); + + return res.json({ + success: true, + data: result.rows[0], + message: "카테고리 값 연쇄관계 그룹이 수정되었습니다.", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 수정 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 삭제 + */ +export const deleteCategoryValueCascadingGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + // 권한 체크 + const existingCheck = await pool.query( + `SELECT group_id, company_code FROM category_value_cascading_group WHERE group_id = $1`, + [groupId] + ); + + if (existingCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + const existingCompanyCode = existingCheck.rows[0].company_code; + if ( + companyCode !== "*" && + existingCompanyCode !== companyCode && + existingCompanyCode !== "*" + ) { + return res.status(403).json({ + success: false, + message: "삭제 권한이 없습니다.", + }); + } + + // 소프트 삭제 + await pool.query( + `UPDATE category_value_cascading_group + SET is_active = 'N', updated_by = $1, updated_date = NOW() + WHERE group_id = $2`, + [userId, groupId] + ); + + logger.info("카테고리 값 연쇄관계 그룹 삭제", { + groupId, + companyCode, + userId, + }); + + return res.json({ + success: true, + message: "카테고리 값 연쇄관계 그룹이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 삭제 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ============================================ +// 카테고리 값 연쇄관계 매핑 CRUD +// ============================================ + +/** + * 매핑 일괄 저장 (기존 매핑 교체) + */ +export const saveCategoryValueCascadingMappings = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { mappings } = req.body; // [{ parentValueCode, parentValueLabel, childValueCode, childValueLabel, displayOrder }] + + if (!Array.isArray(mappings)) { + return res.status(400).json({ + success: false, + message: "mappings는 배열이어야 합니다.", + }); + } + + // 그룹 존재 확인 + const groupCheck = await pool.query( + `SELECT group_id FROM category_value_cascading_group WHERE group_id = $1 AND is_active = 'Y'`, + [groupId] + ); + + if (groupCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + // 트랜잭션으로 처리 + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 기존 매핑 삭제 (하드 삭제) + await client.query( + `DELETE FROM category_value_cascading_mapping WHERE group_id = $1`, + [groupId] + ); + + // 새 매핑 삽입 + if (mappings.length > 0) { + const insertQuery = ` + INSERT INTO category_value_cascading_mapping ( + group_id, parent_value_code, parent_value_label, + child_value_code, child_value_label, display_order, + company_code, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', NOW()) + `; + + for (const mapping of mappings) { + await client.query(insertQuery, [ + groupId, + mapping.parentValueCode, + mapping.parentValueLabel || null, + mapping.childValueCode, + mapping.childValueLabel || null, + mapping.displayOrder || 0, + companyCode, + ]); + } + } + + await client.query("COMMIT"); + + logger.info("카테고리 값 연쇄관계 매핑 저장", { + groupId, + mappingCount: mappings.length, + companyCode, + }); + + return res.json({ + success: true, + message: `${mappings.length}개의 매핑이 저장되었습니다.`, + }); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 매핑 저장 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 매핑 저장에 실패했습니다.", + error: error.message, + }); + } +}; + +// ============================================ +// 연쇄 옵션 조회 (실제 드롭다운에서 사용) +// ============================================ + +/** + * 카테고리 값 연쇄 옵션 조회 + * 부모 값(들)에 해당하는 자식 카테고리 값 목록 반환 + * 다중 부모값 지원 + */ +export const getCategoryValueCascadingOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const { parentValue, parentValues } = req.query; + const companyCode = req.user?.companyCode || "*"; + + // 다중 부모값 파싱 + let parentValueArray: string[] = []; + + if (parentValues) { + if (Array.isArray(parentValues)) { + parentValueArray = parentValues.map((v) => String(v)); + } else { + parentValueArray = String(parentValues) + .split(",") + .map((v) => v.trim()) + .filter((v) => v); + } + } else if (parentValue) { + parentValueArray = [String(parentValue)]; + } + + if (parentValueArray.length === 0) { + return res.json({ + success: true, + data: [], + message: "부모 값이 없습니다.", + }); + } + + // 관계 정보 조회 + let groupQuery = ` + SELECT group_id, show_group_label + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const groupParams: any[] = [code]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + groupQuery += ` LIMIT 1`; + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + const group = groupResult.rows[0]; + + // 매핑된 자식 값 조회 (다중 부모값에 대해 IN 절 사용) + const placeholders = parentValueArray + .map((_, idx) => `$${idx + 2}`) + .join(", "); + + const optionsQuery = ` + SELECT DISTINCT + child_value_code as value, + child_value_label as label, + parent_value_code as parent_value, + parent_value_label as parent_label, + display_order + FROM category_value_cascading_mapping + WHERE group_id = $1 + AND parent_value_code IN (${placeholders}) + AND is_active = 'Y' + ORDER BY parent_value_code, display_order, child_value_label + `; + + const optionsResult = await pool.query(optionsQuery, [ + group.group_id, + ...parentValueArray, + ]); + + logger.info("카테고리 값 연쇄 옵션 조회", { + relationCode: code, + parentValues: parentValueArray, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + showGroupLabel: group.show_group_label === "Y", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄 옵션 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 부모 카테고리 값 목록 조회 + */ +export const getCategoryValueCascadingParentOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 관계 정보 조회 + let groupQuery = ` + SELECT + group_id, + parent_table_name, + parent_column_name, + parent_menu_objid + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const groupParams: any[] = [code]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + groupQuery += ` LIMIT 1`; + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + const group = groupResult.rows[0]; + + // 부모 카테고리 값 조회 (table_column_category_values에서) + let optionsQuery = ` + SELECT + value_code as value, + value_label as label, + value_order as display_order + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + `; + + const optionsParams: any[] = [ + group.parent_table_name, + group.parent_column_name, + ]; + let paramIndex = 3; + + // 메뉴 스코프 적용 + if (group.parent_menu_objid) { + optionsQuery += ` AND menu_objid = $${paramIndex}`; + optionsParams.push(group.parent_menu_objid); + paramIndex++; + } + + // 멀티테넌시 적용 + if (companyCode !== "*") { + optionsQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + optionsParams.push(companyCode); + } + + optionsQuery += ` ORDER BY value_order, value_label`; + + const optionsResult = await pool.query(optionsQuery, optionsParams); + + logger.info("부모 카테고리 값 조회", { + relationCode: code, + tableName: group.parent_table_name, + columnName: group.parent_column_name, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + }); + } catch (error: any) { + logger.error("부모 카테고리 값 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "부모 카테고리 값 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 자식 카테고리 값 목록 조회 (매핑 설정 UI용) + */ +export const getCategoryValueCascadingChildOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 관계 정보 조회 + let groupQuery = ` + SELECT + group_id, + child_table_name, + child_column_name, + child_menu_objid + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const groupParams: any[] = [code]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + groupQuery += ` LIMIT 1`; + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + const group = groupResult.rows[0]; + + // 자식 카테고리 값 조회 (table_column_category_values에서) + let optionsQuery = ` + SELECT + value_code as value, + value_label as label, + value_order as display_order + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + `; + + const optionsParams: any[] = [ + group.child_table_name, + group.child_column_name, + ]; + let paramIndex = 3; + + // 메뉴 스코프 적용 + if (group.child_menu_objid) { + optionsQuery += ` AND menu_objid = $${paramIndex}`; + optionsParams.push(group.child_menu_objid); + paramIndex++; + } + + // 멀티테넌시 적용 + if (companyCode !== "*") { + optionsQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + optionsParams.push(companyCode); + } + + optionsQuery += ` ORDER BY value_order, value_label`; + + const optionsResult = await pool.query(optionsQuery, optionsParams); + + logger.info("자식 카테고리 값 조회", { + relationCode: code, + tableName: group.child_table_name, + columnName: group.child_column_name, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + }); + } catch (error: any) { + logger.error("자식 카테고리 값 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "자식 카테고리 값 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회 + * (테이블 목록에서 코드→라벨 변환에 사용) + */ +export const getCategoryValueCascadingMappingsByTable = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { tableName } = req.params; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName) { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + }); + } + + // 해당 테이블이 자식 테이블인 연쇄관계 그룹 찾기 + let groupQuery = ` + SELECT + group_id, + relation_code, + child_column_name + FROM category_value_cascading_group + WHERE child_table_name = $1 + AND is_active = 'Y' + `; + const groupParams: any[] = [tableName]; + let paramIndex = 2; + + // 멀티테넌시 적용 + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + groupParams.push(companyCode); + } + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + // 연쇄관계가 없으면 빈 객체 반환 + return res.json({ + success: true, + data: {}, + }); + } + + // 각 그룹의 매핑 조회 + const mappings: Record> = {}; + + for (const group of groupResult.rows) { + const mappingQuery = ` + SELECT DISTINCT + child_value_code as code, + child_value_label as label + FROM category_value_cascading_mapping + WHERE group_id = $1 + AND is_active = 'Y' + ORDER BY child_value_label + `; + + const mappingResult = await pool.query(mappingQuery, [group.group_id]); + + if (mappingResult.rowCount && mappingResult.rowCount > 0) { + mappings[group.child_column_name] = mappingResult.rows; + } + } + + logger.info("테이블별 연쇄관계 매핑 조회", { + tableName, + groupCount: groupResult.rowCount, + columnMappings: Object.keys(mappings), + }); + + return res.json({ + success: true, + data: mappings, + }); + } catch (error: any) { + logger.error("테이블별 연쇄관계 매핑 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄관계 매핑 조회에 실패했습니다.", + error: error.message, + }); + } +}; diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index f9162016..a2e8e8a9 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -12,6 +12,22 @@ import { } from "../types/report"; import path from "path"; import fs from "fs"; +import { + Document, + Packer, + Paragraph, + TextRun, + ImageRun, + Table, + TableRow, + TableCell, + WidthType, + AlignmentType, + VerticalAlign, + BorderStyle, + PageOrientation, + convertMillimetersToTwip, +} from "docx"; export class ReportController { /** @@ -207,11 +223,31 @@ export class ReportController { }); } - // components JSON 파싱 - const layoutData = { - ...layout, - components: layout.components ? JSON.parse(layout.components) : [], - }; + // components 컬럼에서 JSON 파싱 + const parsedComponents = layout.components + ? JSON.parse(layout.components) + : null; + + let layoutData; + // 새 구조 (layoutConfig.pages)인지 확인 + if ( + parsedComponents && + parsedComponents.pages && + Array.isArray(parsedComponents.pages) + ) { + // pages 배열을 직접 포함하여 반환 + layoutData = { + ...layout, + pages: parsedComponents.pages, + components: [], // 호환성을 위해 빈 배열 + }; + } else { + // 기존 구조: components 배열 + layoutData = { + ...layout, + components: parsedComponents || [], + }; + } return res.json({ success: true, @@ -232,16 +268,15 @@ export class ReportController { const data: SaveLayoutRequest = req.body; const userId = (req as any).user?.userId || "SYSTEM"; - // 필수 필드 검증 + // 필수 필드 검증 (페이지 기반 구조) if ( - !data.canvasWidth || - !data.canvasHeight || - !data.pageOrientation || - !data.components + !data.layoutConfig || + !data.layoutConfig.pages || + data.layoutConfig.pages.length === 0 ) { return res.status(400).json({ success: false, - message: "필수 레이아웃 정보가 누락되었습니다.", + message: "레이아웃 설정이 필요합니다.", }); } @@ -534,6 +569,2226 @@ export class ReportController { return next(error); } } + + /** + * 컴포넌트 데이터를 WORD(DOCX)로 변환 + * POST /api/admin/reports/export-word + */ + async exportToWord(req: Request, res: Response, next: NextFunction) { + try { + const { layoutConfig, queryResults, fileName = "리포트" } = req.body; + + if (!layoutConfig || !layoutConfig.pages) { + return res.status(400).json({ + success: false, + message: "레이아웃 데이터가 필요합니다.", + }); + } + + // mm를 twip으로 변환 + const mmToTwip = (mm: number) => convertMillimetersToTwip(mm); + // px를 twip으로 변환 (1px = 15twip at 96DPI) + const pxToTwip = (px: number) => Math.round(px * 15); + + // 쿼리 결과 맵 + const queryResultsMap: Record< + string, + { fields: string[]; rows: Record[] } + > = queryResults || {}; + + // 컴포넌트 값 가져오기 + const getComponentValue = (component: any): string => { + if (component.queryId && component.fieldName) { + const queryResult = queryResultsMap[component.queryId]; + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + const value = queryResult.rows[0][component.fieldName]; + if (value !== null && value !== undefined) { + return String(value); + } + } + return `{${component.fieldName}}`; + } + return component.defaultValue || ""; + }; + + // px → half-point 변환 (1px = 0.75pt, Word는 half-pt 단위 사용) + // px * 0.75 * 2 = px * 1.5 + const pxToHalfPt = (px: number) => Math.round(px * 1.5); + + // 셀 내용 생성 헬퍼 함수 (가로 배치용) + const createCellContent = ( + component: any, + displayValue: string, + pxToHalfPtFn: (px: number) => number, + pxToTwipFn: (px: number) => number, + queryResultsMapRef: Record< + string, + { fields: string[]; rows: Record[] } + >, + AlignmentTypeRef: typeof AlignmentType, + VerticalAlignRef: typeof VerticalAlign, + BorderStyleRef: typeof BorderStyle, + ParagraphRef: typeof Paragraph, + TextRunRef: typeof TextRun, + ImageRunRef: typeof ImageRun, + TableRef: typeof Table, + TableRowRef: typeof TableRow, + TableCellRef: typeof TableCell, + pageIndex: number = 0, + totalPages: number = 1 + ): (Paragraph | Table)[] => { + const result: (Paragraph | Table)[] = []; + + // Text/Label + if (component.type === "text" || component.type === "label") { + const fontSizeHalfPt = pxToHalfPtFn(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentTypeRef.CENTER + : component.textAlign === "right" + ? AlignmentTypeRef.RIGHT + : AlignmentTypeRef.LEFT; + + // 줄바꿈 처리: \n으로 split하여 각 줄을 TextRun으로 생성 + const lines = displayValue.split("\n"); + const textChildren: TextRun[] = []; + lines.forEach((line: string, index: number) => { + if (index > 0) { + // 줄바꿈 추가 (break: 1은 줄바꿈 1개) + textChildren.push(new TextRunRef({ break: 1 })); + } + textChildren.push( + new TextRunRef({ + text: line, + size: fontSizeHalfPt, + color: (component.fontColor || "#000000").replace("#", ""), + bold: + component.fontWeight === "bold" || + component.fontWeight === "600", + font: "맑은 고딕", + }) + ); + }); + + result.push( + new ParagraphRef({ + alignment, + children: textChildren, + }) + ); + } + + // Image + else if (component.type === "image" && component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + result.push( + new ParagraphRef({ + children: [ + new ImageRunRef({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }) + ); + } catch (e) { + result.push(new ParagraphRef({ children: [] })); + } + } + + // Signature + else if (component.type === "signature") { + const sigFontSize = pxToHalfPtFn(component.fontSize || 12); + const textRuns: TextRun[] = []; + if (component.showLabel !== false) { + textRuns.push( + new TextRunRef({ + text: (component.labelText || "서명:") + " ", + size: sigFontSize, + font: "맑은 고딕", + }) + ); + } + if (component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + result.push( + new ParagraphRef({ + children: [ + ...textRuns, + new ImageRunRef({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }) + ); + } catch (e) { + textRuns.push( + new TextRunRef({ + text: "_".repeat(20), + size: sigFontSize, + font: "맑은 고딕", + }) + ); + result.push(new ParagraphRef({ children: textRuns })); + } + } else { + textRuns.push( + new TextRunRef({ + text: "_".repeat(20), + size: sigFontSize, + font: "맑은 고딕", + }) + ); + result.push(new ParagraphRef({ children: textRuns })); + } + } + + // Stamp + else if (component.type === "stamp") { + const stampFontSize = pxToHalfPtFn(component.fontSize || 12); + const textRuns: TextRun[] = []; + if (component.personName) { + textRuns.push( + new TextRunRef({ + text: component.personName + " ", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + } + if (component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + result.push( + new ParagraphRef({ + children: [ + ...textRuns, + new ImageRunRef({ + data: imageBuffer, + transformation: { + width: Math.round( + Math.min(component.width, component.height) * 0.75 + ), + height: Math.round( + Math.min(component.width, component.height) * 0.75 + ), + }, + type: "png", + }), + ], + }) + ); + } catch (e) { + textRuns.push( + new TextRunRef({ + text: "(인)", + color: "DC2626", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + result.push(new ParagraphRef({ children: textRuns })); + } + } else { + textRuns.push( + new TextRunRef({ + text: "(인)", + color: "DC2626", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + result.push(new ParagraphRef({ children: textRuns })); + } + } + + // PageNumber + else if (component.type === "pageNumber") { + const format = component.pageNumberFormat || "number"; + const currentPageNum = pageIndex + 1; + let pageNumberText = ""; + switch (format) { + case "number": + pageNumberText = `${currentPageNum}`; + break; + case "numberTotal": + pageNumberText = `${currentPageNum} / ${totalPages}`; + break; + case "koreanNumber": + pageNumberText = `${currentPageNum} 페이지`; + break; + default: + pageNumberText = `${currentPageNum}`; + } + const fontSizeHalfPt = pxToHalfPtFn(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentTypeRef.CENTER + : component.textAlign === "right" + ? AlignmentTypeRef.RIGHT + : AlignmentTypeRef.LEFT; + result.push( + new ParagraphRef({ + alignment, + children: [ + new TextRunRef({ + text: pageNumberText, + size: fontSizeHalfPt, + color: (component.fontColor || "#000000").replace("#", ""), + font: "맑은 고딕", + }), + ], + }) + ); + } + + // Card 컴포넌트 + else if (component.type === "card") { + const cardTitle = component.cardTitle || "정보 카드"; + const cardItems = component.cardItems || []; + const labelWidth = component.labelWidth || 80; + const showCardTitle = component.showCardTitle !== false; + const titleFontSize = pxToHalfPtFn(component.titleFontSize || 14); + const labelFontSize = pxToHalfPtFn(component.labelFontSize || 13); + const valueFontSize = pxToHalfPtFn(component.valueFontSize || 13); + const titleColor = (component.titleColor || "#1e40af").replace( + "#", + "" + ); + const labelColor = (component.labelColor || "#374151").replace( + "#", + "" + ); + const valueColor = (component.valueColor || "#000000").replace( + "#", + "" + ); + const borderColor = (component.borderColor || "#e5e7eb").replace( + "#", + "" + ); + + // 쿼리 바인딩된 값 가져오기 + const getCardValueFn = (item: { + label: string; + value: string; + fieldName?: string; + }) => { + if ( + item.fieldName && + component.queryId && + queryResultsMapRef[component.queryId] + ) { + const qResult = queryResultsMapRef[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + return row[item.fieldName] !== undefined + ? String(row[item.fieldName]) + : item.value; + } + } + return item.value; + }; + + // 제목 + if (showCardTitle) { + result.push( + new ParagraphRef({ + children: [ + new TextRunRef({ + text: cardTitle, + size: titleFontSize, + color: titleColor, + bold: true, + font: "맑은 고딕", + }), + ], + }) + ); + // 구분선 + result.push( + new ParagraphRef({ + border: { + bottom: { + color: borderColor, + space: 1, + style: BorderStyleRef.SINGLE, + size: 8, + }, + }, + children: [], + }) + ); + } + + // 항목들 + for (const item of cardItems) { + const itemValue = getCardValueFn( + item as { label: string; value: string; fieldName?: string } + ); + result.push( + new ParagraphRef({ + children: [ + new TextRunRef({ + text: item.label, + size: labelFontSize, + color: labelColor, + bold: true, + font: "맑은 고딕", + }), + new TextRunRef({ + text: " ", + size: labelFontSize, + font: "맑은 고딕", + }), + new TextRunRef({ + text: itemValue, + size: valueFontSize, + color: valueColor, + font: "맑은 고딕", + }), + ], + }) + ); + } + } + + // 계산 컴포넌트 + else if (component.type === "calculation") { + const calcItems = component.calcItems || []; + const resultLabel = component.resultLabel || "합계"; + const calcLabelWidth = component.labelWidth || 120; + const calcLabelFontSize = pxToHalfPtFn(component.labelFontSize || 13); + const calcValueFontSize = pxToHalfPtFn(component.valueFontSize || 13); + const calcResultFontSize = pxToHalfPtFn( + component.resultFontSize || 16 + ); + const calcLabelColor = (component.labelColor || "#374151").replace( + "#", + "" + ); + const calcValueColor = (component.valueColor || "#000000").replace( + "#", + "" + ); + const calcResultColor = (component.resultColor || "#2563eb").replace( + "#", + "" + ); + const numberFormat = component.numberFormat || "currency"; + const currencySuffix = component.currencySuffix || "원"; + const borderColor = (component.borderColor || "#374151").replace( + "#", + "" + ); + + // 숫자 포맷팅 함수 + const formatNumberFn = (num: number): string => { + if (numberFormat === "none") return String(num); + if (numberFormat === "comma") return num.toLocaleString(); + if (numberFormat === "currency") + return num.toLocaleString() + currencySuffix; + return String(num); + }; + + // 쿼리 바인딩된 값 가져오기 + const getCalcItemValueFn = (item: { + label: string; + value: number | string; + operator: string; + fieldName?: string; + }): number => { + if ( + item.fieldName && + component.queryId && + queryResultsMapRef[component.queryId] + ) { + const qResult = queryResultsMapRef[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + const val = row[item.fieldName]; + return typeof val === "number" + ? val + : parseFloat(String(val)) || 0; + } + } + return typeof item.value === "number" + ? item.value + : parseFloat(String(item.value)) || 0; + }; + + // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) + let calcResult = 0; + if (calcItems.length > 0) { + // 첫 번째 항목은 기준값 + calcResult = getCalcItemValueFn( + calcItems[0] as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + + // 두 번째 항목부터 연산자 적용 + for (let i = 1; i < calcItems.length; i++) { + const item = calcItems[i]; + const val = getCalcItemValueFn( + item as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + switch ((item as { operator: string }).operator) { + case "+": + calcResult += val; + break; + case "-": + calcResult -= val; + break; + case "x": + calcResult *= val; + break; + case "÷": + calcResult = val !== 0 ? calcResult / val : calcResult; + break; + } + } + } + + // 테이블로 계산 항목 렌더링 + const calcTableRows = []; + + // 각 항목 + for (const item of calcItems) { + const itemValue = getCalcItemValueFn( + item as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + calcTableRows.push( + new TableRowRef({ + children: [ + new TableCellRef({ + children: [ + new ParagraphRef({ + children: [ + new TextRunRef({ + text: item.label, + size: calcLabelFontSize, + color: calcLabelColor, + font: "맑은 고딕", + }), + ], + }), + ], + width: { + size: pxToTwipFn(calcLabelWidth), + type: WidthType.DXA, + }, + borders: { + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCellRef({ + children: [ + new ParagraphRef({ + alignment: AlignmentTypeRef.RIGHT, + children: [ + new TextRunRef({ + text: formatNumberFn(itemValue), + size: calcValueFontSize, + color: calcValueColor, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + } + + // 구분선 행 + calcTableRows.push( + new TableRowRef({ + children: [ + new TableCellRef({ + columnSpan: 2, + children: [new ParagraphRef({ children: [] })], + borders: { + top: { + style: BorderStyleRef.SINGLE, + size: 8, + color: borderColor, + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }), + ], + }) + ); + + // 결과 행 + calcTableRows.push( + new TableRowRef({ + children: [ + new TableCellRef({ + children: [ + new ParagraphRef({ + children: [ + new TextRunRef({ + text: resultLabel, + size: calcResultFontSize, + color: calcLabelColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + width: { + size: pxToTwipFn(calcLabelWidth), + type: WidthType.DXA, + }, + borders: { + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCellRef({ + children: [ + new ParagraphRef({ + alignment: AlignmentTypeRef.RIGHT, + children: [ + new TextRunRef({ + text: formatNumberFn(calcResult), + size: calcResultFontSize, + color: calcResultColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + + result.push( + new TableRef({ + rows: calcTableRows, + width: { size: pxToTwipFn(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }) + ); + } + + // Divider - 테이블 셀로 감싸서 정확한 너비 적용 + else if ( + component.type === "divider" && + component.orientation === "horizontal" + ) { + result.push( + new ParagraphRef({ + border: { + bottom: { + color: (component.lineColor || "#000000").replace("#", ""), + space: 1, + style: BorderStyleRef.SINGLE, + size: (component.lineWidth || 1) * 8, + }, + }, + children: [], + }) + ); + } + + // 기타 (빈 paragraph) + else { + result.push(new ParagraphRef({ children: [] })); + } + + return result; + }; + + // 섹션 생성 (페이지별) + const sortedPages = layoutConfig.pages.sort( + (a: any, b: any) => a.page_order - b.page_order + ); + const totalPagesCount = sortedPages.length; + + const sections = sortedPages.map((page: any, pageIndex: number) => { + const pageWidthTwip = mmToTwip(page.width); + const pageHeightTwip = mmToTwip(page.height); + const marginTopMm = page.margins?.top || 10; + const marginBottomMm = page.margins?.bottom || 10; + const marginLeftMm = page.margins?.left || 10; + const marginRightMm = page.margins?.right || 10; + + const marginTop = mmToTwip(marginTopMm); + const marginBottom = mmToTwip(marginBottomMm); + const marginLeft = mmToTwip(marginLeftMm); + const marginRight = mmToTwip(marginRightMm); + + // 마진을 px로 변환 (1mm ≈ 3.78px at 96 DPI) + const marginLeftPx = marginLeftMm * 3.78; + const marginTopPx = marginTopMm * 3.78; + + // 컴포넌트를 Y좌표순으로 정렬 + const sortedComponents = [...(page.components || [])].sort( + (a: any, b: any) => a.y - b.y + ); + + // 같은 Y좌표 범위(±30px)의 컴포넌트들을 그룹화 + const Y_GROUP_THRESHOLD = 30; // px + const componentGroups: any[][] = []; + let currentGroup: any[] = []; + let groupBaseY = -Infinity; + + for (const comp of sortedComponents) { + const compY = comp.y - marginTopPx; + if (currentGroup.length === 0) { + currentGroup.push(comp); + groupBaseY = compY; + } else if (Math.abs(compY - groupBaseY) <= Y_GROUP_THRESHOLD) { + currentGroup.push(comp); + } else { + componentGroups.push(currentGroup); + currentGroup = [comp]; + groupBaseY = compY; + } + } + if (currentGroup.length > 0) { + componentGroups.push(currentGroup); + } + + // 컴포넌트를 Paragraph/Table로 변환 + const children: (Paragraph | Table)[] = []; + + // Y좌표를 spacing으로 변환하기 위한 추적 변수 + let lastBottomY = 0; + + // 각 그룹 처리 + for (const group of componentGroups) { + // 그룹 내 컴포넌트들을 X좌표 순으로 정렬 + const sortedGroup = [...group].sort((a: any, b: any) => a.x - b.x); + + // 그룹의 Y 좌표 (첫 번째 컴포넌트 기준) + const groupY = Math.max(0, sortedGroup[0].y - marginTopPx); + const groupHeight = Math.max( + ...sortedGroup.map((c: any) => c.height) + ); + + // spacing 계산 + const gapFromPrevious = Math.max(0, groupY - lastBottomY); + const spacingBefore = pxToTwip(gapFromPrevious); + + // 그룹에 컴포넌트가 여러 개면 하나의 테이블 행으로 배치 + if (sortedGroup.length > 1) { + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + + // 각 컴포넌트를 셀로 변환 + const cells: TableCell[] = []; + let prevEndX = 0; + + for (const component of sortedGroup) { + const adjustedX = Math.max(0, component.x - marginLeftPx); + const displayValue = getComponentValue(component); + + // 이전 셀과의 간격을 위한 빈 셀 추가 + if (adjustedX > prevEndX + 5) { + const gapWidth = adjustedX - prevEndX; + cells.push( + new TableCell({ + children: [new Paragraph({ children: [] })], + width: { size: pxToTwip(gapWidth), type: WidthType.DXA }, + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }) + ); + } + + // 컴포넌트 셀 생성 + const cellContent = createCellContent( + component, + displayValue, + pxToHalfPt, + pxToTwip, + queryResultsMap, + AlignmentType, + VerticalAlign, + BorderStyle, + Paragraph, + TextRun, + ImageRun, + Table, + TableRow, + TableCell, + pageIndex, + totalPagesCount + ); + cells.push( + new TableCell({ + children: cellContent, + width: { + size: pxToTwip(component.width), + type: WidthType.DXA, + }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + verticalAlign: VerticalAlign.TOP, + }) + ); + prevEndX = adjustedX + component.width; + } + + // 테이블 행 생성 + const rowTable = new Table({ + rows: [new TableRow({ children: cells })], + width: { size: 100, type: WidthType.PERCENTAGE }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + children.push(rowTable); + lastBottomY = groupY + groupHeight; + continue; + } + + // 단일 컴포넌트 처리 (기존 로직) + const component = sortedGroup[0]; + const displayValue = getComponentValue(component); + const adjustedX = Math.max(0, component.x - marginLeftPx); + const adjustedY = groupY; + + // X좌표를 indent로 변환 (마진 제외한 순수 들여쓰기) + const indentLeft = pxToTwip(adjustedX); + + // Text/Label 컴포넌트 - 테이블 셀로 감싸서 width 내 줄바꿈 적용 + if (component.type === "text" || component.type === "label") { + const fontSizeHalfPt = pxToHalfPt(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentType.CENTER + : component.textAlign === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT; + + // 줄바꿈 처리: \n으로 split하여 각 줄을 TextRun으로 생성 + const lines = displayValue.split("\n"); + const textChildren: TextRun[] = []; + lines.forEach((line: string, index: number) => { + if (index > 0) { + textChildren.push(new TextRun({ break: 1 })); + } + textChildren.push( + new TextRun({ + text: line, + size: fontSizeHalfPt, + color: (component.fontColor || "#000000").replace("#", ""), + bold: + component.fontWeight === "bold" || + component.fontWeight === "600", + font: "맑은 고딕", + }) + ); + }); + + // 테이블 셀로 감싸서 width 제한 → 자동 줄바꿈 + const textCell = new TableCell({ + children: [ + new Paragraph({ + alignment, + children: textChildren, + }), + ], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + verticalAlign: VerticalAlign.TOP, + }); + + const textTable = new Table({ + rows: [new TableRow({ children: [textCell] })], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + children.push(textTable); + lastBottomY = adjustedY + component.height; + } + + // Image 컴포넌트 + else if (component.type === "image" && component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + + const paragraph = new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + new ImageRun({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }); + children.push(paragraph); + lastBottomY = adjustedY + component.height; + } catch (imgError) { + console.error("이미지 처리 오류:", imgError); + } + } + + // Divider 컴포넌트 - 테이블 셀로 감싸서 정확한 위치와 너비 적용 + else if (component.type === "divider") { + if (component.orientation === "horizontal") { + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + + // 테이블 셀로 감싸서 너비 제한 + const dividerCell = new TableCell({ + children: [ + new Paragraph({ + border: { + bottom: { + color: (component.lineColor || "#000000").replace( + "#", + "" + ), + space: 1, + style: BorderStyle.SINGLE, + size: (component.lineWidth || 1) * 8, + }, + }, + children: [], + }), + ], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + }); + + const dividerTable = new Table({ + rows: [new TableRow({ children: [dividerCell] })], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + children.push(dividerTable); + lastBottomY = adjustedY + component.height; + } + } + + // Signature 컴포넌트 + else if (component.type === "signature") { + const labelText = component.labelText || "서명:"; + const showLabel = component.showLabel !== false; + const sigFontSize = pxToHalfPt(component.fontSize || 12); + const textRuns: TextRun[] = []; + + if (showLabel) { + textRuns.push( + new TextRun({ + text: labelText + " ", + size: sigFontSize, + font: "맑은 고딕", + }) + ); + } + + if (component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + + const paragraph = new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + ...textRuns, + new ImageRun({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }); + children.push(paragraph); + } catch (imgError) { + console.error("서명 이미지 오류:", imgError); + textRuns.push( + new TextRun({ + text: "_".repeat(20), + size: sigFontSize, + font: "맑은 고딕", + }) + ); + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: textRuns, + }) + ); + } + } else { + textRuns.push( + new TextRun({ + text: "_".repeat(20), + size: sigFontSize, + font: "맑은 고딕", + }) + ); + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: textRuns, + }) + ); + } + lastBottomY = adjustedY + component.height; + } + + // Stamp 컴포넌트 + else if (component.type === "stamp") { + const personName = component.personName || ""; + const stampFontSize = pxToHalfPt(component.fontSize || 12); + const textRuns: TextRun[] = []; + + if (personName) { + textRuns.push( + new TextRun({ + text: personName + " ", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + } + + if (component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + + const paragraph = new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + ...textRuns, + new ImageRun({ + data: imageBuffer, + transformation: { + width: Math.round( + Math.min(component.width, component.height) * 0.75 + ), + height: Math.round( + Math.min(component.width, component.height) * 0.75 + ), + }, + type: "png", + }), + ], + }); + children.push(paragraph); + } catch (imgError) { + console.error("도장 이미지 오류:", imgError); + textRuns.push( + new TextRun({ + text: "(인)", + color: "DC2626", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: textRuns, + }) + ); + } + } else { + textRuns.push( + new TextRun({ + text: "(인)", + color: "DC2626", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: textRuns, + }) + ); + } + lastBottomY = adjustedY + component.height; + } + + // PageNumber 컴포넌트 - 테이블 셀로 감싸서 정확한 위치 적용 + else if (component.type === "pageNumber") { + const format = component.pageNumberFormat || "number"; + const currentPageNum = pageIndex + 1; + let pageNumberText = ""; + switch (format) { + case "number": + pageNumberText = `${currentPageNum}`; + break; + case "numberTotal": + pageNumberText = `${currentPageNum} / ${totalPagesCount}`; + break; + case "koreanNumber": + pageNumberText = `${currentPageNum} 페이지`; + break; + default: + pageNumberText = `${currentPageNum}`; + } + const pageNumFontSize = pxToHalfPt(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentType.CENTER + : component.textAlign === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT; + + // 테이블 셀로 감싸서 width와 indent 정확히 적용 + const pageNumCell = new TableCell({ + children: [ + new Paragraph({ + alignment, + children: [ + new TextRun({ + text: pageNumberText, + size: pageNumFontSize, + color: (component.fontColor || "#000000").replace( + "#", + "" + ), + font: "맑은 고딕", + }), + ], + }), + ], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + verticalAlign: VerticalAlign.TOP, + }); + + const pageNumTable = new Table({ + rows: [new TableRow({ children: [pageNumCell] })], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + children.push(pageNumTable); + lastBottomY = adjustedY + component.height; + } + + // Card 컴포넌트 - 테이블로 감싸서 정확한 위치 적용 + else if (component.type === "card") { + const cardTitle = component.cardTitle || "정보 카드"; + const cardItems = component.cardItems || []; + const labelWidthPx = component.labelWidth || 80; + const showCardTitle = component.showCardTitle !== false; + const titleFontSize = pxToHalfPt(component.titleFontSize || 14); + const labelFontSizeCard = pxToHalfPt(component.labelFontSize || 13); + const valueFontSizeCard = pxToHalfPt(component.valueFontSize || 13); + const titleColorCard = (component.titleColor || "#1e40af").replace( + "#", + "" + ); + const labelColorCard = (component.labelColor || "#374151").replace( + "#", + "" + ); + const valueColorCard = (component.valueColor || "#000000").replace( + "#", + "" + ); + const borderColorCard = ( + component.borderColor || "#e5e7eb" + ).replace("#", ""); + + // 쿼리 바인딩된 값 가져오기 + const getCardValueLocal = (item: { + label: string; + value: string; + fieldName?: string; + }) => { + if ( + item.fieldName && + component.queryId && + queryResultsMap[component.queryId] + ) { + const qResult = queryResultsMap[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + return row[item.fieldName] !== undefined + ? String(row[item.fieldName]) + : item.value; + } + } + return item.value; + }; + + const cardParagraphs: Paragraph[] = []; + + // 제목 + if (showCardTitle) { + cardParagraphs.push( + new Paragraph({ + children: [ + new TextRun({ + text: cardTitle, + size: titleFontSize, + color: titleColorCard, + bold: true, + font: "맑은 고딕", + }), + ], + }) + ); + // 구분선 + cardParagraphs.push( + new Paragraph({ + border: { + bottom: { + color: borderColorCard, + space: 1, + style: BorderStyle.SINGLE, + size: 8, + }, + }, + children: [], + }) + ); + } + + // 항목들을 테이블로 구성 (라벨 + 값) + const itemRows = cardItems.map( + (item: { label: string; value: string; fieldName?: string }) => { + const itemValue = getCardValueLocal(item); + return new TableRow({ + children: [ + new TableCell({ + width: { + size: pxToTwip(labelWidthPx), + type: WidthType.DXA, + }, + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: item.label, + size: labelFontSizeCard, + color: labelColorCard, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }), + new TableCell({ + width: { + size: pxToTwip(component.width - labelWidthPx - 16), + type: WidthType.DXA, + }, + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: itemValue, + size: valueFontSizeCard, + color: valueColorCard, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }), + ], + }); + } + ); + + const itemsTable = new Table({ + rows: itemRows, + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + + // 전체를 하나의 테이블 셀로 감싸기 + const cardCell = new TableCell({ + children: [...cardParagraphs, itemsTable], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: + component.showCardBorder !== false + ? { + top: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + bottom: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + left: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + right: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + } + : { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + verticalAlign: VerticalAlign.TOP, + }); + + const cardTable = new Table({ + rows: [new TableRow({ children: [cardCell] })], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + children.push(cardTable); + lastBottomY = adjustedY + component.height; + } + + // 계산 컴포넌트 - 테이블로 감싸서 정확한 위치 적용 + else if (component.type === "calculation") { + const calcItems = component.calcItems || []; + const resultLabel = component.resultLabel || "합계"; + const calcLabelWidth = component.labelWidth || 120; + const calcLabelFontSize = pxToHalfPt(component.labelFontSize || 13); + const calcValueFontSize = pxToHalfPt(component.valueFontSize || 13); + const calcResultFontSize = pxToHalfPt( + component.resultFontSize || 16 + ); + const calcLabelColor = (component.labelColor || "#374151").replace( + "#", + "" + ); + const calcValueColor = (component.valueColor || "#000000").replace( + "#", + "" + ); + const calcResultColor = ( + component.resultColor || "#2563eb" + ).replace("#", ""); + const numberFormat = component.numberFormat || "currency"; + const currencySuffix = component.currencySuffix || "원"; + const borderColor = (component.borderColor || "#374151").replace( + "#", + "" + ); + + // 숫자 포맷팅 함수 + const formatNumberFn = (num: number): string => { + if (numberFormat === "none") return String(num); + if (numberFormat === "comma") return num.toLocaleString(); + if (numberFormat === "currency") + return num.toLocaleString() + currencySuffix; + return String(num); + }; + + // 쿼리 바인딩된 값 가져오기 + const getCalcItemValueFn = (item: { + label: string; + value: number | string; + operator: string; + fieldName?: string; + }): number => { + if ( + item.fieldName && + component.queryId && + queryResultsMap[component.queryId] + ) { + const qResult = queryResultsMap[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + const val = row[item.fieldName]; + return typeof val === "number" + ? val + : parseFloat(String(val)) || 0; + } + } + return typeof item.value === "number" + ? item.value + : parseFloat(String(item.value)) || 0; + }; + + // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) + let calcResult = 0; + if (calcItems.length > 0) { + // 첫 번째 항목은 기준값 + calcResult = getCalcItemValueFn( + calcItems[0] as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + + // 두 번째 항목부터 연산자 적용 + for (let i = 1; i < calcItems.length; i++) { + const calcItem = calcItems[i]; + const val = getCalcItemValueFn( + calcItem as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + switch ((calcItem as { operator: string }).operator) { + case "+": + calcResult += val; + break; + case "-": + calcResult -= val; + break; + case "x": + calcResult *= val; + break; + case "÷": + calcResult = val !== 0 ? calcResult / val : calcResult; + break; + } + } + } + + // 테이블 행 생성 + const calcTableRows: TableRow[] = []; + + // 각 항목 행 + for (const calcItem of calcItems) { + const itemValue = getCalcItemValueFn( + calcItem as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + calcTableRows.push( + new TableRow({ + children: [ + new TableCell({ + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: calcItem.label, + size: calcLabelFontSize, + color: calcLabelColor, + font: "맑은 고딕", + }), + ], + }), + ], + width: { + size: pxToTwip(calcLabelWidth), + type: WidthType.DXA, + }, + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCell({ + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [ + new TextRun({ + text: formatNumberFn(itemValue), + size: calcValueFontSize, + color: calcValueColor, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + } + + // 구분선 행 + calcTableRows.push( + new TableRow({ + children: [ + new TableCell({ + columnSpan: 2, + children: [new Paragraph({ children: [] })], + borders: { + top: { + style: BorderStyle.SINGLE, + size: 8, + color: borderColor, + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }), + ], + }) + ); + + // 결과 행 + calcTableRows.push( + new TableRow({ + children: [ + new TableCell({ + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: resultLabel, + size: calcResultFontSize, + color: calcLabelColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + width: { + size: pxToTwip(calcLabelWidth), + type: WidthType.DXA, + }, + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCell({ + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [ + new TextRun({ + text: formatNumberFn(calcResult), + size: calcResultFontSize, + color: calcResultColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + + const calcTable = new Table({ + rows: calcTableRows, + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + children.push(calcTable); + lastBottomY = adjustedY + component.height; + } + + // Table 컴포넌트 + else if (component.type === "table" && component.queryId) { + const queryResult = queryResultsMap[component.queryId]; + if ( + queryResult && + queryResult.rows && + queryResult.rows.length > 0 + ) { + // 테이블 앞에 spacing과 indent를 위한 빈 paragraph 추가 + if (spacingBefore > 0 || indentLeft > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [], + }) + ); + } + + const columns = + component.tableColumns && component.tableColumns.length > 0 + ? component.tableColumns + : queryResult.fields.map((field: string) => ({ + field, + header: field, + align: "left", + width: undefined, + })); + + // 테이블 폰트 사이즈 (기본 12px) + const tableFontSize = pxToHalfPt(component.fontSize || 12); + + // 헤더 행 + const headerCells = columns.map( + (col: { header: string; align?: string }) => + new TableCell({ + children: [ + new Paragraph({ + alignment: + col.align === "center" + ? AlignmentType.CENTER + : col.align === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT, + children: [ + new TextRun({ + text: col.header, + bold: true, + size: tableFontSize, + font: "맑은 고딕", + }), + ], + }), + ], + shading: { + fill: ( + component.headerBackgroundColor || "#f3f4f6" + ).replace("#", ""), + }, + verticalAlign: VerticalAlign.CENTER, + }) + ); + const headerRow = new TableRow({ children: headerCells }); + + // 데이터 행 + const dataRows = queryResult.rows.map( + (row: Record) => + new TableRow({ + children: columns.map( + (col: { field: string; align?: string }) => + new TableCell({ + children: [ + new Paragraph({ + alignment: + col.align === "center" + ? AlignmentType.CENTER + : col.align === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT, + children: [ + new TextRun({ + text: String(row[col.field] ?? ""), + size: tableFontSize, + font: "맑은 고딕", + }), + ], + }), + ], + verticalAlign: VerticalAlign.CENTER, + }) + ), + }) + ); + + const table = new Table({ + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + rows: [headerRow, ...dataRows], + }); + children.push(table); + lastBottomY = adjustedY + component.height; + } + } + } + + // 빈 페이지 방지 + if (children.length === 0) { + children.push(new Paragraph({ children: [] })); + } + + return { + properties: { + page: { + size: { + width: pageWidthTwip, + height: pageHeightTwip, + orientation: + page.width > page.height + ? PageOrientation.LANDSCAPE + : PageOrientation.PORTRAIT, + }, + margin: { + top: marginTop, + bottom: marginBottom, + left: marginLeft, + right: marginRight, + }, + }, + }, + children, + }; + }); + + // Document 생성 + const doc = new Document({ + sections, + }); + + // Buffer로 변환 + const docxBuffer = await Packer.toBuffer(doc); + + // 파일명 인코딩 (한글 지원) + const timestamp = new Date().toISOString().slice(0, 10); + const safeFileName = encodeURIComponent(`${fileName}_${timestamp}.docx`); + + // DOCX 파일로 응답 + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + res.setHeader( + "Content-Disposition", + `attachment; filename*=UTF-8''${safeFileName}` + ); + res.setHeader("Content-Length", docxBuffer.length); + + return res.send(docxBuffer); + } catch (error: any) { + console.error("WORD 변환 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "WORD 변환에 실패했습니다.", + }); + } + } } export default new ReportController(); diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index 7aa1d825..92036080 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -52,3 +52,5 @@ export default router; + + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index 5f57c6ca..ed11d3d1 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -48,3 +48,5 @@ export default router; + + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index b0e3c79a..d74929cb 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -64,3 +64,5 @@ export default router; + + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index 0cec35d2..ce2fbcac 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -52,3 +52,5 @@ export default router; + + diff --git a/backend-node/src/routes/categoryValueCascadingRoutes.ts b/backend-node/src/routes/categoryValueCascadingRoutes.ts new file mode 100644 index 00000000..894da819 --- /dev/null +++ b/backend-node/src/routes/categoryValueCascadingRoutes.ts @@ -0,0 +1,74 @@ +import { Router } from "express"; +import { + getCategoryValueCascadingGroups, + getCategoryValueCascadingGroupById, + getCategoryValueCascadingByCode, + createCategoryValueCascadingGroup, + updateCategoryValueCascadingGroup, + deleteCategoryValueCascadingGroup, + saveCategoryValueCascadingMappings, + getCategoryValueCascadingOptions, + getCategoryValueCascadingParentOptions, + getCategoryValueCascadingChildOptions, + getCategoryValueCascadingMappingsByTable, +} from "../controllers/categoryValueCascadingController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 적용 +router.use(authenticateToken); + +// ============================================ +// 카테고리 값 연쇄관계 그룹 CRUD +// ============================================ + +// 그룹 목록 조회 +router.get("/groups", getCategoryValueCascadingGroups); + +// 그룹 상세 조회 (ID) +router.get("/groups/:groupId", getCategoryValueCascadingGroupById); + +// 관계 코드로 조회 +router.get("/code/:code", getCategoryValueCascadingByCode); + +// 그룹 생성 +router.post("/groups", createCategoryValueCascadingGroup); + +// 그룹 수정 +router.put("/groups/:groupId", updateCategoryValueCascadingGroup); + +// 그룹 삭제 +router.delete("/groups/:groupId", deleteCategoryValueCascadingGroup); + +// ============================================ +// 카테고리 값 연쇄관계 매핑 +// ============================================ + +// 매핑 일괄 저장 +router.post("/groups/:groupId/mappings", saveCategoryValueCascadingMappings); + +// ============================================ +// 연쇄 옵션 조회 (실제 드롭다운에서 사용) +// ============================================ + +// 부모 카테고리 값 목록 조회 +router.get("/parent-options/:code", getCategoryValueCascadingParentOptions); + +// 자식 카테고리 값 목록 조회 (매핑 설정 UI용) +router.get("/child-options/:code", getCategoryValueCascadingChildOptions); + +// 연쇄 옵션 조회 (부모 값 기반 자식 옵션) +router.get("/options/:code", getCategoryValueCascadingOptions); + +// ============================================ +// 테이블별 매핑 조회 (테이블 목록 표시용) +// ============================================ + +// 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회 +router.get( + "/table/:tableName/mappings", + getCategoryValueCascadingMappingsByTable +); + +export default router; diff --git a/backend-node/src/routes/reportRoutes.ts b/backend-node/src/routes/reportRoutes.ts index 76e1a955..bb644fef 100644 --- a/backend-node/src/routes/reportRoutes.ts +++ b/backend-node/src/routes/reportRoutes.ts @@ -56,6 +56,11 @@ router.post("/upload-image", upload.single("image"), (req, res, next) => reportController.uploadImage(req, res, next) ); +// WORD(DOCX) 내보내기 +router.post("/export-word", (req, res, next) => + reportController.exportToWord(req, res, next) +); + // 리포트 목록 router.get("/", (req, res, next) => reportController.getReports(req, res, next) diff --git a/backend-node/src/routes/roleRoutes.ts b/backend-node/src/routes/roleRoutes.ts index 21c17ecb..0f8a64b0 100644 --- a/backend-node/src/routes/roleRoutes.ts +++ b/backend-node/src/routes/roleRoutes.ts @@ -22,6 +22,15 @@ const router = Router(); // 모든 role 라우트에 인증 미들웨어 적용 router.use(authenticateToken); +/** + * 사용자 권한 그룹 조회 (/:id 보다 먼저 정의해야 함) + */ +// 현재 사용자가 속한 권한 그룹 조회 +router.get("/user/my-groups", getUserRoleGroups); + +// 특정 사용자가 속한 권한 그룹 조회 +router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups); + /** * 권한 그룹 CRUD */ @@ -67,13 +76,4 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions); // 메뉴 권한 설정 router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions); -/** - * 사용자 권한 그룹 조회 - */ -// 현재 사용자가 속한 권한 그룹 조회 -router.get("/user/my-groups", getUserRoleGroups); - -// 특정 사용자가 속한 권한 그룹 조회 -router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups); - export default router; diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 5557d8b5..25d96927 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -186,8 +186,13 @@ export class EntityJoinService { } } - // 별칭 컬럼명 생성 (writer -> writer_name) - const aliasColumn = `${column.column_name}_name`; + // 🎯 별칭 컬럼명 생성 - 사용자가 선택한 displayColumns 기반으로 동적 생성 + // 단일 컬럼: manager + user_name → manager_user_name + // 여러 컬럼: 첫 번째 컬럼 기준 (나머지는 개별 alias로 처리됨) + const firstDisplayColumn = displayColumns[0] || "name"; + const aliasColumn = `${column.column_name}_${firstDisplayColumn}`; + + logger.info(`🔧 별칭 컬럼명 생성: ${column.column_name} + ${firstDisplayColumn} → ${aliasColumn}`); const joinConfig: EntityJoinConfig = { sourceTable: tableName, diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index b12d7a4a..26c8b779 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -10,12 +10,29 @@ export interface MenuCopyResult { copiedMenus: number; copiedScreens: number; copiedFlows: number; + copiedCodeCategories: number; + copiedCodes: number; + copiedNumberingRules: number; + copiedCategoryMappings: number; + copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정 + copiedCascadingRelations: number; // 연쇄관계 설정 menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; warnings: string[]; } +/** + * 추가 복사 옵션 + */ +export interface AdditionalCopyOptions { + copyCodeCategory?: boolean; + copyNumberingRules?: boolean; + copyCategoryMapping?: boolean; + copyTableTypeColumns?: boolean; // 테이블 타입관리 입력타입 설정 + copyCascadingRelation?: boolean; // 연쇄관계 설정 +} + /** * 메뉴 정보 */ @@ -230,7 +247,9 @@ export class MenuCopyService { typeof screenId === "number" ? screenId : parseInt(screenId); if (!isNaN(numId)) { referenced.push(numId); - logger.debug(` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})`); + logger.debug( + ` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})` + ); } } } @@ -240,7 +259,9 @@ export class MenuCopyService { if (props?.componentConfig?.leftScreenId) { const leftScreenId = props.componentConfig.leftScreenId; const numId = - typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId); + typeof leftScreenId === "number" + ? leftScreenId + : parseInt(leftScreenId); if (!isNaN(numId) && numId > 0) { referenced.push(numId); logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`); @@ -250,7 +271,9 @@ export class MenuCopyService { if (props?.componentConfig?.rightScreenId) { const rightScreenId = props.componentConfig.rightScreenId; const numId = - typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId); + typeof rightScreenId === "number" + ? rightScreenId + : parseInt(rightScreenId); if (!isNaN(numId) && numId > 0) { referenced.push(numId); logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`); @@ -276,18 +299,16 @@ export class MenuCopyService { const screenIds = new Set(); const visited = new Set(); - // 1) 메뉴에 직접 할당된 화면 - for (const menuObjid of menuObjids) { - const assignmentsResult = await client.query<{ screen_id: number }>( - `SELECT DISTINCT screen_id - FROM screen_menu_assignments - WHERE menu_objid = $1 AND company_code = $2`, - [menuObjid, sourceCompanyCode] - ); + // 1) 메뉴에 직접 할당된 화면 - 배치 조회 + const assignmentsResult = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id + FROM screen_menu_assignments + WHERE menu_objid = ANY($1) AND company_code = $2`, + [menuObjids, sourceCompanyCode] + ); - for (const assignment of assignmentsResult.rows) { - screenIds.add(assignment.screen_id); - } + for (const assignment of assignmentsResult.rows) { + screenIds.add(assignment.screen_id); } logger.info(`📌 직접 할당 화면: ${screenIds.size}개`); @@ -342,37 +363,62 @@ export class MenuCopyService { logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`); const flowIds = new Set(); - const flowDetails: Array<{ flowId: number; flowName: string; screenId: number }> = []; + const flowDetails: Array<{ + flowId: number; + flowName: string; + screenId: number; + }> = []; - for (const screenId of screenIds) { - const layoutsResult = await client.query( - `SELECT properties FROM screen_layouts WHERE screen_id = $1`, - [screenId] - ); + // 배치 조회: 모든 화면의 레이아웃을 한 번에 조회 + const screenIdArray = Array.from(screenIds); + if (screenIdArray.length === 0) { + return flowIds; + } - for (const layout of layoutsResult.rows) { - const props = layout.properties; + const layoutsResult = await client.query< + ScreenLayout & { screen_id: number } + >( + `SELECT screen_id, properties FROM screen_layouts WHERE screen_id = ANY($1)`, + [screenIdArray] + ); - // webTypeConfig.dataflowConfig.flowConfig.flowId - const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; - const flowName = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown"; - - if (flowId && typeof flowId === "number" && flowId > 0) { - if (!flowIds.has(flowId)) { - flowIds.add(flowId); - flowDetails.push({ flowId, flowName, screenId }); - logger.info(` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"`); - } + for (const layout of layoutsResult.rows) { + const props = layout.properties; + const screenId = layout.screen_id; + + // webTypeConfig.dataflowConfig.flowConfig.flowId + const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; + const flowName = + props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown"; + + if (flowId && typeof flowId === "number" && flowId > 0) { + if (!flowIds.has(flowId)) { + flowIds.add(flowId); + flowDetails.push({ flowId, flowName, screenId }); + logger.info( + ` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"` + ); } + } - // selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음) - const selectedDiagramId = props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; - if (selectedDiagramId && typeof selectedDiagramId === "number" && selectedDiagramId > 0) { - if (!flowIds.has(selectedDiagramId)) { - flowIds.add(selectedDiagramId); - flowDetails.push({ flowId: selectedDiagramId, flowName: "SelectedDiagram", screenId }); - logger.info(` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}`); - } + // selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음) + const selectedDiagramId = + props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; + if ( + selectedDiagramId && + typeof selectedDiagramId === "number" && + selectedDiagramId > 0 + ) { + if (!flowIds.has(selectedDiagramId)) { + flowIds.add(selectedDiagramId); + flowDetails.push({ + flowId: selectedDiagramId, + flowName: "SelectedDiagram", + screenId, + }); + logger.info( + ` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}` + ); } } } @@ -383,7 +429,7 @@ export class MenuCopyService { } else { logger.info(`📭 수집된 플로우 없음 (화면에 플로우 참조가 없음)`); } - + return flowIds; } @@ -431,12 +477,13 @@ export class MenuCopyService { * properties 내부 참조 업데이트 */ /** - * properties 내부의 모든 screen_id, screenId, targetScreenId, flowId 재귀 업데이트 + * properties 내부의 모든 screen_id, screenId, targetScreenId, flowId, numberingRuleId 재귀 업데이트 */ private updateReferencesInProperties( properties: any, screenIdMap: Map, - flowIdMap: Map + flowIdMap: Map, + numberingRuleIdMap?: Map ): any { if (!properties) return properties; @@ -444,7 +491,13 @@ export class MenuCopyService { const updated = JSON.parse(JSON.stringify(properties)); // 재귀적으로 객체/배열 탐색 - this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap); + this.recursiveUpdateReferences( + updated, + screenIdMap, + flowIdMap, + "", + numberingRuleIdMap + ); return updated; } @@ -456,7 +509,8 @@ export class MenuCopyService { obj: any, screenIdMap: Map, flowIdMap: Map, - path: string = "" + path: string = "", + numberingRuleIdMap?: Map ): void { if (!obj || typeof obj !== "object") return; @@ -467,7 +521,8 @@ export class MenuCopyService { item, screenIdMap, flowIdMap, - `${path}[${index}]` + `${path}[${index}]`, + numberingRuleIdMap ); }); return; @@ -518,13 +573,36 @@ export class MenuCopyService { } } + // numberingRuleId 매핑 (문자열) + if ( + key === "numberingRuleId" && + numberingRuleIdMap && + typeof value === "string" && + value + ) { + const newRuleId = numberingRuleIdMap.get(value); + if (newRuleId) { + obj[key] = newRuleId; + logger.info( + ` 🔗 채번규칙 참조 업데이트 (${currentPath}): ${value} → ${newRuleId}` + ); + } else { + // 매핑이 없는 채번규칙은 빈 값으로 설정 (다른 회사 채번규칙 참조 방지) + logger.warn( + ` ⚠️ 채번규칙 매핑 없음 (${currentPath}): ${value} → 빈 값으로 설정` + ); + obj[key] = ""; + } + } + // 재귀 호출 if (typeof value === "object" && value !== null) { this.recursiveUpdateReferences( value, screenIdMap, flowIdMap, - currentPath + currentPath, + numberingRuleIdMap ); } } @@ -534,6 +612,8 @@ export class MenuCopyService { * 기존 복사본 삭제 (덮어쓰기를 위한 사전 정리) * * 같은 원본 메뉴에서 복사된 메뉴 구조가 대상 회사에 이미 존재하면 삭제 + * - 최상위 메뉴 복사: 해당 메뉴 트리 전체 삭제 + * - 하위 메뉴 복사: 해당 메뉴와 그 하위만 삭제 (부모는 유지) */ private async deleteExistingCopy( sourceMenuObjid: number, @@ -542,9 +622,9 @@ export class MenuCopyService { ): Promise { logger.info("\n🗑️ [0단계] 기존 복사본 확인 및 삭제"); - // 1. 대상 회사에 같은 이름의 최상위 메뉴가 있는지 확인 + // 1. 원본 메뉴 정보 확인 const sourceMenuResult = await client.query( - `SELECT menu_name_kor, menu_name_eng + `SELECT menu_name_kor, menu_name_eng, parent_obj_id FROM menu_info WHERE objid = $1`, [sourceMenuObjid] @@ -556,14 +636,19 @@ export class MenuCopyService { } const sourceMenu = sourceMenuResult.rows[0]; + const isRootMenu = + !sourceMenu.parent_obj_id || sourceMenu.parent_obj_id === 0; // 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭) - const existingMenuResult = await client.query<{ objid: number }>( - `SELECT objid + // 최상위/하위 구분 없이 모든 복사본 검색 + const existingMenuResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( + `SELECT objid, parent_obj_id FROM menu_info WHERE source_menu_objid = $1 - AND company_code = $2 - AND (parent_obj_id = 0 OR parent_obj_id IS NULL)`, + AND company_code = $2`, [sourceMenuObjid, targetCompanyCode] ); @@ -573,11 +658,15 @@ export class MenuCopyService { } const existingMenuObjid = existingMenuResult.rows[0].objid; + const existingIsRoot = + !existingMenuResult.rows[0].parent_obj_id || + existingMenuResult.rows[0].parent_obj_id === 0; + logger.info( - `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid})` + `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid}, 최상위: ${existingIsRoot})` ); - // 3. 기존 메뉴 트리 수집 + // 3. 기존 메뉴 트리 수집 (해당 메뉴 + 하위 메뉴 모두) const existingMenus = await this.collectMenuTree(existingMenuObjid, client); const existingMenuIds = existingMenus.map((m) => m.objid); @@ -595,16 +684,7 @@ export class MenuCopyService { // 5. 삭제 순서 (외래키 제약 고려) - // 5-1. 화면 레이아웃 삭제 - if (screenIds.length > 0) { - await client.query( - `DELETE FROM screen_layouts WHERE screen_id = ANY($1)`, - [screenIds] - ); - logger.info(` ✅ 화면 레이아웃 삭제 완료`); - } - - // 5-2. 화면-메뉴 할당 삭제 + // 5-1. 화면-메뉴 할당 먼저 삭제 (공유 화면 체크를 위해 먼저 삭제) await client.query( `DELETE FROM screen_menu_assignments WHERE menu_objid = ANY($1) AND company_code = $2`, @@ -612,27 +692,115 @@ export class MenuCopyService { ); logger.info(` ✅ 화면-메뉴 할당 삭제 완료`); - // 5-3. 화면 정의 삭제 + // 5-2. 화면 정의 삭제 (다른 메뉴에서 사용 중인 화면은 제외) if (screenIds.length > 0) { - await client.query( - `DELETE FROM screen_definitions + // 다른 메뉴에서도 사용 중인 화면 ID 조회 + const sharedScreensResult = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id FROM screen_menu_assignments WHERE screen_id = ANY($1) AND company_code = $2`, [screenIds, targetCompanyCode] ); - logger.info(` ✅ 화면 정의 삭제 완료`); + const sharedScreenIds = new Set( + sharedScreensResult.rows.map((r) => r.screen_id) + ); + + // 공유되지 않은 화면만 삭제 + const screensToDelete = screenIds.filter( + (id) => !sharedScreenIds.has(id) + ); + + if (screensToDelete.length > 0) { + // 레이아웃 삭제 + await client.query( + `DELETE FROM screen_layouts WHERE screen_id = ANY($1)`, + [screensToDelete] + ); + + // 화면 정의 삭제 + await client.query( + `DELETE FROM screen_definitions + WHERE screen_id = ANY($1) AND company_code = $2`, + [screensToDelete, targetCompanyCode] + ); + logger.info(` ✅ 화면 정의 삭제 완료: ${screensToDelete.length}개`); + } + + if (sharedScreenIds.size > 0) { + logger.info( + ` ♻️ 공유 화면 유지: ${sharedScreenIds.size}개 (다른 메뉴에서 사용 중)` + ); + } } - // 5-4. 메뉴 권한 삭제 + // 5-3. 메뉴 권한 삭제 await client.query(`DELETE FROM rel_menu_auth WHERE menu_objid = ANY($1)`, [ existingMenuIds, ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-5. 메뉴 삭제 (역순: 하위 메뉴부터) - // 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음 - for (let i = existingMenus.length - 1; i >= 0; i--) { - await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ - existingMenus[i].objid, + // 5-4. 채번 규칙 처리 (체크 제약조건 고려) + // scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함) + // check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수 + const menuScopedRulesResult = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE menu_objid = ANY($1) AND company_code = $2 AND scope_type = 'menu'`, + [existingMenuIds, targetCompanyCode] + ); + if (menuScopedRulesResult.rows.length > 0) { + const menuScopedRuleIds = menuScopedRulesResult.rows.map( + (r) => r.rule_id + ); + // 채번 규칙 파트 먼저 삭제 + await client.query( + `DELETE FROM numbering_rule_parts WHERE rule_id = ANY($1)`, + [menuScopedRuleIds] + ); + // 채번 규칙 삭제 + await client.query(`DELETE FROM numbering_rules WHERE rule_id = ANY($1)`, [ + menuScopedRuleIds, + ]); + logger.info( + ` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}개` + ); + } + + // scope_type != 'menu'인 채번 규칙: menu_objid만 NULL로 설정 (규칙 보존) + const updatedNumberingRules = await client.query( + `UPDATE numbering_rules + SET menu_objid = NULL + WHERE menu_objid = ANY($1) AND company_code = $2 + AND (scope_type IS NULL OR scope_type != 'menu') + RETURNING rule_id`, + [existingMenuIds, targetCompanyCode] + ); + if (updatedNumberingRules.rowCount && updatedNumberingRules.rowCount > 0) { + logger.info( + ` ✅ 테이블 스코프 채번 규칙 연결 해제: ${updatedNumberingRules.rowCount}개 (데이터 보존됨)` + ); + } + + // 5-5. 카테고리 매핑 삭제 (menu_objid가 NOT NULL이므로 NULL 설정 불가) + // 카테고리 매핑은 메뉴와 강하게 연결되어 있으므로 함께 삭제 + const deletedCategoryMappings = await client.query( + `DELETE FROM category_column_mapping + WHERE menu_objid = ANY($1) AND company_code = $2 + RETURNING mapping_id`, + [existingMenuIds, targetCompanyCode] + ); + if ( + deletedCategoryMappings.rowCount && + deletedCategoryMappings.rowCount > 0 + ) { + logger.info( + ` ✅ 카테고리 매핑 삭제 완료: ${deletedCategoryMappings.rowCount}개` + ); + } + + // 5-6. 메뉴 삭제 (배치 삭제 - 하위 메뉴부터 삭제를 위해 역순 정렬된 ID 사용) + // 외래키 제약이 해제되었으므로 배치 삭제 가능 + if (existingMenuIds.length > 0) { + await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [ + existingMenuIds, ]); } logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}개`); @@ -650,7 +818,8 @@ export class MenuCopyService { screenNameConfig?: { removeText?: string; addPrefix?: string; - } + }, + additionalCopyOptions?: AdditionalCopyOptions ): Promise { logger.info(` 🚀 ============================================ @@ -702,30 +871,118 @@ export class MenuCopyService { client ); - // === 3단계: 화면 복사 === - logger.info("\n📄 [3단계] 화면 복사"); + // 변수 초기화 + let copiedCodeCategories = 0; + let copiedCodes = 0; + let copiedNumberingRules = 0; + let copiedCategoryMappings = 0; + let copiedTableTypeColumns = 0; + let copiedCascadingRelations = 0; + let numberingRuleIdMap = new Map(); + + const menuObjids = menus.map((m) => m.objid); + + // 메뉴 ID 맵을 먼저 생성 (일관된 ID 사용을 위해) + const tempMenuIdMap = new Map(); + let tempObjId = await this.getNextMenuObjid(client); + for (const menu of menus) { + tempMenuIdMap.set(menu.objid, tempObjId++); + } + + // === 3단계: 메뉴 복사 (외래키 의존성 해결을 위해 먼저 실행) === + // 채번 규칙, 코드 카테고리 등이 menu_info를 참조하므로 메뉴를 먼저 생성 + logger.info("\n📂 [3단계] 메뉴 복사 (외래키 선행 조건)"); + const menuIdMap = await this.copyMenus( + menus, + sourceMenuObjid, + sourceCompanyCode, + targetCompanyCode, + new Map(), // screenIdMap은 아직 없음 (나중에 할당에서 처리) + userId, + client, + tempMenuIdMap + ); + + // === 4단계: 채번 규칙 복사 (메뉴 복사 후, 화면 복사 전) === + if (additionalCopyOptions?.copyNumberingRules) { + logger.info("\n📦 [4단계] 채번 규칙 복사"); + const ruleResult = await this.copyNumberingRulesWithMap( + menuObjids, + menuIdMap, // 실제 생성된 메뉴 ID 사용 + targetCompanyCode, + userId, + client + ); + copiedNumberingRules = ruleResult.copiedCount; + numberingRuleIdMap = ruleResult.ruleIdMap; + } + + // === 4.1단계: 코드 카테고리 + 코드 복사 === + if (additionalCopyOptions?.copyCodeCategory) { + logger.info("\n📦 [4.1단계] 코드 카테고리 + 코드 복사"); + const codeResult = await this.copyCodeCategoriesAndCodes( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + copiedCodeCategories = codeResult.copiedCategories; + copiedCodes = codeResult.copiedCodes; + } + + // === 4.2단계: 카테고리 매핑 + 값 복사 === + if (additionalCopyOptions?.copyCategoryMapping) { + logger.info("\n📦 [4.2단계] 카테고리 매핑 + 값 복사"); + copiedCategoryMappings = await this.copyCategoryMappingsAndValues( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + } + + // === 4.3단계: 연쇄관계 복사 === + if (additionalCopyOptions?.copyCascadingRelation) { + logger.info("\n📦 [4.3단계] 연쇄관계 복사"); + copiedCascadingRelations = await this.copyCascadingRelations( + sourceCompanyCode, + targetCompanyCode, + menuIdMap, + userId, + client + ); + } + + // === 4.9단계: 화면에서 참조하는 채번규칙 매핑 보완 === + // 화면 properties에서 참조하는 채번규칙 중 아직 매핑되지 않은 것들을 + // 대상 회사에서 같은 이름의 채번규칙으로 매핑 + if (screenIds.size > 0) { + logger.info("\n🔗 [4.9단계] 화면 채번규칙 참조 매핑 보완"); + await this.supplementNumberingRuleMapping( + Array.from(screenIds), + sourceCompanyCode, + targetCompanyCode, + numberingRuleIdMap, + client + ); + } + + // === 5단계: 화면 복사 === + logger.info("\n📄 [5단계] 화면 복사"); const screenIdMap = await this.copyScreens( screenIds, targetCompanyCode, flowIdMap, userId, client, - screenNameConfig + screenNameConfig, + numberingRuleIdMap ); - // === 4단계: 메뉴 복사 === - logger.info("\n📂 [4단계] 메뉴 복사"); - const menuIdMap = await this.copyMenus( - menus, - sourceMenuObjid, // 원본 최상위 메뉴 ID 전달 - targetCompanyCode, - screenIdMap, - userId, - client - ); - - // === 5단계: 화면-메뉴 할당 === - logger.info("\n🔗 [5단계] 화면-메뉴 할당"); + // === 6단계: 화면-메뉴 할당 === + logger.info("\n🔗 [6단계] 화면-메뉴 할당"); await this.createScreenMenuAssignments( menus, menuIdMap, @@ -734,6 +991,17 @@ export class MenuCopyService { client ); + // === 7단계: 테이블 타입 설정 복사 === + if (additionalCopyOptions?.copyTableTypeColumns) { + logger.info("\n📦 [7단계] 테이블 타입 설정 복사"); + copiedTableTypeColumns = await this.copyTableTypeColumns( + Array.from(screenIdMap.keys()), + sourceCompanyCode, + targetCompanyCode, + client + ); + } + // 커밋 await client.query("COMMIT"); logger.info("✅ 트랜잭션 커밋 완료"); @@ -743,6 +1011,12 @@ export class MenuCopyService { copiedMenus: menuIdMap.size, copiedScreens: screenIdMap.size, copiedFlows: flowIdMap.size, + copiedCodeCategories, + copiedCodes, + copiedNumberingRules, + copiedCategoryMappings, + copiedTableTypeColumns, + copiedCascadingRelations, menuIdMap: Object.fromEntries(menuIdMap), screenIdMap: Object.fromEntries(screenIdMap), flowIdMap: Object.fromEntries(flowIdMap), @@ -755,8 +1029,12 @@ export class MenuCopyService { - 메뉴: ${result.copiedMenus}개 - 화면: ${result.copiedScreens}개 - 플로우: ${result.copiedFlows}개 - - ⚠️ 주의: 코드, 카테고리 설정, 채번 규칙은 복사되지 않습니다. + - 코드 카테고리: ${copiedCodeCategories}개 + - 코드: ${copiedCodes}개 + - 채번규칙: ${copiedNumberingRules}개 + - 카테고리 매핑: ${copiedCategoryMappings}개 + - 테이블 타입 설정: ${copiedTableTypeColumns}개 + - 연쇄관계: ${copiedCascadingRelations}개 ============================================ `); @@ -789,144 +1067,228 @@ export class MenuCopyService { return flowIdMap; } + const flowIdArray = Array.from(flowIds); logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`); - logger.info(` 📋 복사 대상 flowIds: [${Array.from(flowIds).join(", ")}]`); + logger.info(` 📋 복사 대상 flowIds: [${flowIdArray.join(", ")}]`); - for (const originalFlowId of flowIds) { - try { - // 1) 원본 flow_definition 조회 - const flowDefResult = await client.query( - `SELECT * FROM flow_definition WHERE id = $1`, - [originalFlowId] + // === 최적화: 배치 조회 === + // 1) 모든 원본 플로우 한 번에 조회 + const allFlowDefsResult = await client.query( + `SELECT * FROM flow_definition WHERE id = ANY($1)`, + [flowIdArray] + ); + const flowDefMap = new Map(allFlowDefsResult.rows.map((f) => [f.id, f])); + + // 2) 대상 회사의 기존 플로우 한 번에 조회 (이름+테이블 기준) + const flowNames = allFlowDefsResult.rows.map((f) => f.name); + const existingFlowsResult = await client.query<{ + id: number; + name: string; + table_name: string; + }>( + `SELECT id, name, table_name FROM flow_definition + WHERE company_code = $1 AND name = ANY($2)`, + [targetCompanyCode, flowNames] + ); + const existingFlowMap = new Map( + existingFlowsResult.rows.map((f) => [`${f.name}|${f.table_name}`, f.id]) + ); + + // 3) 복사가 필요한 플로우 ID 목록 + const flowsToCopy: FlowDefinition[] = []; + + for (const originalFlowId of flowIdArray) { + const flowDef = flowDefMap.get(originalFlowId); + if (!flowDef) { + logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); + continue; + } + + const key = `${flowDef.name}|${flowDef.table_name}`; + const existingId = existingFlowMap.get(key); + + if (existingId) { + flowIdMap.set(originalFlowId, existingId); + logger.info( + ` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${existingId} (${flowDef.name})` ); + } else { + flowsToCopy.push(flowDef); + } + } - if (flowDefResult.rows.length === 0) { - logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); - continue; - } + // 4) 새 플로우 복사 (배치 처리) + if (flowsToCopy.length > 0) { + // 배치 INSERT로 플로우 생성 + const flowValues = flowsToCopy + .map( + (f, i) => + `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8})` + ) + .join(", "); - const flowDef = flowDefResult.rows[0]; - logger.info(` 🔍 원본 플로우 발견: id=${originalFlowId}, name="${flowDef.name}", table="${flowDef.table_name}", company="${flowDef.company_code}"`); + const flowParams = flowsToCopy.flatMap((f) => [ + f.name, + f.description, + f.table_name, + f.is_active, + targetCompanyCode, + userId, + f.db_source_type, + f.db_connection_id, + ]); - // 2) 대상 회사에 이미 같은 이름+테이블의 플로우가 있는지 확인 - const existingFlowResult = await client.query<{ id: number }>( - `SELECT id FROM flow_definition - WHERE company_code = $1 AND name = $2 AND table_name = $3 - LIMIT 1`, - [targetCompanyCode, flowDef.name, flowDef.table_name] - ); - - let newFlowId: number; - - if (existingFlowResult.rows.length > 0) { - // 기존 플로우가 있으면 재사용 - newFlowId = existingFlowResult.rows[0].id; - flowIdMap.set(originalFlowId, newFlowId); - logger.info( - ` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${newFlowId} (${flowDef.name})` - ); - continue; // 스텝/연결 복사 생략 (기존 것 사용) - } - - // 3) 새 flow_definition 복사 - const newFlowResult = await client.query<{ id: number }>( - `INSERT INTO flow_definition ( + const newFlowsResult = await client.query<{ id: number }>( + `INSERT INTO flow_definition ( name, description, table_name, is_active, company_code, created_by, db_source_type, db_connection_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ) VALUES ${flowValues} RETURNING id`, - [ - flowDef.name, - flowDef.description, - flowDef.table_name, - flowDef.is_active, - targetCompanyCode, // 새 회사 코드 - userId, - flowDef.db_source_type, - flowDef.db_connection_id, - ] - ); - - newFlowId = newFlowResult.rows[0].id; - flowIdMap.set(originalFlowId, newFlowId); + flowParams + ); + // 새 플로우 ID 매핑 + flowsToCopy.forEach((flowDef, index) => { + const newFlowId = newFlowsResult.rows[index].id; + flowIdMap.set(flowDef.id, newFlowId); logger.info( - ` ✅ 플로우 신규 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` + ` ✅ 플로우 신규 복사: ${flowDef.id} → ${newFlowId} (${flowDef.name})` ); + }); - // 3) flow_step 복사 - const stepsResult = await client.query( - `SELECT * FROM flow_step WHERE flow_definition_id = $1 ORDER BY step_order`, - [originalFlowId] - ); + // 5) 스텝 및 연결 복사 (복사된 플로우만) + const originalFlowIdsToCopy = flowsToCopy.map((f) => f.id); + // 모든 스텝 한 번에 조회 + const allStepsResult = await client.query( + `SELECT * FROM flow_step WHERE flow_definition_id = ANY($1) ORDER BY flow_definition_id, step_order`, + [originalFlowIdsToCopy] + ); + + // 플로우별 스텝 그룹핑 + const stepsByFlow = new Map(); + for (const step of allStepsResult.rows) { + if (!stepsByFlow.has(step.flow_definition_id)) { + stepsByFlow.set(step.flow_definition_id, []); + } + stepsByFlow.get(step.flow_definition_id)!.push(step); + } + + // 스텝 복사 (플로우별) + const allStepIdMaps = new Map>(); // originalFlowId -> stepIdMap + + for (const originalFlowId of originalFlowIdsToCopy) { + const newFlowId = flowIdMap.get(originalFlowId)!; + const steps = stepsByFlow.get(originalFlowId) || []; const stepIdMap = new Map(); - for (const step of stepsResult.rows) { - const newStepResult = await client.query<{ id: number }>( + if (steps.length > 0) { + // 배치 INSERT로 스텝 생성 + const stepValues = steps + .map( + (_, i) => + `($${i * 17 + 1}, $${i * 17 + 2}, $${i * 17 + 3}, $${i * 17 + 4}, $${i * 17 + 5}, $${i * 17 + 6}, $${i * 17 + 7}, $${i * 17 + 8}, $${i * 17 + 9}, $${i * 17 + 10}, $${i * 17 + 11}, $${i * 17 + 12}, $${i * 17 + 13}, $${i * 17 + 14}, $${i * 17 + 15}, $${i * 17 + 16}, $${i * 17 + 17})` + ) + .join(", "); + + const stepParams = steps.flatMap((s) => [ + newFlowId, + s.step_name, + s.step_order, + s.condition_json, + s.color, + s.position_x, + s.position_y, + s.table_name, + s.move_type, + s.status_column, + s.status_value, + s.target_table, + s.field_mappings, + s.required_fields, + s.integration_type, + s.integration_config, + s.display_config, + ]); + + const newStepsResult = await client.query<{ id: number }>( `INSERT INTO flow_step ( flow_definition_id, step_name, step_order, condition_json, color, position_x, position_y, table_name, move_type, status_column, status_value, target_table, field_mappings, required_fields, integration_type, integration_config, display_config - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + ) VALUES ${stepValues} RETURNING id`, - [ - newFlowId, // 새 플로우 ID - step.step_name, - step.step_order, - step.condition_json, - step.color, - step.position_x, - step.position_y, - step.table_name, - step.move_type, - step.status_column, - step.status_value, - step.target_table, - step.field_mappings, - step.required_fields, - step.integration_type, - step.integration_config, - step.display_config, - ] + stepParams ); - const newStepId = newStepResult.rows[0].id; - stepIdMap.set(step.id, newStepId); + steps.forEach((step, index) => { + stepIdMap.set(step.id, newStepsResult.rows[index].id); + }); + + logger.info( + ` ↳ 플로우 ${originalFlowId}: 스텝 ${steps.length}개 복사` + ); } - logger.info(` ↳ 스텝 복사: ${stepIdMap.size}개`); + allStepIdMaps.set(originalFlowId, stepIdMap); + } - // 4) flow_step_connection 복사 (스텝 ID 재매핑) - const connectionsResult = await client.query( - `SELECT * FROM flow_step_connection WHERE flow_definition_id = $1`, - [originalFlowId] + // 모든 연결 한 번에 조회 + const allConnectionsResult = await client.query( + `SELECT * FROM flow_step_connection WHERE flow_definition_id = ANY($1)`, + [originalFlowIdsToCopy] + ); + + // 연결 복사 (배치 INSERT) + const connectionsToInsert: { + newFlowId: number; + newFromStepId: number; + newToStepId: number; + label: string; + }[] = []; + + for (const conn of allConnectionsResult.rows) { + const stepIdMap = allStepIdMaps.get(conn.flow_definition_id); + if (!stepIdMap) continue; + + const newFromStepId = stepIdMap.get(conn.from_step_id); + const newToStepId = stepIdMap.get(conn.to_step_id); + const newFlowId = flowIdMap.get(conn.flow_definition_id); + + if (newFromStepId && newToStepId && newFlowId) { + connectionsToInsert.push({ + newFlowId, + newFromStepId, + newToStepId, + label: conn.label || "", + }); + } + } + + if (connectionsToInsert.length > 0) { + const connValues = connectionsToInsert + .map( + (_, i) => + `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})` + ) + .join(", "); + + const connParams = connectionsToInsert.flatMap((c) => [ + c.newFlowId, + c.newFromStepId, + c.newToStepId, + c.label, + ]); + + await client.query( + `INSERT INTO flow_step_connection ( + flow_definition_id, from_step_id, to_step_id, label + ) VALUES ${connValues}`, + connParams ); - for (const conn of connectionsResult.rows) { - const newFromStepId = stepIdMap.get(conn.from_step_id); - const newToStepId = stepIdMap.get(conn.to_step_id); - - if (!newFromStepId || !newToStepId) { - logger.warn( - `⚠️ 스텝 ID 매핑 실패: ${conn.from_step_id} → ${conn.to_step_id}` - ); - continue; - } - - await client.query( - `INSERT INTO flow_step_connection ( - flow_definition_id, from_step_id, to_step_id, label - ) VALUES ($1, $2, $3, $4)`, - [newFlowId, newFromStepId, newToStepId, conn.label] - ); - } - - logger.info(` ↳ 연결 복사: ${connectionsResult.rows.length}개`); - } catch (error: any) { - logger.error(`❌ 플로우 복사 실패: id=${originalFlowId}`, error); - throw error; + logger.info(` ↳ 연결 ${connectionsToInsert.length}개 복사`); } } @@ -949,7 +1311,8 @@ export class MenuCopyService { screenNameConfig?: { removeText?: string; addPrefix?: string; - } + }, + numberingRuleIdMap?: Map ): Promise> { const screenIdMap = new Map(); @@ -960,6 +1323,37 @@ export class MenuCopyService { logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}개`); + // === 0단계: 원본 화면 정의 배치 조회 === + const screenIdArray = Array.from(screenIds); + const allScreenDefsResult = await client.query( + `SELECT * FROM screen_definitions WHERE screen_id = ANY($1)`, + [screenIdArray] + ); + const screenDefMap = new Map(); + for (const def of allScreenDefsResult.rows) { + screenDefMap.set(def.screen_id, def); + } + + // 대상 회사의 기존 복사본 배치 조회 (source_screen_id 기준) + const existingCopiesResult = await client.query<{ + screen_id: number; + screen_name: string; + source_screen_id: number; + updated_date: Date; + }>( + `SELECT screen_id, screen_name, source_screen_id, updated_date + FROM screen_definitions + WHERE source_screen_id = ANY($1) AND company_code = $2 AND deleted_date IS NULL`, + [screenIdArray, targetCompanyCode] + ); + const existingCopyMap = new Map< + number, + { screen_id: number; screen_name: string; updated_date: Date } + >(); + for (const copy of existingCopiesResult.rows) { + existingCopyMap.set(copy.source_screen_id, copy); + } + // === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) === const screenDefsToProcess: Array<{ originalScreenId: number; @@ -970,31 +1364,47 @@ export class MenuCopyService { for (const originalScreenId of screenIds) { try { - // 1) 원본 screen_definitions 조회 - const screenDefResult = await client.query( - `SELECT * FROM screen_definitions WHERE screen_id = $1`, - [originalScreenId] - ); + // 1) 원본 screen_definitions 조회 (캐시에서) + const screenDef = screenDefMap.get(originalScreenId); - if (screenDefResult.rows.length === 0) { + if (!screenDef) { logger.warn(`⚠️ 화면을 찾을 수 없음: screen_id=${originalScreenId}`); continue; } - const screenDef = screenDefResult.rows[0]; + // 2) 기존 복사본 찾기: 캐시에서 조회 (source_screen_id 기준) + let existingCopy = existingCopyMap.get(originalScreenId); - // 2) 기존 복사본 찾기: source_screen_id로 검색 - const existingCopyResult = await client.query<{ - screen_id: number; - screen_name: string; - updated_date: Date; - }>( - `SELECT screen_id, screen_name, updated_date - FROM screen_definitions - WHERE source_screen_id = $1 AND company_code = $2 AND deleted_date IS NULL - LIMIT 1`, - [originalScreenId, targetCompanyCode] - ); + // 2-1) source_screen_id가 없는 기존 복사본 (이름 + 테이블로 검색) - 호환성 유지 + if (!existingCopy && screenDef.screen_name) { + const legacyCopyResult = await client.query<{ + screen_id: number; + screen_name: string; + updated_date: Date; + }>( + `SELECT screen_id, screen_name, updated_date + FROM screen_definitions + WHERE screen_name = $1 + AND table_name = $2 + AND company_code = $3 + AND source_screen_id IS NULL + AND deleted_date IS NULL + LIMIT 1`, + [screenDef.screen_name, screenDef.table_name, targetCompanyCode] + ); + + if (legacyCopyResult.rows.length > 0) { + existingCopy = legacyCopyResult.rows[0]; + // 기존 복사본에 source_screen_id 업데이트 (마이그레이션) + await client.query( + `UPDATE screen_definitions SET source_screen_id = $1 WHERE screen_id = $2`, + [originalScreenId, existingCopy.screen_id] + ); + logger.info( + ` 📝 기존 화면에 source_screen_id 추가: ${existingCopy.screen_id} ← ${originalScreenId}` + ); + } + } // 3) 화면명 변환 적용 let transformedScreenName = screenDef.screen_name; @@ -1012,10 +1422,9 @@ export class MenuCopyService { } } - if (existingCopyResult.rows.length > 0) { + if (existingCopy) { // === 기존 복사본이 있는 경우: 업데이트 === - const existingScreen = existingCopyResult.rows[0]; - const existingScreenId = existingScreen.screen_id; + const existingScreenId = existingCopy.screen_id; // 원본 레이아웃 조회 const sourceLayoutsResult = await client.query( @@ -1129,10 +1538,7 @@ export class MenuCopyService { }); } } catch (error: any) { - logger.error( - `❌ 화면 처리 실패: screen_id=${originalScreenId}`, - error - ); + logger.error(`❌ 화면 처리 실패: screen_id=${originalScreenId}`, error); throw error; } } @@ -1166,35 +1572,39 @@ export class MenuCopyService { // component_id 매핑 생성 (원본 → 새 ID) const componentIdMap = new Map(); - for (const layout of layoutsResult.rows) { - const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const timestamp = Date.now(); + layoutsResult.rows.forEach((layout, idx) => { + const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`; componentIdMap.set(layout.component_id, newComponentId); - } + }); - // 레이아웃 삽입 - for (const layout of layoutsResult.rows) { - const newComponentId = componentIdMap.get(layout.component_id)!; + // 레이아웃 배치 삽입 준비 + if (layoutsResult.rows.length > 0) { + const layoutValues: string[] = []; + const layoutParams: any[] = []; + let paramIdx = 1; - const newParentId = layout.parent_id - ? componentIdMap.get(layout.parent_id) || layout.parent_id - : null; - const newZoneId = layout.zone_id - ? componentIdMap.get(layout.zone_id) || layout.zone_id - : null; + for (const layout of layoutsResult.rows) { + const newComponentId = componentIdMap.get(layout.component_id)!; - const updatedProperties = this.updateReferencesInProperties( - layout.properties, - screenIdMap, - flowIdMap - ); + const newParentId = layout.parent_id + ? componentIdMap.get(layout.parent_id) || layout.parent_id + : null; + const newZoneId = layout.zone_id + ? componentIdMap.get(layout.zone_id) || layout.zone_id + : null; - await client.query( - `INSERT INTO screen_layouts ( - screen_id, component_type, component_id, parent_id, - position_x, position_y, width, height, properties, - display_order, layout_type, layout_config, zones_config, zone_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, - [ + const updatedProperties = this.updateReferencesInProperties( + layout.properties, + screenIdMap, + flowIdMap, + numberingRuleIdMap + ); + + layoutValues.push( + `($${paramIdx}, $${paramIdx + 1}, $${paramIdx + 2}, $${paramIdx + 3}, $${paramIdx + 4}, $${paramIdx + 5}, $${paramIdx + 6}, $${paramIdx + 7}, $${paramIdx + 8}, $${paramIdx + 9}, $${paramIdx + 10}, $${paramIdx + 11}, $${paramIdx + 12}, $${paramIdx + 13})` + ); + layoutParams.push( targetScreenId, layout.component_type, newComponentId, @@ -1208,8 +1618,19 @@ export class MenuCopyService { layout.layout_type, layout.layout_config, layout.zones_config, - newZoneId, - ] + newZoneId + ); + paramIdx += 14; + } + + // 배치 INSERT + await client.query( + `INSERT INTO screen_layouts ( + screen_id, component_type, component_id, parent_id, + position_x, position_y, width, height, properties, + display_order, layout_type, layout_config, zones_config, zone_id + ) VALUES ${layoutValues.join(", ")}`, + layoutParams ); } @@ -1332,16 +1753,81 @@ export class MenuCopyService { return screenCode; } + /** + * 대상 회사에서 부모 메뉴 찾기 + * - 원본 메뉴의 parent_obj_id를 source_menu_objid로 가진 메뉴를 대상 회사에서 검색 + * - 2레벨 이하 메뉴 복사 시 기존에 복사된 부모 메뉴와 연결하기 위함 + */ + private async findParentMenuInTargetCompany( + originalParentObjId: number, + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise { + // 1. 대상 회사에서 source_menu_objid가 원본 부모 ID인 메뉴 찾기 + const result = await client.query<{ objid: number }>( + `SELECT objid FROM menu_info + WHERE source_menu_objid = $1 AND company_code = $2 + LIMIT 1`, + [originalParentObjId, targetCompanyCode] + ); + + if (result.rows.length > 0) { + return result.rows[0].objid; + } + + // 2. source_menu_objid로 못 찾으면, 동일 원본 회사에서 복사된 메뉴 중 같은 이름으로 찾기 (fallback) + // 원본 부모 메뉴 정보 조회 + const parentMenuResult = await client.query( + `SELECT * FROM menu_info WHERE objid = $1`, + [originalParentObjId] + ); + + if (parentMenuResult.rows.length === 0) { + return null; + } + + const parentMenu = parentMenuResult.rows[0]; + + // 대상 회사에서 같은 이름 + 같은 원본 회사에서 복사된 메뉴 찾기 + // source_menu_objid가 있는 메뉴(복사된 메뉴)만 대상으로, + // 해당 source_menu_objid의 원본 메뉴가 같은 회사(sourceCompanyCode)에 속하는지 확인 + const sameNameResult = await client.query<{ objid: number }>( + `SELECT m.objid FROM menu_info m + WHERE m.menu_name_kor = $1 + AND m.company_code = $2 + AND m.source_menu_objid IS NOT NULL + AND EXISTS ( + SELECT 1 FROM menu_info orig + WHERE orig.objid = m.source_menu_objid + AND orig.company_code = $3 + ) + LIMIT 1`, + [parentMenu.menu_name_kor, targetCompanyCode, sourceCompanyCode] + ); + + if (sameNameResult.rows.length > 0) { + logger.info( + ` 📎 이름으로 부모 메뉴 찾음: "${parentMenu.menu_name_kor}" → objid: ${sameNameResult.rows[0].objid}` + ); + return sameNameResult.rows[0].objid; + } + + return null; + } + /** * 메뉴 복사 */ private async copyMenus( menus: Menu[], rootMenuObjid: number, + sourceCompanyCode: string, targetCompanyCode: string, screenIdMap: Map, userId: string, - client: PoolClient + client: PoolClient, + preAllocatedMenuIdMap?: Map // 미리 할당된 메뉴 ID 맵 (옵션 데이터 복사에 사용된 경우) ): Promise> { const menuIdMap = new Map(); @@ -1357,27 +1843,115 @@ export class MenuCopyService { for (const menu of sortedMenus) { try { - // 새 objid 생성 - const newObjId = await this.getNextMenuObjid(client); + // 0. 이미 복사된 메뉴가 있는지 확인 (고아 메뉴 재연결용) + // 1차: source_menu_objid로 검색 + let existingCopyResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( + `SELECT objid, parent_obj_id FROM menu_info + WHERE source_menu_objid = $1 AND company_code = $2 + LIMIT 1`, + [menu.objid, targetCompanyCode] + ); - // parent_obj_id 재매핑 - // NULL이나 0은 최상위 메뉴를 의미하므로 0으로 통일 + // 2차: source_menu_objid가 없는 기존 복사본 (이름 + 메뉴타입으로 검색) - 호환성 유지 + if (existingCopyResult.rows.length === 0 && menu.menu_name_kor) { + existingCopyResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( + `SELECT objid, parent_obj_id FROM menu_info + WHERE menu_name_kor = $1 + AND company_code = $2 + AND menu_type = $3 + AND source_menu_objid IS NULL + LIMIT 1`, + [menu.menu_name_kor, targetCompanyCode, menu.menu_type] + ); + + if (existingCopyResult.rows.length > 0) { + // 기존 복사본에 source_menu_objid 업데이트 (마이그레이션) + await client.query( + `UPDATE menu_info SET source_menu_objid = $1 WHERE objid = $2`, + [menu.objid, existingCopyResult.rows[0].objid] + ); + logger.info( + ` 📝 기존 메뉴에 source_menu_objid 추가: ${existingCopyResult.rows[0].objid} ← ${menu.objid}` + ); + } + } + + // parent_obj_id 계산 (신규/재연결 모두 필요) let newParentObjId: number | null; if (!menu.parent_obj_id || menu.parent_obj_id === 0) { newParentObjId = 0; // 최상위 메뉴는 항상 0 } else { - newParentObjId = - menuIdMap.get(menu.parent_obj_id) || menu.parent_obj_id; + // 1. 현재 복사 세션에서 부모가 이미 복사되었는지 확인 + newParentObjId = menuIdMap.get(menu.parent_obj_id) || null; + + // 2. 현재 세션에서 못 찾으면, 대상 회사에서 기존에 복사된 부모 찾기 + if (!newParentObjId) { + const existingParent = await this.findParentMenuInTargetCompany( + menu.parent_obj_id, + sourceCompanyCode, + targetCompanyCode, + client + ); + + if (existingParent) { + newParentObjId = existingParent; + logger.info( + ` 🔗 기존 부모 메뉴 연결: 원본 ${menu.parent_obj_id} → 대상 ${existingParent}` + ); + } else { + // 3. 부모를 못 찾으면 최상위로 설정 (경고 로그) + newParentObjId = 0; + logger.warn( + ` ⚠️ 부모 메뉴를 찾을 수 없음: ${menu.parent_obj_id} - 최상위로 생성됨` + ); + } + } } - // source_menu_objid 저장: 원본 최상위 메뉴만 저장 (덮어쓰기 식별용) - // BigInt 타입이 문자열로 반환될 수 있으므로 문자열로 변환 후 비교 - const isRootMenu = String(menu.objid) === String(rootMenuObjid); - const sourceMenuObjid = isRootMenu ? menu.objid : null; + if (existingCopyResult.rows.length > 0) { + // === 이미 복사된 메뉴가 있는 경우: 재연결만 === + const existingMenu = existingCopyResult.rows[0]; + const existingObjId = existingMenu.objid; + const existingParentId = existingMenu.parent_obj_id; - if (sourceMenuObjid) { + // 부모가 다르면 업데이트 (고아 메뉴 재연결) + if (existingParentId !== newParentObjId) { + await client.query( + `UPDATE menu_info SET parent_obj_id = $1, writer = $2 WHERE objid = $3`, + [newParentObjId, userId, existingObjId] + ); + logger.info( + ` ♻️ 메뉴 재연결: ${menu.objid} → ${existingObjId} (${menu.menu_name_kor}), parent: ${existingParentId} → ${newParentObjId}` + ); + } else { + logger.info( + ` ⏭️ 메뉴 이미 존재 (스킵): ${menu.objid} → ${existingObjId} (${menu.menu_name_kor})` + ); + } + + menuIdMap.set(menu.objid, existingObjId); + continue; + } + + // === 신규 메뉴 복사 === + // 미리 할당된 ID가 있으면 사용, 없으면 새로 생성 + const newObjId = + preAllocatedMenuIdMap?.get(menu.objid) ?? + (await this.getNextMenuObjid(client)); + + // source_menu_objid 저장: 모든 복사된 메뉴에 원본 ID 저장 (추적용) + const sourceMenuObjid = menu.objid; + const isRootMenu = String(menu.objid) === String(rootMenuObjid); + + if (isRootMenu) { logger.info( - ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (원본 최상위 메뉴)` + ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (복사 시작 메뉴)` ); } @@ -1426,7 +2000,7 @@ export class MenuCopyService { } /** - * 화면-메뉴 할당 + * 화면-메뉴 할당 (최적화: 배치 조회/삽입) */ private async createScreenMenuAssignments( menus: Menu[], @@ -1437,53 +2011,1086 @@ export class MenuCopyService { ): Promise { logger.info(`🔗 화면-메뉴 할당 중...`); - let assignmentCount = 0; + if (menus.length === 0) { + return; + } - for (const menu of menus) { - const newMenuObjid = menuIdMap.get(menu.objid); - if (!newMenuObjid) continue; + // === 최적화: 배치 조회 === + // 1. 모든 원본 메뉴의 화면 할당 한 번에 조회 + const menuObjids = menus.map((m) => m.objid); + const companyCodes = [...new Set(menus.map((m) => m.company_code))]; - // 원본 메뉴에 할당된 화면 조회 - const assignmentsResult = await client.query<{ - screen_id: number; - display_order: number; - is_active: string; - }>( - `SELECT screen_id, display_order, is_active + const allAssignmentsResult = await client.query<{ + menu_objid: number; + screen_id: number; + display_order: number; + is_active: string; + }>( + `SELECT menu_objid, screen_id, display_order, is_active FROM screen_menu_assignments - WHERE menu_objid = $1 AND company_code = $2`, - [menu.objid, menu.company_code] - ); + WHERE menu_objid = ANY($1) AND company_code = ANY($2)`, + [menuObjids, companyCodes] + ); - for (const assignment of assignmentsResult.rows) { - const newScreenId = screenIdMap.get(assignment.screen_id); + if (allAssignmentsResult.rows.length === 0) { + logger.info(` 📭 화면-메뉴 할당 없음`); + return; + } + + // 2. 유효한 할당만 필터링 + const validAssignments: Array<{ + newScreenId: number; + newMenuObjid: number; + displayOrder: number; + isActive: string; + }> = []; + + for (const assignment of allAssignmentsResult.rows) { + const newMenuObjid = menuIdMap.get(assignment.menu_objid); + const newScreenId = screenIdMap.get(assignment.screen_id); + + if (!newMenuObjid || !newScreenId) { if (!newScreenId) { logger.warn( `⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}` ); - continue; } + continue; + } - // 새 할당 생성 - await client.query( - `INSERT INTO screen_menu_assignments ( + validAssignments.push({ + newScreenId, + newMenuObjid, + displayOrder: assignment.display_order, + isActive: assignment.is_active, + }); + } + + // 3. 배치 INSERT + if (validAssignments.length > 0) { + const assignmentValues = validAssignments + .map( + (_, i) => + `($${i * 6 + 1}, $${i * 6 + 2}, $${i * 6 + 3}, $${i * 6 + 4}, $${i * 6 + 5}, $${i * 6 + 6})` + ) + .join(", "); + + const assignmentParams = validAssignments.flatMap((a) => [ + a.newScreenId, + a.newMenuObjid, + targetCompanyCode, + a.displayOrder, + a.isActive, + "system", + ]); + + await client.query( + `INSERT INTO screen_menu_assignments ( screen_id, menu_objid, company_code, display_order, is_active, created_by - ) VALUES ($1, $2, $3, $4, $5, $6)`, - [ - newScreenId, // 재매핑 - newMenuObjid, // 재매핑 - targetCompanyCode, - assignment.display_order, - assignment.is_active, - "system", - ] - ); + ) VALUES ${assignmentValues}`, + assignmentParams + ); + } - assignmentCount++; + logger.info(`✅ 화면-메뉴 할당 완료: ${validAssignments.length}개`); + } + + /** + * 코드 카테고리 + 코드 복사 (최적화: 배치 조회/삽입) + */ + private async copyCodeCategoriesAndCodes( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise<{ copiedCategories: number; copiedCodes: number }> { + let copiedCategories = 0; + let copiedCodes = 0; + + if (menuObjids.length === 0) { + return { copiedCategories, copiedCodes }; + } + + // === 최적화: 배치 조회 === + // 1. 모든 원본 카테고리 한 번에 조회 + const allCategoriesResult = await client.query( + `SELECT * FROM code_category WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + if (allCategoriesResult.rows.length === 0) { + logger.info(` 📭 복사할 코드 카테고리 없음`); + return { copiedCategories, copiedCodes }; + } + + // 2. 대상 회사에 이미 존재하는 카테고리 한 번에 조회 + const categoryCodes = allCategoriesResult.rows.map((c) => c.category_code); + const existingCategoriesResult = await client.query( + `SELECT category_code FROM code_category + WHERE category_code = ANY($1) AND company_code = $2`, + [categoryCodes, targetCompanyCode] + ); + const existingCategoryCodes = new Set( + existingCategoriesResult.rows.map((c) => c.category_code) + ); + + // 3. 복사할 카테고리 필터링 + const categoriesToCopy = allCategoriesResult.rows.filter( + (c) => !existingCategoryCodes.has(c.category_code) + ); + + // 4. 배치 INSERT로 카테고리 복사 + if (categoriesToCopy.length > 0) { + const categoryValues = categoriesToCopy + .map( + (_, i) => + `($${i * 9 + 1}, $${i * 9 + 2}, $${i * 9 + 3}, $${i * 9 + 4}, $${i * 9 + 5}, $${i * 9 + 6}, NOW(), $${i * 9 + 7}, $${i * 9 + 8}, $${i * 9 + 9})` + ) + .join(", "); + + const categoryParams = categoriesToCopy.flatMap((c) => { + const newMenuObjid = menuIdMap.get(c.menu_objid); + return [ + c.category_code, + c.category_name, + c.category_name_eng, + c.description, + c.sort_order, + c.is_active, + userId, + targetCompanyCode, + newMenuObjid, + ]; + }); + + await client.query( + `INSERT INTO code_category ( + category_code, category_name, category_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ${categoryValues}`, + categoryParams + ); + + copiedCategories = categoriesToCopy.length; + logger.info(` ✅ 코드 카테고리 ${copiedCategories}개 복사`); + } + + // 5. 모든 원본 코드 한 번에 조회 + const allCodesResult = await client.query( + `SELECT * FROM code_info WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + if (allCodesResult.rows.length === 0) { + logger.info(` 📭 복사할 코드 없음`); + return { copiedCategories, copiedCodes }; + } + + // 6. 대상 회사에 이미 존재하는 코드 한 번에 조회 + const existingCodesResult = await client.query( + `SELECT code_category, code_value FROM code_info + WHERE menu_objid = ANY($1) AND company_code = $2`, + [Array.from(menuIdMap.values()), targetCompanyCode] + ); + const existingCodeKeys = new Set( + existingCodesResult.rows.map((c) => `${c.code_category}|${c.code_value}`) + ); + + // 7. 복사할 코드 필터링 + const codesToCopy = allCodesResult.rows.filter( + (c) => !existingCodeKeys.has(`${c.code_category}|${c.code_value}`) + ); + + // 8. 배치 INSERT로 코드 복사 + if (codesToCopy.length > 0) { + const codeValues = codesToCopy + .map( + (_, i) => + `($${i * 10 + 1}, $${i * 10 + 2}, $${i * 10 + 3}, $${i * 10 + 4}, $${i * 10 + 5}, $${i * 10 + 6}, $${i * 10 + 7}, NOW(), $${i * 10 + 8}, $${i * 10 + 9}, $${i * 10 + 10})` + ) + .join(", "); + + const codeParams = codesToCopy.flatMap((c) => { + const newMenuObjid = menuIdMap.get(c.menu_objid); + return [ + c.code_category, + c.code_value, + c.code_name, + c.code_name_eng, + c.description, + c.sort_order, + c.is_active, + userId, + targetCompanyCode, + newMenuObjid, + ]; + }); + + await client.query( + `INSERT INTO code_info ( + code_category, code_value, code_name, code_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ${codeValues}`, + codeParams + ); + + copiedCodes = codesToCopy.length; + logger.info(` ✅ 코드 ${copiedCodes}개 복사`); + } + + logger.info( + `✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}개` + ); + return { copiedCategories, copiedCodes }; + } + + /** + * 화면에서 참조하는 채번규칙 매핑 보완 + * 화면 properties에서 참조하는 채번규칙 중 아직 매핑되지 않은 것들을 + * 대상 회사에서 같은 이름(rule_name)의 채번규칙으로 매핑 + */ + private async supplementNumberingRuleMapping( + screenIds: number[], + sourceCompanyCode: string, + targetCompanyCode: string, + numberingRuleIdMap: Map, + client: PoolClient + ): Promise { + if (screenIds.length === 0) return; + + // 1. 화면 레이아웃에서 모든 채번규칙 ID 추출 + const layoutsResult = await client.query( + `SELECT properties::text as props FROM screen_layouts WHERE screen_id = ANY($1)`, + [screenIds] + ); + + const referencedRuleIds = new Set(); + const ruleIdRegex = /"numberingRuleId"\s*:\s*"([^"]+)"/g; + + for (const row of layoutsResult.rows) { + if (!row.props) continue; + let match; + while ((match = ruleIdRegex.exec(row.props)) !== null) { + const ruleId = match[1]; + // 이미 매핑된 것은 제외 + if (ruleId && !numberingRuleIdMap.has(ruleId)) { + referencedRuleIds.add(ruleId); + } } } - logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); + if (referencedRuleIds.size === 0) { + logger.info(` 📭 추가 매핑 필요 없음`); + return; + } + + logger.info(` 🔍 매핑 필요한 채번규칙: ${referencedRuleIds.size}개`); + + // 2. 원본 채번규칙 정보 조회 (rule_name으로 대상 회사에서 찾기 위해) + const sourceRulesResult = await client.query( + `SELECT rule_id, rule_name, table_name FROM numbering_rules + WHERE rule_id = ANY($1)`, + [Array.from(referencedRuleIds)] + ); + + if (sourceRulesResult.rows.length === 0) { + logger.warn( + ` ⚠️ 원본 채번규칙 조회 실패: ${Array.from(referencedRuleIds).join(", ")}` + ); + return; + } + + // 3. 대상 회사에서 같은 이름의 채번규칙 찾기 + const ruleNames = sourceRulesResult.rows.map((r) => r.rule_name); + const targetRulesResult = await client.query( + `SELECT rule_id, rule_name, table_name FROM numbering_rules + WHERE rule_name = ANY($1) AND company_code = $2`, + [ruleNames, targetCompanyCode] + ); + + // rule_name -> target_rule_id 매핑 + const targetRulesByName = new Map(); + for (const r of targetRulesResult.rows) { + // 같은 이름이 여러 개일 수 있으므로 첫 번째만 사용 + if (!targetRulesByName.has(r.rule_name)) { + targetRulesByName.set(r.rule_name, r.rule_id); + } + } + + // 4. 매핑 추가 + let mappedCount = 0; + for (const sourceRule of sourceRulesResult.rows) { + const targetRuleId = targetRulesByName.get(sourceRule.rule_name); + if (targetRuleId) { + numberingRuleIdMap.set(sourceRule.rule_id, targetRuleId); + logger.info( + ` ✅ 채번규칙 매핑 추가: ${sourceRule.rule_id} (${sourceRule.rule_name}) → ${targetRuleId}` + ); + mappedCount++; + } else { + logger.warn( + ` ⚠️ 대상 회사에 같은 이름의 채번규칙 없음: ${sourceRule.rule_name}` + ); + } + } + + logger.info( + ` ✅ 채번규칙 매핑 보완 완료: ${mappedCount}/${referencedRuleIds.size}개` + ); } + /** + * 채번 규칙 복사 (최적화: 배치 조회/삽입) + * 화면 복사 전에 호출되어 numberingRuleId 참조 업데이트에 사용됨 + */ + private async copyNumberingRulesWithMap( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise<{ copiedCount: number; ruleIdMap: Map }> { + let copiedCount = 0; + const ruleIdMap = new Map(); + + if (menuObjids.length === 0) { + return { copiedCount, ruleIdMap }; + } + + // === 최적화: 배치 조회 === + // 1. 모든 원본 채번 규칙 한 번에 조회 + const allRulesResult = await client.query( + `SELECT * FROM numbering_rules WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + if (allRulesResult.rows.length === 0) { + logger.info(` 📭 복사할 채번 규칙 없음`); + return { copiedCount, ruleIdMap }; + } + + // 2. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회 + const ruleIds = allRulesResult.rows.map((r) => r.rule_id); + const existingRulesResult = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE rule_id = ANY($1) AND company_code = $2`, + [ruleIds, targetCompanyCode] + ); + const existingRuleIds = new Set( + existingRulesResult.rows.map((r) => r.rule_id) + ); + + // 3. 복사할 규칙과 스킵할 규칙 분류 + const rulesToCopy: any[] = []; + const originalToNewRuleMap: Array<{ original: string; new: string }> = []; + + // 기존 규칙 중 menu_objid 업데이트가 필요한 규칙들 + const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = []; + + for (const rule of allRulesResult.rows) { + if (existingRuleIds.has(rule.rule_id)) { + // 기존 규칙은 동일한 ID로 매핑 + ruleIdMap.set(rule.rule_id, rule.rule_id); + + // 새 메뉴 ID로 연결 업데이트 필요 + const newMenuObjid = menuIdMap.get(rule.menu_objid); + if (newMenuObjid) { + rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid }); + } + logger.info( + ` ♻️ 채번규칙 이미 존재 (메뉴 연결 갱신): ${rule.rule_id}` + ); + } else { + // 새 rule_id 생성 + const originalSuffix = rule.rule_id.includes("_") + ? rule.rule_id.replace(/^[^_]*_/, "") + : rule.rule_id; + const newRuleId = `${targetCompanyCode}_${originalSuffix}`; + + ruleIdMap.set(rule.rule_id, newRuleId); + originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId }); + rulesToCopy.push({ ...rule, newRuleId }); + } + } + + // 4. 배치 INSERT로 채번 규칙 복사 + if (rulesToCopy.length > 0) { + const ruleValues = rulesToCopy + .map( + (_, i) => + `($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})` + ) + .join(", "); + + const ruleParams = rulesToCopy.flatMap((r) => { + const newMenuObjid = menuIdMap.get(r.menu_objid); + return [ + r.newRuleId, + r.rule_name, + r.description, + r.separator, + r.reset_period, + 0, + r.table_name, + r.column_name, + targetCompanyCode, + userId, + newMenuObjid, + r.scope_type, + null, + ]; + }); + + await client.query( + `INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, reset_period, + current_sequence, table_name, column_name, company_code, + created_at, created_by, menu_objid, scope_type, last_generated_date + ) VALUES ${ruleValues}`, + ruleParams + ); + + copiedCount = rulesToCopy.length; + logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`); + } + + // 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리 + if (rulesToUpdate.length > 0) { + // CASE WHEN을 사용한 배치 업데이트 + const caseWhen = rulesToUpdate + .map((_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}`) + .join(" "); + const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId); + const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]); + + await client.query( + `UPDATE numbering_rules + SET menu_objid = CASE ${caseWhen} END, updated_at = NOW() + WHERE rule_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`, + [...params, ruleIdsForUpdate, targetCompanyCode] + ); + logger.info( + ` ✅ 기존 채번 규칙 ${rulesToUpdate.length}개 메뉴 연결 갱신` + ); + } + + // 5. 모든 원본 파트 한 번에 조회 (새로 복사한 규칙만 대상) + if (rulesToCopy.length > 0) { + const originalRuleIds = rulesToCopy.map((r) => r.rule_id); + const allPartsResult = await client.query( + `SELECT * FROM numbering_rule_parts + WHERE rule_id = ANY($1) ORDER BY rule_id, part_order`, + [originalRuleIds] + ); + + // 6. 배치 INSERT로 채번 규칙 파트 복사 + if (allPartsResult.rows.length > 0) { + // 원본 rule_id -> 새 rule_id 매핑 + const ruleMapping = new Map( + originalToNewRuleMap.map((m) => [m.original, m.new]) + ); + + const partValues = allPartsResult.rows + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())` + ) + .join(", "); + + const partParams = allPartsResult.rows.flatMap((p) => [ + ruleMapping.get(p.rule_id), + p.part_order, + p.part_type, + p.generation_method, + p.auto_config, + p.manual_config, + targetCompanyCode, + ]); + + await client.query( + `INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code, created_at + ) VALUES ${partValues}`, + partParams + ); + + logger.info(` ✅ 채번 규칙 파트 ${allPartsResult.rows.length}개 복사`); + } + } + + logger.info( + `✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}개` + ); + return { copiedCount, ruleIdMap }; + } + + /** + * 카테고리 매핑 + 값 복사 (최적화: 배치 조회) + */ + private async copyCategoryMappingsAndValues( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise { + let copiedCount = 0; + + if (menuObjids.length === 0) { + return copiedCount; + } + + // === 최적화: 배치 조회 === + // 1. 모든 원본 카테고리 매핑 한 번에 조회 + const allMappingsResult = await client.query( + `SELECT * FROM category_column_mapping WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + if (allMappingsResult.rows.length === 0) { + logger.info(` 📭 복사할 카테고리 매핑 없음`); + return copiedCount; + } + + // 2. 대상 회사에 이미 존재하는 매핑 한 번에 조회 + const existingMappingsResult = await client.query( + `SELECT mapping_id, table_name, logical_column_name + FROM category_column_mapping WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingMappingKeys = new Map( + existingMappingsResult.rows.map((m) => [ + `${m.table_name}|${m.logical_column_name}`, + m.mapping_id, + ]) + ); + + // 3. 복사할 매핑 필터링 및 기존 매핑 업데이트 대상 분류 + const mappingsToCopy: any[] = []; + const mappingsToUpdate: Array<{ mappingId: number; newMenuObjid: number }> = + []; + + for (const m of allMappingsResult.rows) { + const key = `${m.table_name}|${m.logical_column_name}`; + if (existingMappingKeys.has(key)) { + // 기존 매핑은 menu_objid만 업데이트 + const existingMappingId = existingMappingKeys.get(key); + const newMenuObjid = menuIdMap.get(m.menu_objid); + if (existingMappingId && newMenuObjid) { + mappingsToUpdate.push({ mappingId: existingMappingId, newMenuObjid }); + } + } else { + mappingsToCopy.push(m); + } + } + + // 새 매핑 ID -> 원본 매핑 정보 추적 + const mappingInsertInfo: Array<{ mapping: any; newMenuObjid: number }> = []; + + if (mappingsToCopy.length > 0) { + const mappingValues = mappingsToCopy + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), $${i * 7 + 7})` + ) + .join(", "); + + const mappingParams = mappingsToCopy.flatMap((m) => { + const newMenuObjid = menuIdMap.get(m.menu_objid) || 0; + mappingInsertInfo.push({ mapping: m, newMenuObjid }); + return [ + m.table_name, + m.logical_column_name, + m.physical_column_name, + newMenuObjid, + targetCompanyCode, + m.description, + userId, + ]; + }); + + const insertResult = await client.query( + `INSERT INTO category_column_mapping ( + table_name, logical_column_name, physical_column_name, + menu_objid, company_code, description, created_at, created_by + ) VALUES ${mappingValues} + RETURNING mapping_id`, + mappingParams + ); + + // 새로 생성된 매핑 ID를 기존 매핑 맵에 추가 + insertResult.rows.forEach((row, index) => { + const m = mappingsToCopy[index]; + existingMappingKeys.set( + `${m.table_name}|${m.logical_column_name}`, + row.mapping_id + ); + }); + + copiedCount = mappingsToCopy.length; + logger.info(` ✅ 카테고리 매핑 ${copiedCount}개 복사`); + } + + // 3-1. 기존 카테고리 매핑의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리 + if (mappingsToUpdate.length > 0) { + // CASE WHEN을 사용한 배치 업데이트 + const caseWhen = mappingsToUpdate + .map((_, i) => `WHEN mapping_id = $${i * 2 + 1} THEN $${i * 2 + 2}`) + .join(" "); + const mappingIdsForUpdate = mappingsToUpdate.map((m) => m.mappingId); + const params = mappingsToUpdate.flatMap((m) => [ + m.mappingId, + m.newMenuObjid, + ]); + + await client.query( + `UPDATE category_column_mapping + SET menu_objid = CASE ${caseWhen} END + WHERE mapping_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`, + [...params, mappingIdsForUpdate, targetCompanyCode] + ); + logger.info( + ` ✅ 기존 카테고리 매핑 ${mappingsToUpdate.length}개 메뉴 연결 갱신` + ); + } + + // 4. 모든 원본 카테고리 값 한 번에 조회 + const allValuesResult = await client.query( + `SELECT * FROM table_column_category_values + WHERE menu_objid = ANY($1) + ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`, + [menuObjids] + ); + + if (allValuesResult.rows.length === 0) { + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); + return copiedCount; + } + + // 5. 대상 회사에 이미 존재하는 값 한 번에 조회 + const existingValuesResult = await client.query( + `SELECT value_id, table_name, column_name, value_code + FROM table_column_category_values WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingValueKeys = new Map( + existingValuesResult.rows.map((v) => [ + `${v.table_name}|${v.column_name}|${v.value_code}`, + v.value_id, + ]) + ); + + // 6. 값 복사 (부모-자식 관계 유지를 위해 depth 순서로 처리) + const valueIdMap = new Map(); + let copiedValues = 0; + + // 이미 존재하는 값들의 ID 매핑 + for (const value of allValuesResult.rows) { + const key = `${value.table_name}|${value.column_name}|${value.value_code}`; + const existingId = existingValueKeys.get(key); + if (existingId) { + valueIdMap.set(value.value_id, existingId); + } + } + + // depth별로 그룹핑하여 배치 처리 (부모가 먼저 삽입되어야 함) + const valuesByDepth = new Map(); + for (const value of allValuesResult.rows) { + const key = `${value.table_name}|${value.column_name}|${value.value_code}`; + if (existingValueKeys.has(key)) continue; // 이미 존재하면 스킵 + + const depth = value.depth ?? 0; + if (!valuesByDepth.has(depth)) { + valuesByDepth.set(depth, []); + } + valuesByDepth.get(depth)!.push(value); + } + + // depth 순서대로 처리 + const sortedDepths = Array.from(valuesByDepth.keys()).sort((a, b) => a - b); + + for (const depth of sortedDepths) { + const values = valuesByDepth.get(depth)!; + if (values.length === 0) continue; + + const valueStrings = values + .map( + (_, i) => + `($${i * 15 + 1}, $${i * 15 + 2}, $${i * 15 + 3}, $${i * 15 + 4}, $${i * 15 + 5}, $${i * 15 + 6}, $${i * 15 + 7}, $${i * 15 + 8}, $${i * 15 + 9}, $${i * 15 + 10}, $${i * 15 + 11}, $${i * 15 + 12}, NOW(), $${i * 15 + 13}, $${i * 15 + 14}, $${i * 15 + 15})` + ) + .join(", "); + + const valueParams = values.flatMap((v) => { + const newMenuObjid = menuIdMap.get(v.menu_objid); + const newParentId = v.parent_value_id + ? valueIdMap.get(v.parent_value_id) || null + : null; + return [ + v.table_name, + v.column_name, + v.value_code, + v.value_label, + v.value_order, + newParentId, + v.depth, + v.description, + v.color, + v.icon, + v.is_active, + v.is_default, + userId, + targetCompanyCode, + newMenuObjid, + ]; + }); + + const insertResult = await client.query( + `INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, value_order, + parent_value_id, depth, description, color, icon, + is_active, is_default, created_at, created_by, company_code, menu_objid + ) VALUES ${valueStrings} + RETURNING value_id`, + valueParams + ); + + // 새 value_id 매핑 + insertResult.rows.forEach((row, index) => { + valueIdMap.set(values[index].value_id, row.value_id); + }); + + copiedValues += values.length; + } + + if (copiedValues > 0) { + logger.info(` ✅ 카테고리 값 ${copiedValues}개 복사`); + } + + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); + return copiedCount; + } + + /** + * 테이블 타입관리 입력타입 설정 복사 (최적화: 배치 조회/삽입) + * - 복사된 화면에서 사용하는 테이블들의 table_type_columns 설정을 대상 회사로 복사 + */ + private async copyTableTypeColumns( + screenIds: number[], + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise { + if (screenIds.length === 0) { + return 0; + } + + logger.info(`📋 테이블 타입 설정 복사 시작`); + + // === 최적화: 배치 조회 === + // 1. 복사된 화면에서 사용하는 테이블 목록 조회 + const tablesResult = await client.query<{ table_name: string }>( + `SELECT DISTINCT table_name FROM screen_definitions + WHERE screen_id = ANY($1) AND table_name IS NOT NULL AND table_name != ''`, + [screenIds] + ); + + if (tablesResult.rows.length === 0) { + logger.info(" ⚠️ 복사된 화면에 테이블이 없음"); + return 0; + } + + const tableNames = tablesResult.rows.map((r) => r.table_name); + logger.info(` 사용 테이블: ${tableNames.join(", ")}`); + + // 2. 원본 회사의 모든 테이블 타입 설정 한 번에 조회 + const sourceSettingsResult = await client.query( + `SELECT * FROM table_type_columns + WHERE table_name = ANY($1) AND company_code = $2`, + [tableNames, sourceCompanyCode] + ); + + if (sourceSettingsResult.rows.length === 0) { + logger.info(` ⚠️ 원본 회사 설정 없음`); + return 0; + } + + // 3. 대상 회사의 기존 설정 한 번에 조회 + const existingSettingsResult = await client.query( + `SELECT table_name, column_name FROM table_type_columns + WHERE table_name = ANY($1) AND company_code = $2`, + [tableNames, targetCompanyCode] + ); + const existingKeys = new Set( + existingSettingsResult.rows.map((s) => `${s.table_name}|${s.column_name}`) + ); + + // 4. 복사할 설정 필터링 + const settingsToCopy = sourceSettingsResult.rows.filter( + (s) => !existingKeys.has(`${s.table_name}|${s.column_name}`) + ); + + logger.info( + ` 원본 설정: ${sourceSettingsResult.rows.length}개, 복사 대상: ${settingsToCopy.length}개` + ); + + // 5. 배치 INSERT + if (settingsToCopy.length > 0) { + const settingValues = settingsToCopy + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), NOW(), $${i * 7 + 7})` + ) + .join(", "); + + const settingParams = settingsToCopy.flatMap((s) => [ + s.table_name, + s.column_name, + s.input_type, + s.detail_settings, + s.is_nullable, + s.display_order, + targetCompanyCode, + ]); + + await client.query( + `INSERT INTO table_type_columns ( + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date, company_code + ) VALUES ${settingValues}`, + settingParams + ); + } + + logger.info(`✅ 테이블 타입 설정 복사 완료: ${settingsToCopy.length}개`); + return settingsToCopy.length; + } + + /** + * 연쇄관계 복사 (최적화: 배치 조회/삽입) + * - category_value_cascading_group + category_value_cascading_mapping + * - cascading_relation (테이블 기반) + */ + private async copyCascadingRelations( + sourceCompanyCode: string, + targetCompanyCode: string, + menuIdMap: Map, + userId: string, + client: PoolClient + ): Promise { + logger.info(`📋 연쇄관계 복사 시작`); + let copiedCount = 0; + + // === 1. category_value_cascading_group 복사 === + const groupsResult = await client.query( + `SELECT * FROM category_value_cascading_group + WHERE company_code = $1 AND is_active = 'Y'`, + [sourceCompanyCode] + ); + + if (groupsResult.rows.length === 0) { + logger.info(` 카테고리 값 연쇄 그룹: 0개`); + } else { + logger.info(` 카테고리 값 연쇄 그룹: ${groupsResult.rows.length}개`); + + // 대상 회사의 기존 그룹 한 번에 조회 + const existingGroupsResult = await client.query( + `SELECT group_id, relation_code FROM category_value_cascading_group + WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingGroupsByCode = new Map( + existingGroupsResult.rows.map((g) => [g.relation_code, g.group_id]) + ); + + // group_id 매핑 + const groupIdMap = new Map(); + const groupsToCopy: any[] = []; + + for (const group of groupsResult.rows) { + const existingGroupId = existingGroupsByCode.get(group.relation_code); + if (existingGroupId) { + groupIdMap.set(group.group_id, existingGroupId); + } else { + groupsToCopy.push(group); + } + } + + logger.info( + ` 기존: ${groupsResult.rows.length - groupsToCopy.length}개, 신규: ${groupsToCopy.length}개` + ); + + // 그룹별로 삽입하고 매핑 저장 (RETURNING이 필요해서 배치 불가) + for (const group of groupsToCopy) { + const newParentMenuObjid = group.parent_menu_objid + ? menuIdMap.get(Number(group.parent_menu_objid)) || null + : null; + const newChildMenuObjid = group.child_menu_objid + ? menuIdMap.get(Number(group.child_menu_objid)) || null + : null; + + const insertResult = await client.query( + `INSERT INTO category_value_cascading_group ( + relation_code, relation_name, description, + parent_table_name, parent_column_name, parent_menu_objid, + child_table_name, child_column_name, child_menu_objid, + clear_on_parent_change, show_group_label, + empty_parent_message, no_options_message, + company_code, is_active, created_by, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW()) + RETURNING group_id`, + [ + group.relation_code, + group.relation_name, + group.description, + group.parent_table_name, + group.parent_column_name, + newParentMenuObjid, + group.child_table_name, + group.child_column_name, + newChildMenuObjid, + group.clear_on_parent_change, + group.show_group_label, + group.empty_parent_message, + group.no_options_message, + targetCompanyCode, + "Y", + userId, + ] + ); + + const newGroupId = insertResult.rows[0].group_id; + groupIdMap.set(group.group_id, newGroupId); + copiedCount++; + } + + // 모든 매핑 한 번에 조회 (복사할 그룹만) + const groupIdsToCopy = groupsToCopy.map((g) => g.group_id); + if (groupIdsToCopy.length > 0) { + const allMappingsResult = await client.query( + `SELECT * FROM category_value_cascading_mapping + WHERE group_id = ANY($1) AND company_code = $2 + ORDER BY group_id, display_order`, + [groupIdsToCopy, sourceCompanyCode] + ); + + // 배치 INSERT + if (allMappingsResult.rows.length > 0) { + const mappingValues = allMappingsResult.rows + .map( + (_, i) => + `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8}, NOW())` + ) + .join(", "); + + const mappingParams = allMappingsResult.rows.flatMap((m) => { + const newGroupId = groupIdMap.get(m.group_id); + return [ + newGroupId, + m.parent_value_code, + m.parent_value_label, + m.child_value_code, + m.child_value_label, + m.display_order, + targetCompanyCode, + "Y", + ]; + }); + + await client.query( + `INSERT INTO category_value_cascading_mapping ( + group_id, parent_value_code, parent_value_label, + child_value_code, child_value_label, display_order, + company_code, is_active, created_date + ) VALUES ${mappingValues}`, + mappingParams + ); + + logger.info(` ↳ 매핑 ${allMappingsResult.rows.length}개 복사`); + } + } + } + + // === 2. cascading_relation 복사 (테이블 기반) === + const relationsResult = await client.query( + `SELECT * FROM cascading_relation + WHERE company_code = $1 AND is_active = 'Y'`, + [sourceCompanyCode] + ); + + if (relationsResult.rows.length === 0) { + logger.info(` 기본 연쇄관계: 0개`); + } else { + logger.info(` 기본 연쇄관계: ${relationsResult.rows.length}개`); + + // 대상 회사의 기존 관계 한 번에 조회 + const existingRelationsResult = await client.query( + `SELECT relation_code FROM cascading_relation + WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingRelationCodes = new Set( + existingRelationsResult.rows.map((r) => r.relation_code) + ); + + // 복사할 관계 필터링 + const relationsToCopy = relationsResult.rows.filter( + (r) => !existingRelationCodes.has(r.relation_code) + ); + + logger.info( + ` 기존: ${relationsResult.rows.length - relationsToCopy.length}개, 신규: ${relationsToCopy.length}개` + ); + + // 배치 INSERT + if (relationsToCopy.length > 0) { + const relationValues = relationsToCopy + .map( + (_, i) => + `($${i * 19 + 1}, $${i * 19 + 2}, $${i * 19 + 3}, $${i * 19 + 4}, $${i * 19 + 5}, $${i * 19 + 6}, $${i * 19 + 7}, $${i * 19 + 8}, $${i * 19 + 9}, $${i * 19 + 10}, $${i * 19 + 11}, $${i * 19 + 12}, $${i * 19 + 13}, $${i * 19 + 14}, $${i * 19 + 15}, $${i * 19 + 16}, $${i * 19 + 17}, $${i * 19 + 18}, $${i * 19 + 19}, NOW())` + ) + .join(", "); + + const relationParams = relationsToCopy.flatMap((r) => [ + r.relation_code, + r.relation_name, + r.description, + r.parent_table, + r.parent_value_column, + r.parent_label_column, + r.child_table, + r.child_filter_column, + r.child_value_column, + r.child_label_column, + r.child_order_column, + r.child_order_direction, + r.empty_parent_message, + r.no_options_message, + r.loading_message, + r.clear_on_parent_change, + targetCompanyCode, + "Y", + userId, + ]); + + await client.query( + `INSERT INTO cascading_relation ( + relation_code, relation_name, description, + parent_table, parent_value_column, parent_label_column, + child_table, child_filter_column, child_value_column, child_label_column, + child_order_column, child_order_direction, + empty_parent_message, no_options_message, loading_message, + clear_on_parent_change, company_code, is_active, created_by, created_date + ) VALUES ${relationValues}`, + relationParams + ); + + copiedCount += relationsToCopy.length; + } + } + + logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}개`); + return copiedCount; + } } diff --git a/backend-node/src/services/reportService.ts b/backend-node/src/services/reportService.ts index 77087f25..f4991863 100644 --- a/backend-node/src/services/reportService.ts +++ b/backend-node/src/services/reportService.ts @@ -477,6 +477,12 @@ export class ReportService { ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) `; + // components가 이미 문자열이면 그대로, 객체면 JSON.stringify + const componentsData = + typeof originalLayout.components === "string" + ? originalLayout.components + : JSON.stringify(originalLayout.components); + await client.query(copyLayoutQuery, [ newLayoutId, newReportId, @@ -487,7 +493,7 @@ export class ReportService { originalLayout.margin_bottom, originalLayout.margin_left, originalLayout.margin_right, - JSON.stringify(originalLayout.components), + componentsData, userId, ]); } @@ -561,7 +567,7 @@ export class ReportService { } /** - * 레이아웃 저장 (쿼리 포함) + * 레이아웃 저장 (쿼리 포함) - 페이지 기반 구조 */ async saveLayout( reportId: string, @@ -569,6 +575,19 @@ export class ReportService { userId: string ): Promise { return transaction(async (client) => { + // 첫 번째 페이지 정보를 기본 레이아웃으로 사용 + const firstPage = data.layoutConfig.pages[0]; + const canvasWidth = firstPage?.width || 210; + const canvasHeight = firstPage?.height || 297; + const pageOrientation = + canvasWidth > canvasHeight ? "landscape" : "portrait"; + const margins = firstPage?.margins || { + top: 20, + bottom: 20, + left: 20, + right: 20, + }; + // 1. 레이아웃 저장 const existingQuery = ` SELECT layout_id FROM report_layout WHERE report_id = $1 @@ -576,7 +595,7 @@ export class ReportService { const existing = await client.query(existingQuery, [reportId]); if (existing.rows.length > 0) { - // 업데이트 + // 업데이트 - components 컬럼에 전체 layoutConfig 저장 const updateQuery = ` UPDATE report_layout SET @@ -594,14 +613,14 @@ export class ReportService { `; await client.query(updateQuery, [ - data.canvasWidth, - data.canvasHeight, - data.pageOrientation, - data.marginTop, - data.marginBottom, - data.marginLeft, - data.marginRight, - JSON.stringify(data.components), + canvasWidth, + canvasHeight, + pageOrientation, + margins.top, + margins.bottom, + margins.left, + margins.right, + JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장 userId, reportId, ]); @@ -627,14 +646,14 @@ export class ReportService { await client.query(insertQuery, [ layoutId, reportId, - data.canvasWidth, - data.canvasHeight, - data.pageOrientation, - data.marginTop, - data.marginBottom, - data.marginLeft, - data.marginRight, - JSON.stringify(data.components), + canvasWidth, + canvasHeight, + pageOrientation, + margins.top, + margins.bottom, + margins.left, + margins.right, + JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장 userId, ]); } diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts index 77cc35d7..2fe1cfd3 100644 --- a/backend-node/src/types/report.ts +++ b/backend-node/src/types/report.ts @@ -116,22 +116,38 @@ export interface UpdateReportRequest { useYn?: string; } +// 페이지 설정 +export interface PageConfig { + page_id: string; + page_name: string; + page_order: number; + width: number; + height: number; + background_color: string; + margins: { + top: number; + bottom: number; + left: number; + right: number; + }; + components: any[]; +} + +// 레이아웃 설정 +export interface ReportLayoutConfig { + pages: PageConfig[]; +} + // 레이아웃 저장 요청 export interface SaveLayoutRequest { - canvasWidth: number; - canvasHeight: number; - pageOrientation: string; - marginTop: number; - marginBottom: number; - marginLeft: number; - marginRight: number; - components: any[]; + layoutConfig: ReportLayoutConfig; queries?: Array<{ id: string; name: string; type: "MASTER" | "DETAIL"; sqlQuery: string; parameters: string[]; + externalConnectionId?: number; }>; } diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index a181ac21..c2c44be0 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -584,3 +584,5 @@ const result = await executeNodeFlow(flowId, { + + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 916fbc54..4ffb7655 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -357,3 +357,5 @@ + + diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md index 6ce86286..1de42fb2 100644 --- a/docs/즉시저장_버튼_액션_구현_계획서.md +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -343,3 +343,5 @@ const getComponentValue = (componentId: string) => { 3. **조건부 저장**: 특정 조건 만족 시에만 저장 4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장 + + diff --git a/frontend/app/(main)/admin/cascading-management/page.tsx b/frontend/app/(main)/admin/cascading-management/page.tsx index 70382dd9..5b5f6b37 100644 --- a/frontend/app/(main)/admin/cascading-management/page.tsx +++ b/frontend/app/(main)/admin/cascading-management/page.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react"; +import { Link2, Layers, Filter, FormInput, Ban, Tags } from "lucide-react"; // 탭별 컴포넌트 import CascadingRelationsTab from "./tabs/CascadingRelationsTab"; @@ -11,6 +11,7 @@ import AutoFillTab from "./tabs/AutoFillTab"; import HierarchyTab from "./tabs/HierarchyTab"; import ConditionTab from "./tabs/ConditionTab"; import MutualExclusionTab from "./tabs/MutualExclusionTab"; +import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab"; export default function CascadingManagementPage() { const searchParams = useSearchParams(); @@ -20,7 +21,7 @@ export default function CascadingManagementPage() { // URL 쿼리 파라미터에서 탭 설정 useEffect(() => { const tab = searchParams.get("tab"); - if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) { + if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value"].includes(tab)) { setActiveTab(tab); } }, [searchParams]); @@ -46,7 +47,7 @@ export default function CascadingManagementPage() { {/* 탭 네비게이션 */} - + 2단계 연쇄관계 @@ -72,6 +73,11 @@ export default function CascadingManagementPage() { 상호 배제 배제 + + + 카테고리값 + 카테고리 + {/* 탭 컨텐츠 */} @@ -95,6 +101,10 @@ export default function CascadingManagementPage() { + + + + diff --git a/frontend/app/(main)/admin/cascading-management/tabs/CategoryValueCascadingTab.tsx b/frontend/app/(main)/admin/cascading-management/tabs/CategoryValueCascadingTab.tsx new file mode 100644 index 00000000..ccb439e1 --- /dev/null +++ b/frontend/app/(main)/admin/cascading-management/tabs/CategoryValueCascadingTab.tsx @@ -0,0 +1,1009 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Plus, Pencil, Trash2, Search, Save, RefreshCw, Check, ChevronsUpDown, X } from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { + categoryValueCascadingApi, + CategoryValueCascadingGroup, + CategoryValueCascadingGroupInput, + CategoryValueCascadingMappingInput, +} from "@/lib/api/categoryValueCascading"; +import { tableManagementApi } from "@/lib/api/tableManagement"; + +interface TableInfo { + tableName: string; + displayName: string; + description?: string; + columnCount?: number; +} + +interface ColumnInfo { + columnName: string; + displayName: string; + dataType?: string; + inputType?: string; + input_type?: string; +} + +interface CategoryValue { + value: string; + label: string; +} + +export default function CategoryValueCascadingTab() { + // 상태 + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 테이블/컬럼 목록 + const [tables, setTables] = useState([]); + const [parentColumns, setParentColumns] = useState([]); + const [childColumns, setChildColumns] = useState([]); + + // Combobox 상태 + const [parentTableOpen, setParentTableOpen] = useState(false); + const [childTableOpen, setChildTableOpen] = useState(false); + + // 모달 상태 + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isMappingModalOpen, setIsMappingModalOpen] = useState(false); + const [selectedGroup, setSelectedGroup] = useState(null); + + // 폼 상태 + const [formData, setFormData] = useState({ + relationCode: "", + relationName: "", + description: "", + parentTableName: "", + parentColumnName: "", + childTableName: "", + childColumnName: "", + clearOnParentChange: true, + showGroupLabel: true, + emptyParentMessage: "상위 항목을 먼저 선택하세요", + noOptionsMessage: "선택 가능한 항목이 없습니다", + }); + + // 매핑 상태 + const [parentValues, setParentValues] = useState([]); + const [childValues, setChildValues] = useState([]); + const [mappings, setMappings] = useState>>({}); + const [savingMappings, setSavingMappings] = useState(false); + + // 직접 입력 매핑 상태 (각 부모값에 대한 하위 옵션 목록) + const [childOptionsMap, setChildOptionsMap] = useState>({}); + const [newOptionInputs, setNewOptionInputs] = useState>({}); + + // 검색 + const [searchText, setSearchText] = useState(""); + + // 그룹 목록 로드 + const loadGroups = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await categoryValueCascadingApi.getGroups("Y"); + if (response.success && response.data) { + setGroups(response.data); + } else { + setError(response.error || "그룹 목록 로드 실패"); + } + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + // 테이블 목록 로드 + const loadTables = useCallback(async () => { + try { + console.log("📦 테이블 목록 로드 시작"); + const response = await tableManagementApi.getTableList(); + console.log("📦 테이블 목록 응답:", response); + if (response.success && response.data) { + console.log("✅ 테이블 목록 로드 성공:", response.data.length, "개"); + setTables(response.data); + } else { + console.error("❌ 테이블 목록 로드 실패:", response); + } + } catch (err: any) { + console.error("테이블 목록 로드 실패:", err); + } + }, []); + + // 컬럼 목록 로드 + const loadColumns = useCallback(async (tableName: string, target: "parent" | "child") => { + if (!tableName) { + if (target === "parent") setParentColumns([]); + else setChildColumns([]); + return; + } + + try { + const response = await tableManagementApi.getColumnList(tableName); + if (response.success && response.data) { + // API 응답 형식: { columns: [...], total, page, ... } + const columns = response.data.columns || response.data; + const columnsArray = Array.isArray(columns) ? columns : []; + + // category 타입 컬럼만 필터링 + const categoryColumns = columnsArray.filter( + (col: any) => col.input_type === "category" || col.inputType === "category" + ); + + // 인터페이스에 맞게 변환 + const mappedColumns: ColumnInfo[] = categoryColumns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + displayName: col.displayName || col.column_label || col.columnName || col.column_name, + dataType: col.dataType || col.data_type, + inputType: col.inputType || col.input_type, + })); + + if (target === "parent") setParentColumns(mappedColumns); + else setChildColumns(mappedColumns); + } + } catch (err: any) { + console.error("컬럼 목록 로드 실패:", err); + } + }, []); + + // 초기 로드 + useEffect(() => { + loadGroups(); + loadTables(); + }, [loadGroups, loadTables]); + + // 필터링된 그룹 + const filteredGroups = groups.filter((group) => { + if (!searchText) return true; + const lowerSearch = searchText.toLowerCase(); + return ( + group.relation_code.toLowerCase().includes(lowerSearch) || + group.relation_name.toLowerCase().includes(lowerSearch) || + group.parent_table_name.toLowerCase().includes(lowerSearch) || + group.child_table_name.toLowerCase().includes(lowerSearch) + ); + }); + + // 폼 초기화 + const resetForm = () => { + // 자동 관계코드 생성 + const autoCode = `CVC_${Date.now()}`; + setFormData({ + relationCode: autoCode, + relationName: "", + description: "", + parentTableName: "", + parentColumnName: "", + childTableName: "", + childColumnName: "", + clearOnParentChange: true, + showGroupLabel: true, + emptyParentMessage: "상위 항목을 먼저 선택하세요", + noOptionsMessage: "선택 가능한 항목이 없습니다", + }); + setParentColumns([]); + setChildColumns([]); + }; + + // 생성 모달 열기 + const openCreateModal = async () => { + resetForm(); + // 테이블 목록이 없으면 다시 로드 + if (tables.length === 0) { + console.log("📦 테이블 목록이 비어있어서 다시 로드"); + await loadTables(); + } + setIsCreateModalOpen(true); + }; + + // 수정 모달 열기 + const openEditModal = (group: CategoryValueCascadingGroup) => { + setSelectedGroup(group); + setFormData({ + relationCode: group.relation_code, + relationName: group.relation_name, + description: group.description || "", + parentTableName: group.parent_table_name, + parentColumnName: group.parent_column_name, + childTableName: group.child_table_name, + childColumnName: group.child_column_name, + clearOnParentChange: group.clear_on_parent_change === "Y", + showGroupLabel: group.show_group_label === "Y", + emptyParentMessage: group.empty_parent_message || "", + noOptionsMessage: group.no_options_message || "", + }); + loadColumns(group.parent_table_name, "parent"); + loadColumns(group.child_table_name, "child"); + setIsEditModalOpen(true); + }; + + // 매핑 모달 열기 + const openMappingModal = async (group: CategoryValueCascadingGroup) => { + setSelectedGroup(group); + setMappings({}); + setParentValues([]); + setChildValues([]); + setChildOptionsMap({}); + setNewOptionInputs({}); + + try { + // 부모 카테고리 값과 기존 매핑 로드 + const [parentResponse, groupDetailResponse] = await Promise.all([ + categoryValueCascadingApi.getParentOptions(group.relation_code), + categoryValueCascadingApi.getGroupById(group.group_id), + ]); + + if (parentResponse.success && parentResponse.data) { + setParentValues(parentResponse.data); + + // 부모 값별 입력창 초기화 + const inputs: Record = {}; + for (const pv of parentResponse.data) { + inputs[pv.value] = ""; + } + setNewOptionInputs(inputs); + } + + // 기존 매핑을 직접 입력 형태로 변환 + if (groupDetailResponse.success && groupDetailResponse.data?.mappings) { + const optionsMap: Record = {}; + + for (const mapping of groupDetailResponse.data.mappings) { + const parentCode = mapping.parent_value_code; + if (!optionsMap[parentCode]) { + optionsMap[parentCode] = []; + } + // 중복 체크 + if (!optionsMap[parentCode].some(opt => opt.code === mapping.child_value_code)) { + optionsMap[parentCode].push({ + code: mapping.child_value_code, + label: mapping.child_value_label || mapping.child_value_code, + }); + } + } + setChildOptionsMap(optionsMap); + } + + setIsMappingModalOpen(true); + } catch (err: any) { + console.error("매핑 데이터 로드 실패:", err); + setError("매핑 데이터 로드 실패"); + } + }; + + // 고유 코드 생성 함수 + const generateUniqueCode = () => { + const timestamp = Date.now().toString(36).toUpperCase(); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `TARGET_${timestamp}${random}`; + }; + + // 하위 옵션 추가 + const addChildOption = (parentValue: string) => { + const inputValue = newOptionInputs[parentValue]?.trim(); + if (!inputValue) return; + + // 자동 고유 코드 생성 + const code = generateUniqueCode(); + + setChildOptionsMap((prev) => { + const currentOptions = prev[parentValue] || []; + // 중복 체크 (라벨만 체크 - 코드는 항상 고유) + if (currentOptions.some((opt) => opt.label === inputValue)) { + return prev; + } + return { + ...prev, + [parentValue]: [...currentOptions, { code, label: inputValue }], + }; + }); + + // 입력창 초기화 + setNewOptionInputs((prev) => ({ ...prev, [parentValue]: "" })); + }; + + // 하위 옵션 삭제 + const removeChildOption = (parentValue: string, optionCode: string) => { + setChildOptionsMap((prev) => ({ + ...prev, + [parentValue]: (prev[parentValue] || []).filter((opt) => opt.code !== optionCode), + })); + }; + + // 그룹 생성 + const handleCreate = async () => { + try { + const response = await categoryValueCascadingApi.createGroup(formData); + if (response.success) { + setIsCreateModalOpen(false); + loadGroups(); + } else { + setError(response.error || "생성 실패"); + } + } catch (err: any) { + setError(err.message); + } + }; + + // 그룹 수정 + const handleUpdate = async () => { + if (!selectedGroup) return; + + try { + const response = await categoryValueCascadingApi.updateGroup(selectedGroup.group_id, formData); + if (response.success) { + setIsEditModalOpen(false); + loadGroups(); + } else { + setError(response.error || "수정 실패"); + } + } catch (err: any) { + setError(err.message); + } + }; + + // 그룹 삭제 + const handleDelete = async (group: CategoryValueCascadingGroup) => { + if (!confirm(`"${group.relation_name}" 연쇄관계를 삭제하시겠습니까?`)) return; + + try { + const response = await categoryValueCascadingApi.deleteGroup(group.group_id); + if (response.success) { + loadGroups(); + } else { + setError(response.error || "삭제 실패"); + } + } catch (err: any) { + setError(err.message); + } + }; + + // 매핑 토글 + const toggleMapping = (parentCode: string, childCode: string) => { + setMappings((prev) => { + const newMappings = { ...prev }; + if (!newMappings[parentCode]) { + newMappings[parentCode] = new Set(); + } + + const newSet = new Set(newMappings[parentCode]); + if (newSet.has(childCode)) { + newSet.delete(childCode); + } else { + newSet.add(childCode); + } + newMappings[parentCode] = newSet; + + return newMappings; + }); + }; + + // 매핑 저장 + const handleSaveMappings = async () => { + if (!selectedGroup) return; + + setSavingMappings(true); + try { + // 직접 입력된 옵션으로 매핑 데이터 생성 + const mappingInputs: CategoryValueCascadingMappingInput[] = []; + let displayOrder = 0; + + for (const parentCode of Object.keys(childOptionsMap)) { + const parentValue = parentValues.find((p) => p.value === parentCode); + const childOptions = childOptionsMap[parentCode] || []; + + for (const childOption of childOptions) { + mappingInputs.push({ + parentValueCode: parentCode, + parentValueLabel: parentValue?.label, + childValueCode: childOption.code, + childValueLabel: childOption.label, + displayOrder: displayOrder++, + }); + } + } + + const response = await categoryValueCascadingApi.saveMappings( + selectedGroup.group_id, + mappingInputs + ); + + if (response.success) { + setIsMappingModalOpen(false); + } else { + setError(response.error || "매핑 저장 실패"); + } + } catch (err: any) { + setError(err.message); + } finally { + setSavingMappings(false); + } + }; + + // 활성/비활성 토글 + const toggleActive = async (group: CategoryValueCascadingGroup) => { + try { + const newActive = group.is_active !== "Y"; + const response = await categoryValueCascadingApi.updateGroup(group.group_id, { + isActive: newActive, + }); + if (response.success) { + loadGroups(); + } + } catch (err: any) { + setError(err.message); + } + }; + + return ( +
+ {/* 설명 */} +
+

카테고리 값 연쇄관계

+

+ 카테고리 값들 간의 부모-자식 연쇄관계를 정의합니다. 예: 검사유형 선택 시 해당 유형에 맞는 적용대상만 표시 +

+
+ + {/* 에러 메시지 */} + {error && ( +
+
+

오류

+ +
+

{error}

+
+ )} + + {/* 검색 및 액션 */} +
+
+
+ + setSearchText(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+
+ +
+
+ 총 {filteredGroups.length} 건 +
+ + +
+
+ + {/* 테이블 */} +
+ + + + 관계코드 + 관계명 + 부모 (테이블.컬럼) + 자식 (테이블.컬럼) + 사용 + 관리 + + + + {loading ? ( + Array.from({ length: 5 }).map((_, idx) => ( + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + )) + ) : filteredGroups.length === 0 ? ( + + + 등록된 카테고리 값 연쇄관계가 없습니다. + + + ) : ( + filteredGroups.map((group) => ( + + {group.relation_code} + {group.relation_name} + + {group.parent_table_name}. + {group.parent_column_name} + + + {group.child_table_name}. + {group.child_column_name} + + + toggleActive(group)} + aria-label="활성화 토글" + /> + + +
+ + + +
+
+
+ )) + )} + +
+
+ + {/* 생성 모달 */} + + + + 카테고리 값 연쇄관계 등록 + + 카테고리 값들 간의 부모-자식 연쇄관계를 정의합니다. + + + +
+ {/* 기본 정보 */} +
+ + setFormData({ ...formData, relationName: e.target.value })} + placeholder="예: 검사유형-적용대상" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="연쇄관계 설명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + {/* 부모 설정 */} +
+

부모 카테고리 설정

+
+
+ + + + + + + + + + + {tables.length === 0 ? "테이블을 불러오는 중..." : "테이블을 찾을 수 없습니다."} + + + {tables.map((table) => ( + { + setFormData({ ...formData, parentTableName: table.tableName, parentColumnName: "", childTableName: table.tableName }); + loadColumns(table.tableName, "parent"); + setParentTableOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {table.displayName || table.tableName} + {table.displayName && table.displayName !== table.tableName && ( + {table.tableName} + )} +
+
+ ))} +
+
+
+
+
+
+
+ + +
+
+
+ + {/* 자식 옵션 라벨 설정 */} +
+

하위 옵션 설정

+

+ 부모 카테고리 값별로 표시할 하위 옵션들의 그룹명을 입력합니다. +
+ 실제 하위 옵션은 등록 후 "값 매핑" 버튼에서 직접 입력합니다. +

+
+ + setFormData({ ...formData, childColumnName: e.target.value, childTableName: formData.parentTableName })} + placeholder="예: 적용대상" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 옵션 */} +
+ + +
+
+ + + + + +
+
+ + {/* 수정 모달 */} + + + + 카테고리 값 연쇄관계 수정 + + 연쇄관계 설정을 수정합니다. + + + +
+ {/* 기본 정보 */} +
+
+ + +
+
+ + setFormData({ ...formData, relationName: e.target.value })} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+ + setFormData({ ...formData, description: e.target.value })} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + {/* 부모/자식 설정 - 수정 불가 표시 */} +
+

부모/자식 설정 (수정 불가)

+
+
+ 부모: + {formData.parentTableName}.{formData.parentColumnName} +
+
+ 자식: + {formData.childTableName}.{formData.childColumnName} +
+
+
+ + {/* 옵션 */} +
+ + +
+
+ + + + + +
+
+ + {/* 값 매핑 모달 */} + + + + + 하위 옵션 설정 - {selectedGroup?.relation_name} + + + 각 부모 카테고리 값에 대해 하위 옵션을 직접 입력합니다. + + + +
+ {parentValues.length === 0 ? ( +
+ 부모 카테고리 값이 등록되지 않았습니다. +
+ 먼저 카테고리 관리에서 "{selectedGroup?.parent_column_name}" 컬럼의 값을 등록하세요. +
+ ) : ( +
+ {parentValues.map((parent) => ( +
+
+

{parent.label}

+ + {(childOptionsMap[parent.value] || []).length}개 옵션 + +
+ + {/* 하위 옵션 입력 */} +
+ + setNewOptionInputs((prev) => ({ ...prev, [parent.value]: e.target.value })) + } + onKeyDown={(e) => { + // 한글 IME 조합 중에는 무시 (마지막 글자 중복 방지) + if (e.nativeEvent.isComposing) return; + if (e.key === "Enter") { + e.preventDefault(); + addChildOption(parent.value); + } + }} + placeholder="하위 옵션 입력 후 Enter 또는 추가 버튼" + className="h-8 flex-1 text-xs sm:h-10 sm:text-sm" + /> + +
+ + {/* 등록된 하위 옵션 목록 */} +
+ {(childOptionsMap[parent.value] || []).map((option) => ( +
+ {option.label} + +
+ ))} + {(childOptionsMap[parent.value] || []).length === 0 && ( + 등록된 하위 옵션이 없습니다 + )} +
+
+ ))} +
+ )} +
+ + + + + +
+
+
+ ); +} + diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index cd0c462d..b554dff1 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -93,7 +93,7 @@ export default function TableManagementPage() { const [createTableModalOpen, setCreateTableModalOpen] = useState(false); const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false); - + // 테이블 복제 관련 상태 const [duplicateModalMode, setDuplicateModalMode] = useState<"create" | "duplicate">("create"); const [duplicateSourceTable, setDuplicateSourceTable] = useState(null); @@ -109,7 +109,7 @@ export default function TableManagementPage() { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableToDelete, setTableToDelete] = useState(""); const [isDeleting, setIsDeleting] = useState(false); - + // 선택된 테이블 목록 (체크박스) const [selectedTableIds, setSelectedTableIds] = useState>(new Set()); @@ -459,11 +459,39 @@ export default function TableManagementPage() { if (!selectedTable) return; try { + // 🎯 Entity 타입인 경우 detailSettings에 엔티티 설정을 JSON으로 포함 + let finalDetailSettings = column.detailSettings || ""; + + if (column.inputType === "entity" && column.referenceTable) { + // 기존 detailSettings를 파싱하거나 새로 생성 + let existingSettings: Record = {}; + if (typeof column.detailSettings === "string" && column.detailSettings.trim().startsWith("{")) { + try { + existingSettings = JSON.parse(column.detailSettings); + } catch { + existingSettings = {}; + } + } + + // 엔티티 설정 추가 + const entitySettings = { + ...existingSettings, + entityTable: column.referenceTable, + entityCodeColumn: column.referenceColumn || "id", + entityLabelColumn: column.displayColumn || "name", + placeholder: (existingSettings.placeholder as string) || "항목을 선택하세요", + searchable: existingSettings.searchable ?? true, + }; + + finalDetailSettings = JSON.stringify(entitySettings); + console.log("🔧 Entity 설정 JSON 생성:", entitySettings); + } + const columnSetting = { columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) columnLabel: column.displayName, // 사용자가 입력한 표시명 inputType: column.inputType || "text", - detailSettings: column.detailSettings || "", + detailSettings: finalDetailSettings, codeCategory: column.codeCategory || "", codeValue: column.codeValue || "", referenceTable: column.referenceTable || "", @@ -487,7 +515,7 @@ export default function TableManagementPage() { if (response.data.success) { console.log("✅ 컬럼 설정 저장 성공"); - + // 🆕 Category 타입인 경우 컬럼 매핑 처리 console.log("🔍 카테고리 조건 체크:", { isCategory: column.inputType === "category", @@ -547,7 +575,7 @@ export default function TableManagementPage() { } else if (successCount > 0 && failCount > 0) { toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`); } else if (failCount > 0) { - toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`); + toast.error("컬럼 설정 저장 성공. 메뉴 매핑 생성 실패."); } } else { toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)"); @@ -680,9 +708,7 @@ export default function TableManagementPage() { console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount }); if (totalSuccessCount > 0) { - toast.success( - `테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.` - ); + toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`); } else if (totalFailCount > 0) { toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`); } else { @@ -1000,14 +1026,15 @@ export default function TableManagementPage() { .filter( (table) => table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || - (table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), + (table.displayName && + table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), ) .every((table) => selectedTableIds.has(table.tableName)) } onCheckedChange={handleSelectAll} aria-label="전체 선택" /> - + {selectedTableIds.size > 0 && `${selectedTableIds.size}개 선택됨`} @@ -1047,9 +1074,9 @@ export default function TableManagementPage() {
e.stopPropagation()} /> )} -
handleTableSelect(table.tableName)} - > +
handleTableSelect(table.tableName)}>

{table.displayName || table.tableName}

{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")} @@ -1147,7 +1171,10 @@ export default function TableManagementPage() { ) : (

{/* 컬럼 헤더 (고정) */} -
+
컬럼명
라벨
입력 타입
@@ -1171,7 +1198,7 @@ export default function TableManagementPage() { className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }} > -
+
{column.columnName}
@@ -1226,9 +1253,9 @@ export default function TableManagementPage() { -
+
{secondLevelMenus.length === 0 ? ( -

+

2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.

) : ( @@ -1236,7 +1263,7 @@ export default function TableManagementPage() { // menuObjid를 숫자로 변환하여 비교 const menuObjidNum = Number(menu.menuObjid); const isChecked = (column.categoryMenus || []).includes(menuObjidNum); - + return (
col.columnName === column.columnName ? { ...col, categoryMenus: newMenus } - : col - ) + : col, + ), ); }} - className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring" + className="text-primary focus:ring-ring h-4 w-4 rounded border-gray-300 focus:ring-2" /> @@ -1282,9 +1309,7 @@ export default function TableManagementPage() { <> {/* 참조 테이블 */}
- + @@ -1361,9 +1379,7 @@ export default function TableManagementPage() { column.referenceColumn && column.referenceColumn !== "none" && (
- + setSearchQuery(e.target.value)} - placeholder="이름, Area, Location 검색..." - className="h-10 pl-9 text-sm" - /> - {searchQuery && ( - - )} + {/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */} + {!isExternalMode && ( +
+
+ {/* 검색 */} +
+ +
+ + setSearchQuery(e.target.value)} + placeholder="이름, Area, Location 검색..." + className="h-10 pl-9 text-sm" + /> + {searchQuery && ( + + )} +
+ + {/* 타입 필터 */} +
+ + +
+ + {/* 필터 초기화 */} + {(searchQuery || filterType !== "all") && ( + + )}
- {/* 타입 필터 */} -
- - -
+ {/* 객체 목록 */} +
+ + {filteredObjects.length === 0 ? ( +
+ {searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"} +
+ ) : ( + (() => { + // Area 객체가 있는 경우 계층 트리 아코디언 적용 + const areaObjects = filteredObjects.filter((obj) => obj.type === "area"); - {/* 필터 초기화 */} - {(searchQuery || filterType !== "all") && ( - - )} -
+ // Area가 없으면 기존 평면 리스트 유지 + if (areaObjects.length === 0) { + return ( +
+ {filteredObjects.map((obj) => { + let typeLabel = obj.type; + if (obj.type === "location-bed") typeLabel = "베드(BED)"; + else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; + else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; + else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; + else if (obj.type === "crane-mobile") typeLabel = "크레인"; + else if (obj.type === "area") typeLabel = "Area"; + else if (obj.type === "rack") typeLabel = "랙"; - {/* 객체 목록 */} -
- - {filteredObjects.length === 0 ? ( -
- {searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"} -
- ) : ( - (() => { - // Area 객체가 있는 경우 계층 트리 아코디언 적용 - const areaObjects = filteredObjects.filter((obj) => obj.type === "area"); + return ( +
handleObjectClick(obj.id)} + className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ + selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" + }`} + > +
+
+

{obj.name}

+
+ + {typeLabel} +
+
+
+
+ {obj.areaKey && ( +

+ Area: {obj.areaKey} +

+ )} + {obj.locaKey && ( +

+ Location: {obj.locaKey} +

+ )} + {obj.materialCount !== undefined && obj.materialCount > 0 && ( +

+ 자재: {obj.materialCount}개 +

+ )} +
+
+ ); + })} +
+ ); + } - // Area가 없으면 기존 평면 리스트 유지 - if (areaObjects.length === 0) { + // Area가 있는 경우: Area → Location 계층 아코디언 return ( -
- {filteredObjects.map((obj) => { - let typeLabel = obj.type; - if (obj.type === "location-bed") typeLabel = "베드(BED)"; - else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; - else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; - else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; - else if (obj.type === "crane-mobile") typeLabel = "크레인"; - else if (obj.type === "area") typeLabel = "Area"; - else if (obj.type === "rack") typeLabel = "랙"; + + {areaObjects.map((areaObj) => { + const childLocations = filteredObjects.filter( + (obj) => + obj.type !== "area" && + obj.areaKey === areaObj.areaKey && + (obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey), + ); return ( -
handleObjectClick(obj.id)} - className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ - selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" - }`} - > -
-
-

{obj.name}

-
+ + +
{ + e.stopPropagation(); + handleObjectClick(areaObj.id); + }} + > +
+ + {areaObj.name} +
+
+ ({childLocations.length}) - {typeLabel}
-
-
- {obj.areaKey && ( -

- Area: {obj.areaKey} -

+ + + {childLocations.length === 0 ? ( +

Location이 없습니다

+ ) : ( +
+ {childLocations.map((locationObj) => ( +
handleObjectClick(locationObj.id)} + className={`cursor-pointer rounded-lg border p-2 transition-all ${ + selectedObject?.id === locationObj.id + ? "border-primary bg-primary/10" + : "hover:border-primary/50" + }`} + > +
+
+ {locationObj.type === "location-stp" ? ( + + ) : ( + + )} + {locationObj.name} +
+ +
+

+ 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)}) +

+ {locationObj.locaKey && ( +

+ Location: {locationObj.locaKey} +

+ )} + {locationObj.materialCount !== undefined && locationObj.materialCount > 0 && ( +

+ 자재: {locationObj.materialCount}개 +

+ )} +
+ ))} +
)} - {obj.locaKey && ( -

- Location: {obj.locaKey} -

- )} - {obj.materialCount !== undefined && obj.materialCount > 0 && ( -

- 자재: {obj.materialCount}개 -

- )} -
-
+ + ); })} -
+ ); - } + })() + )} +
+
+ )} - // Area가 있는 경우: Area → Location 계층 아코디언 - return ( - - {areaObjects.map((areaObj) => { - const childLocations = filteredObjects.filter( - (obj) => - obj.type !== "area" && - obj.areaKey === areaObj.areaKey && - (obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey), - ); - - return ( - - -
{ - e.stopPropagation(); - handleObjectClick(areaObj.id); - }} - > -
- - {areaObj.name} -
-
- ({childLocations.length}) - -
-
-
- - {childLocations.length === 0 ? ( -

Location이 없습니다

- ) : ( -
- {childLocations.map((locationObj) => ( -
handleObjectClick(locationObj.id)} - className={`cursor-pointer rounded-lg border p-2 transition-all ${ - selectedObject?.id === locationObj.id - ? "border-primary bg-primary/10" - : "hover:border-primary/50" - }`} - > -
-
- {locationObj.type === "location-stp" ? ( - - ) : ( - - )} - {locationObj.name} -
- -
-

- 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)}) -

- {locationObj.locaKey && ( -

- Location: {locationObj.locaKey} -

- )} - {locationObj.materialCount !== undefined && locationObj.materialCount > 0 && ( -

- 자재: {locationObj.materialCount}개 -

- )} -
- ))} -
- )} -
-
- ); - })} -
- ); - })() + {/* 중앙 + 우측 컨테이너 (전체화면 시 함께 표시) */} +
+ {/* 중앙: 3D 캔버스 */} +
+ {!isLoading && ( + handleObjectClick(placement?.id || null)} + focusOnPlacementId={null} + onCollisionDetected={() => {}} + /> )}
-
- {/* 중앙: 3D 캔버스 */} -
- {!isLoading && ( - handleObjectClick(placement?.id || null)} - focusOnPlacementId={null} - onCollisionDetected={() => {}} - /> - )} -
- - {/* 우측: 정보 패널 */} -
- {selectedObject ? ( -
-
-

상세 정보

-

{selectedObject.name}

-
- - {/* 기본 정보 */} -
-
- -

{selectedObject.type}

+ {/* 우측: 정보 패널 */} +
+ {selectedObject ? ( +
+
+

상세 정보

+

{selectedObject.name}

- {selectedObject.areaKey && ( -
- -

{selectedObject.areaKey}

-
- )} - {selectedObject.locaKey && ( -
- -

{selectedObject.locaKey}

-
- )} - {selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && ( -
- -

{selectedObject.materialCount}개

-
- )} -
- {/* 자재 목록 (Location인 경우) - 아코디언 */} - {(selectedObject.type === "location-bed" || - selectedObject.type === "location-stp" || - selectedObject.type === "location-temp" || - selectedObject.type === "location-dest") && ( -
- {loadingMaterials ? ( -
- + {/* 기본 정보 */} +
+
+ +

{selectedObject.type}

+
+ {selectedObject.areaKey && ( +
+ +

{selectedObject.areaKey}

- ) : materials.length === 0 ? ( -
- {externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"} + )} + {selectedObject.locaKey && ( +
+ +

{selectedObject.locaKey}

- ) : ( -
- - {materials.map((material, index) => { - const displayColumns = hierarchyConfig?.material?.displayColumns || []; - return ( -
- -
-
- - 층 {material[hierarchyConfig?.material?.layerColumn || "LOLAYER"]} - - {displayColumns[0] && ( - - {material[displayColumns[0].column]} - - )} -
-
- - - -
-
- {displayColumns.map((colConfig: any) => ( -
- {colConfig.label}: - {material[colConfig.column] || "-"} -
- ))} -
-
- ); - })} + )} + {selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && ( +
+ +

{selectedObject.materialCount}개

)}
- )} -
- ) : ( -
-

객체를 선택하세요

-
+ + {/* 자재 목록 (Location인 경우) - 테이블 형태 */} + {(selectedObject.type === "location-bed" || + selectedObject.type === "location-stp" || + selectedObject.type === "location-temp" || + selectedObject.type === "location-dest") && ( +
+ {loadingMaterials ? ( +
+ +
+ ) : materials.length === 0 ? ( +
+ {externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"} +
+ ) : ( +
+ + {/* 테이블 형태로 전체 조회 */} +
+ + + + + {(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => ( + + ))} + + + + {materials.map((material, index) => { + const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; + const displayColumns = hierarchyConfig?.material?.displayColumns || []; + return ( + + + {displayColumns.map((colConfig: any) => ( + + ))} + + ); + })} + +
+ {colConfig.label} +
+ {material[layerColumn]}단 + + {material[colConfig.column] || "-"} +
+
+
+ )} +
+ )} +
+ ) : ( +
+

객체를 선택하세요

+
+ )} +
+ + {/* 풀스크린 모드일 때 종료 버튼 */} + {isFullscreen && ( + )}
diff --git a/frontend/components/report/ReportListTable.tsx b/frontend/components/report/ReportListTable.tsx index 7c09537f..f8ad96ad 100644 --- a/frontend/components/report/ReportListTable.tsx +++ b/frontend/components/report/ReportListTable.tsx @@ -14,7 +14,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Pencil, Copy, Trash2, Loader2 } from "lucide-react"; +import { Copy, Trash2, Loader2 } from "lucide-react"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; import { useRouter } from "next/navigation"; @@ -149,7 +149,11 @@ export function ReportListTable({ {reports.map((report, index) => { const rowNumber = (page - 1) * limit + index + 1; return ( - + handleEdit(report.report_id)} + className="cursor-pointer hover:bg-muted/50" + > {rowNumber}
@@ -162,34 +166,25 @@ export function ReportListTable({ {report.created_by || "-"} {formatDate(report.updated_at || report.created_at)} -
+
e.stopPropagation()}> -
diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 7e8e54d2..01f0390b 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -23,6 +23,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) { canvasWidth, canvasHeight, margins, + layoutConfig, + currentPageId, } = useReportDesigner(); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); @@ -270,6 +272,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) { color: component.fontColor, fontWeight: component.fontWeight, textAlign: component.textAlign as "left" | "center" | "right", + whiteSpace: "pre-wrap", }} className="w-full" > @@ -291,6 +294,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) { color: component.fontColor, fontWeight: component.fontWeight, textAlign: component.textAlign as "left" | "center" | "right", + whiteSpace: "pre-wrap", }} > {displayValue} @@ -561,6 +565,245 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
); + case "pageNumber": + // 페이지 번호 포맷 + const format = component.pageNumberFormat || "number"; + const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order); + const currentPageIndex = sortedPages.findIndex((p) => p.page_id === currentPageId); + const totalPages = sortedPages.length; + const currentPageNum = currentPageIndex + 1; + + let pageNumberText = ""; + switch (format) { + case "number": + pageNumberText = `${currentPageNum}`; + break; + case "numberTotal": + pageNumberText = `${currentPageNum} / ${totalPages}`; + break; + case "koreanNumber": + pageNumberText = `${currentPageNum} 페이지`; + break; + default: + pageNumberText = `${currentPageNum}`; + } + + return ( +
+ {pageNumberText} +
+ ); + + case "card": + // 카드 컴포넌트: 제목 + 항목 목록 + const cardTitle = component.cardTitle || "정보 카드"; + const cardItems = component.cardItems || []; + const labelWidth = component.labelWidth || 80; + const showCardTitle = component.showCardTitle !== false; + const titleFontSize = component.titleFontSize || 14; + const labelFontSize = component.labelFontSize || 13; + const valueFontSize = component.valueFontSize || 13; + const titleColor = component.titleColor || "#1e40af"; + const labelColor = component.labelColor || "#374151"; + const valueColor = component.valueColor || "#000000"; + + // 쿼리 바인딩된 값 가져오기 + const getCardItemValue = (item: { label: string; value: string; fieldName?: string }) => { + if (item.fieldName && component.queryId) { + const queryResult = getQueryResult(component.queryId); + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + const row = queryResult.rows[0]; + return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value; + } + } + return item.value; + }; + + return ( +
+ {/* 제목 */} + {showCardTitle && ( + <> +
+ {cardTitle} +
+ {/* 구분선 */} +
+ + )} + {/* 항목 목록 */} +
+ {cardItems.map((item: { label: string; value: string; fieldName?: string }, index: number) => ( +
+ + {item.label} + + + {getCardItemValue(item)} + +
+ ))} +
+
+ ); + + case "calculation": + // 계산 컴포넌트 + const calcItems = component.calcItems || []; + const resultLabel = component.resultLabel || "합계"; + const calcLabelWidth = component.labelWidth || 120; + const calcLabelFontSize = component.labelFontSize || 13; + const calcValueFontSize = component.valueFontSize || 13; + const calcResultFontSize = component.resultFontSize || 16; + const calcLabelColor = component.labelColor || "#374151"; + const calcValueColor = component.valueColor || "#000000"; + const calcResultColor = component.resultColor || "#2563eb"; + const numberFormat = component.numberFormat || "currency"; + const currencySuffix = component.currencySuffix || "원"; + + // 숫자 포맷팅 함수 + const formatNumber = (num: number): string => { + if (numberFormat === "none") return String(num); + if (numberFormat === "comma") return num.toLocaleString(); + if (numberFormat === "currency") return num.toLocaleString() + currencySuffix; + return String(num); + }; + + // 쿼리 바인딩된 값 가져오기 + const getCalcItemValue = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => { + if (item.fieldName && component.queryId) { + const queryResult = getQueryResult(component.queryId); + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + const row = queryResult.rows[0]; + const val = row[item.fieldName]; + return typeof val === "number" ? val : parseFloat(String(val)) || 0; + } + } + return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0; + }; + + // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) + const calculateResult = (): number => { + if (calcItems.length === 0) return 0; + + // 첫 번째 항목은 기준값 + let result = getCalcItemValue(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string }); + + // 두 번째 항목부터 연산자 적용 + for (let i = 1; i < calcItems.length; i++) { + const item = calcItems[i]; + const val = getCalcItemValue(item as { label: string; value: number | string; operator: string; fieldName?: string }); + switch (item.operator) { + case "+": + result += val; + break; + case "-": + result -= val; + break; + case "x": + result *= val; + break; + case "÷": + result = val !== 0 ? result / val : result; + break; + } + } + return result; + }; + + const calcResult = calculateResult(); + + return ( +
+ {/* 항목 목록 */} +
+ {calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, index: number) => { + const itemValue = getCalcItemValue(item); + return ( +
+ + {item.label} + + + {formatNumber(itemValue)} + +
+ ); + })} +
+ {/* 구분선 */} +
+ {/* 결과 */} +
+ + {resultLabel} + + + {formatNumber(calcResult)} + +
+
+ ); + default: return
알 수 없는 컴포넌트
; } diff --git a/frontend/components/report/designer/ComponentPalette.tsx b/frontend/components/report/designer/ComponentPalette.tsx index d6591d0e..9dd0543f 100644 --- a/frontend/components/report/designer/ComponentPalette.tsx +++ b/frontend/components/report/designer/ComponentPalette.tsx @@ -1,7 +1,7 @@ "use client"; import { useDrag } from "react-dnd"; -import { Type, Table, Tag, Image, Minus, PenLine, Stamp as StampIcon } from "lucide-react"; +import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator } from "lucide-react"; interface ComponentItem { type: string; @@ -12,11 +12,13 @@ interface ComponentItem { const COMPONENTS: ComponentItem[] = [ { type: "text", label: "텍스트", icon: }, { type: "table", label: "테이블", icon: }, - { type: "label", label: "레이블", icon: }, { type: "image", label: "이미지", icon: }, { type: "divider", label: "구분선", icon: }, { type: "signature", label: "서명란", icon: }, { type: "stamp", label: "도장란", icon: }, + { type: "pageNumber", label: "페이지번호", icon: }, + { type: "card", label: "정보카드", icon: }, + { type: "calculation", label: "계산", icon: }, ]; function DraggableComponentItem({ type, label, icon }: ComponentItem) { diff --git a/frontend/components/report/designer/PageListPanel.tsx b/frontend/components/report/designer/PageListPanel.tsx index e350ce51..4f191d5a 100644 --- a/frontend/components/report/designer/PageListPanel.tsx +++ b/frontend/components/report/designer/PageListPanel.tsx @@ -76,25 +76,25 @@ export function PageListPanel() { }; return ( -
+
{/* 헤더 */} -
-

페이지 목록

-
{/* 페이지 목록 */}
- -
+ +
{layoutConfig.pages .sort((a, b) => a.page_order - b.page_order) .map((page, index) => (
handleDragOver(e, index)} onDrop={(e) => handleDrop(e, index)} > -
+
{/* 드래그 핸들 */}
e.stopPropagation()} > - +
{/* 페이지 정보 */}
{editingPageId === page.page_id ? ( -
e.stopPropagation()}> +
e.stopPropagation()}> setEditingName(e.target.value)} @@ -129,21 +129,21 @@ export function PageListPanel() { if (e.key === "Enter") handleSaveEdit(); if (e.key === "Escape") handleCancelEdit(); }} - className="h-6 text-xs" + className="h-5 text-[10px]" autoFocus /> - -
) : ( -
{page.page_name}
+
{page.page_name}
)} -
- {page.width}x{page.height}mm • {page.components.length}개 +
+ {page.width}x{page.height}mm
@@ -153,10 +153,10 @@ export function PageListPanel() { @@ -199,9 +199,9 @@ export function PageListPanel() {
{/* 푸터 */} -
-
diff --git a/frontend/components/report/designer/QueryManager.tsx b/frontend/components/report/designer/QueryManager.tsx index 622713b7..39b0d173 100644 --- a/frontend/components/report/designer/QueryManager.tsx +++ b/frontend/components/report/designer/QueryManager.tsx @@ -201,7 +201,8 @@ export function QueryManager() { setIsTestRunning({ ...isTestRunning, [query.id]: true }); try { const testReportId = reportId === "new" ? "TEMP_TEST" : reportId; - const sqlQuery = reportId === "new" ? query.sqlQuery : undefined; + // 항상 sqlQuery를 전달 (새 쿼리가 아직 DB에 저장되지 않았을 수 있음) + const sqlQuery = query.sqlQuery; const externalConnectionId = (query as any).externalConnectionId || null; const queryParams = parameterValues[query.id] || {}; @@ -264,24 +265,24 @@ export function QueryManager() { return ( - -
+
+
{query.name} {query.type}
+
- {/* 쿼리 이름 */}
diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index ace87249..bcf9d88f 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -65,6 +65,9 @@ export function ReportDesignerCanvas() { } else if (item.componentType === "stamp") { width = 70; height = 70; + } else if (item.componentType === "pageNumber") { + width = 100; + height = 30; } // 여백을 px로 변환 (1mm ≈ 3.7795px) @@ -143,6 +146,55 @@ export function ReportDesignerCanvas() { borderWidth: 0, borderColor: "#cccccc", }), + // 페이지 번호 전용 + ...(item.componentType === "pageNumber" && { + pageNumberFormat: "number" as const, // number, numberTotal, koreanNumber + textAlign: "center" as const, + }), + // 카드 컴포넌트 전용 + ...(item.componentType === "card" && { + width: 300, + height: 180, + cardTitle: "정보 카드", + showCardTitle: true, + cardItems: [ + { label: "항목1", value: "내용1", fieldName: "" }, + { label: "항목2", value: "내용2", fieldName: "" }, + { label: "항목3", value: "내용3", fieldName: "" }, + ], + labelWidth: 80, + showCardBorder: true, + titleFontSize: 14, + labelFontSize: 13, + valueFontSize: 13, + titleColor: "#1e40af", + labelColor: "#374151", + valueColor: "#000000", + borderWidth: 1, + borderColor: "#e5e7eb", + }), + // 계산 컴포넌트 전용 + ...(item.componentType === "calculation" && { + width: 350, + height: 120, + calcItems: [ + { label: "공급가액", value: 0, operator: "+" as const, fieldName: "" }, + { label: "부가세 (10%)", value: 0, operator: "+" as const, fieldName: "" }, + ], + resultLabel: "합계 금액", + labelWidth: 120, + labelFontSize: 13, + valueFontSize: 13, + resultFontSize: 16, + labelColor: "#374151", + valueColor: "#000000", + resultColor: "#2563eb", + showCalcBorder: false, + numberFormat: "currency" as const, + currencySuffix: "원", + borderWidth: 0, + borderColor: "#e5e7eb", + }), // 테이블 전용 ...(item.componentType === "table" && { queryId: undefined, @@ -297,13 +349,8 @@ export function ReportDesignerCanvas() { return (
- {/* 작업 영역 제목 */} -
- {currentPage.page_name} ({currentPage.width} x {currentPage.height}mm) -
- {/* 캔버스 스크롤 영역 */} -
+
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
{/* 좌상단 코너 + 가로 눈금자 */} diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index bc2eea74..ff832e21 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -5,6 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -27,6 +28,7 @@ export function ReportDesignerRightPanel() { currentPage, currentPageId, updatePageSettings, + getQueryResult, } = context; const [activeTab, setActiveTab] = useState("properties"); const [uploadingImage, setUploadingImage] = useState(false); @@ -918,6 +920,717 @@ export function ReportDesignerRightPanel() { )} + {/* 페이지 번호 설정 */} + {selectedComponent.type === "pageNumber" && ( + + + 페이지 번호 설정 + + +
+ + +
+
+
+ )} + + {/* 카드 컴포넌트 설정 */} + {selectedComponent.type === "card" && ( + + + 카드 설정 + + + {/* 제목 표시 여부 */} +
+ + updateComponent(selectedComponent.id, { + showCardTitle: e.target.checked, + }) + } + className="h-4 w-4" + /> + +
+ + {/* 제목 텍스트 */} + {selectedComponent.showCardTitle !== false && ( +
+ + + updateComponent(selectedComponent.id, { + cardTitle: e.target.value, + }) + } + placeholder="정보 카드" + className="h-8" + /> +
+ )} + + {/* 라벨 너비 */} +
+ + + updateComponent(selectedComponent.id, { + labelWidth: Number(e.target.value), + }) + } + min={40} + max={200} + className="h-8" + /> +
+ + {/* 테두리 표시 */} +
+ + updateComponent(selectedComponent.id, { + showCardBorder: e.target.checked, + borderWidth: e.target.checked ? 1 : 0, + }) + } + className="h-4 w-4" + /> + +
+ + {/* 폰트 크기 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + titleFontSize: Number(e.target.value), + }) + } + min={10} + max={24} + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + labelFontSize: Number(e.target.value), + }) + } + min={10} + max={20} + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + valueFontSize: Number(e.target.value), + }) + } + min={10} + max={20} + className="h-8" + /> +
+
+ + {/* 색상 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + titleColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + + updateComponent(selectedComponent.id, { + labelColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + + updateComponent(selectedComponent.id, { + valueColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + {/* 항목 목록 관리 */} +
+
+ + +
+ + {/* 쿼리 선택 (데이터 바인딩용) */} +
+ + +
+ + {/* 항목 리스트 */} +
+ {(selectedComponent.cardItems || []).map( + (item: { label: string; value: string; fieldName?: string }, index: number) => ( +
+
+ 항목 {index + 1} + +
+
+
+ + { + const currentItems = [...(selectedComponent.cardItems || [])]; + currentItems[index] = { ...item, label: e.target.value }; + updateComponent(selectedComponent.id, { + cardItems: currentItems, + }); + }} + className="h-6 text-xs" + placeholder="항목명" + /> +
+ {selectedComponent.queryId ? ( +
+ + +
+ ) : ( +
+ + { + const currentItems = [...(selectedComponent.cardItems || [])]; + currentItems[index] = { ...item, value: e.target.value }; + updateComponent(selectedComponent.id, { + cardItems: currentItems, + }); + }} + className="h-6 text-xs" + placeholder="내용" + /> +
+ )} +
+
+ ), + )} +
+
+
+
+ )} + + {/* 계산 컴포넌트 설정 */} + {selectedComponent.type === "calculation" && ( + + + 계산 설정 + + + {/* 결과 라벨 */} +
+ + + updateComponent(selectedComponent.id, { + resultLabel: e.target.value, + }) + } + placeholder="합계 금액" + className="h-8" + /> +
+ + {/* 라벨 너비 */} +
+ + + updateComponent(selectedComponent.id, { + labelWidth: Number(e.target.value), + }) + } + min={60} + max={200} + className="h-8" + /> +
+ + {/* 숫자 포맷 */} +
+ + +
+ + {/* 통화 접미사 */} + {selectedComponent.numberFormat === "currency" && ( +
+ + + updateComponent(selectedComponent.id, { + currencySuffix: e.target.value, + }) + } + placeholder="원" + className="h-8" + /> +
+ )} + + {/* 폰트 크기 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + labelFontSize: Number(e.target.value), + }) + } + min={10} + max={20} + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + valueFontSize: Number(e.target.value), + }) + } + min={10} + max={20} + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + resultFontSize: Number(e.target.value), + }) + } + min={12} + max={24} + className="h-8" + /> +
+
+ + {/* 색상 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + labelColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + + updateComponent(selectedComponent.id, { + valueColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + + updateComponent(selectedComponent.id, { + resultColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + {/* 계산 항목 목록 관리 */} +
+
+ + +
+ + {/* 쿼리 선택 (데이터 바인딩용) */} +
+ + +
+ + {/* 항목 리스트 */} +
+ {(selectedComponent.calcItems || []).map((item, index: number) => ( +
+
+ 항목 {index + 1} + +
+
+
+ + { + const currentItems = [...(selectedComponent.calcItems || [])]; + currentItems[index] = { ...currentItems[index], label: e.target.value }; + updateComponent(selectedComponent.id, { + calcItems: currentItems, + }); + }} + className="h-6 text-xs" + placeholder="항목명" + /> +
+ {/* 두 번째 항목부터 연산자 표시 */} + {index > 0 && ( +
+ + +
+ )} +
+
+ {selectedComponent.queryId ? ( +
+ + +
+ ) : ( +
+ + { + const currentItems = [...(selectedComponent.calcItems || [])]; + currentItems[index] = { + ...currentItems[index], + value: Number(e.target.value), + }; + updateComponent(selectedComponent.id, { + calcItems: currentItems, + }); + }} + className="h-6 text-xs" + placeholder="0" + /> +
+ )} +
+
+ ))} +
+
+
+
+ )} + {/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */} {(selectedComponent.type === "text" || selectedComponent.type === "label" || @@ -1120,16 +1833,16 @@ export function ReportDesignerRightPanel() { {/* 기본값 (텍스트/라벨만) */} {(selectedComponent.type === "text" || selectedComponent.type === "label") && (
- - 텍스트 내용 +