Merge remote-tracking branch 'upstream/main'

This commit is contained in:
SeongHyun Kim 2025-12-22 09:20:57 +09:00
commit 3e409cf9ac
74 changed files with 17687 additions and 1678 deletions

680
PLAN_RENEWAL.md Normal file
View File

@ -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"<br>**`source`**: "static" / "code" / "db" / "api"<br>**`dependency`**: { parentField: "..." } |
| **2. Unified Input** | Text, Number, Email, Tel, Password, Color, Search, Integer, Decimal | **`type`**: "text" / "number" / "password"<br>**`format`**: "email", "currency", "biz_no"<br>**`mask`**: "000-0000-0000" |
| **3. Unified Date** | Date, Time, DateTime, DateRange, Month, Year | **`type`**: "date" / "time" / "datetime"<br>**`range`**: true/false |
| **4. Unified Text** | Textarea, RichEditor, Markdown, HTML | **`mode`**: "simple" / "rich" / "code"<br>**`rows`**: number |
| **5. Unified Media** | File, Image, Video, Audio, Attachment | **`type`**: "file" / "image"<br>**`multiple`**: true/false<br>**`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"<br>**`editable`**: true/false | - `viewMode='table'`: 엑셀형 리스트<br>- `viewMode='card'`: **카드 디스플레이**<br>- `editable=true`: **반복 필드 그룹** |
| **7. Unified Layout** | **Row, Col, Split Panel, Grid, Spacer** | **`type`**: "grid" / "split" / "flex"<br>**`columns`**: number | - `type='split'`: **화면 분할 패널**<br>- `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'`: **랙 구조 설정**<br>- 특수 비즈니스 로직 플러그인 탑재 |
### 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: 별도 관리 메뉴에서 정의 후 참조
<SelectWidget cascadingRelation="WAREHOUSE_LOCATION" />
// TO-BE: 컴포넌트 속성에서 직접 정의
<UnifiedSelect
source="db"
table="warehouse_location"
valueColumn="location_code"
labelColumn="location_name"
cascading={{
parentField: "warehouse_code", // 같은 화면 내 부모 필드
filterColumn: "warehouse_code", // 필터링할 컬럼
clearOnChange: true // 부모 변경 시 초기화
}}
/>
```
#### 조건부 필터 → 공통 conditional 속성
```typescript
// AS-IS: 별도 관리 메뉴에서 조건 정의
// cascading_condition 테이블에 저장
// TO-BE: 모든 컴포넌트에 공통 속성으로 적용
<UnifiedInput
conditional={{
enabled: true,
field: "order_type", // 참조할 필드
operator: "=", // 비교 연산자
value: "EXPORT", // 비교 값
action: "show", // show | hide | disable | enable
}}
/>
```
#### 자동 입력 → autoFill 속성
```typescript
// AS-IS: cascading_auto_fill_group 테이블에 정의
// TO-BE: 컴포넌트 속성에서 직접 정의
<UnifiedInput
autoFill={{
enabled: true,
sourceTable: "company_mng", // 조회할 테이블
filterColumn: "company_code", // 필터링 컬럼
userField: "companyCode", // 사용자 정보 필드
displayColumn: "company_name", // 표시할 컬럼
}}
/>
```
#### 상호 배제 → mutualExclusion 속성
```typescript
// AS-IS: cascading_mutual_exclusion 테이블에 정의
// TO-BE: 컴포넌트 속성에서 직접 정의
<UnifiedSelect
mutualExclusion={{
enabled: true,
targetField: "sub_category", // 상호 배제 대상 필드
type: "exclusive", // exclusive | inclusive
}}
/>
```
### 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건)를
> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다.

View File

@ -14,10 +14,12 @@
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"docx": "^9.5.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.0",
"imap": "^0.8.19", "imap": "^0.8.19",
"joi": "^17.11.0", "joi": "^17.11.0",
@ -2256,6 +2258,93 @@
"node": ">= 8" "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": { "node_modules/@paralleldrive/cuid2": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
@ -4326,6 +4415,12 @@
"node": ">=8" "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": { "node_modules/browserslist": {
"version": "4.26.2", "version": "4.26.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
@ -4521,6 +4616,15 @@
"node": ">=6" "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": { "node_modules/caniuse-lite": {
"version": "1.0.30001745", "version": "1.0.30001745",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz",
@ -5202,6 +5306,56 @@
"node": ">=6.0.0" "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": { "node_modules/dom-serializer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "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" "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": { "node_modules/domelementtype": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
@ -5349,6 +5508,27 @@
"node": ">=8.10.0" "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": { "node_modules/entities": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@ -5361,6 +5541,16 @@
"url": "https://github.com/fb55/entities?sponsor=1" "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": { "node_modules/error-ex": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@ -5643,6 +5833,14 @@
"node": ">= 0.6" "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": { "node_modules/event-target-shim": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@ -6279,6 +6477,16 @@
"node": "*" "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": { "node_modules/globals": {
"version": "13.24.0", "version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
@ -6413,6 +6621,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -6443,6 +6661,22 @@
"node": ">=16.0.0" "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": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -6450,6 +6684,27 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/html-to-text": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
@ -6466,6 +6721,106 @@
"node": ">=14" "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": { "node_modules/htmlparser2": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@ -6590,6 +6945,30 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/imap": {
"version": "0.8.19", "version": "0.8.19",
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz", "resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
@ -6626,6 +7005,12 @@
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
"license": "MIT" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -6673,6 +7058,11 @@
"node": ">=0.8.19" "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": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@ -6854,6 +7244,15 @@
"node": ">=0.12.0" "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": { "node_modules/is-path-inside": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
@ -7696,6 +8095,18 @@
"npm": ">=6" "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": { "node_modules/jwa": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
@ -7812,6 +8223,15 @@
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT" "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": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -8177,6 +8597,21 @@
"node": ">=6" "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": { "node_modules/minimatch": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
@ -8300,6 +8735,24 @@
"node": ">=12" "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": { "node_modules/native-duplexpair": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
@ -8329,6 +8782,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/node-cron": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
@ -8670,6 +9129,12 @@
"node": ">=6" "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": { "node_modules/parchment": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
@ -9179,6 +9644,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -9595,6 +10069,23 @@
], ],
"license": "MIT" "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": { "node_modules/safe-stable-stringify": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
@ -9610,6 +10101,12 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@ -9744,6 +10241,12 @@
"node": ">= 0.4" "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": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -10020,6 +10523,11 @@
"node": ">=10" "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": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -10685,6 +11193,22 @@
"node": ">= 0.8" "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": { "node_modules/walker": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
@ -10862,6 +11386,80 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -28,10 +28,12 @@
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"docx": "^9.5.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.0",
"imap": "^0.8.19", "imap": "^0.8.19",
"joi": "^17.11.0", "joi": "^17.11.0",

View File

@ -80,6 +80,7 @@ import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리 import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리 import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리 import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // 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-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리 app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리 app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석

View File

@ -3394,13 +3394,23 @@ export async function copyMenu(
} }
: undefined; : 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 menuCopyService = new MenuCopyService();
const result = await menuCopyService.copyMenu( const result = await menuCopyService.copyMenu(
parseInt(menuObjid, 10), parseInt(menuObjid, 10),
targetCompanyCode, targetCompanyCode,
userId, userId,
screenNameConfig screenNameConfig,
additionalCopyOptions
); );
logger.info("✅ 메뉴 복사 API 성공"); logger.info("✅ 메뉴 복사 API 성공");

View File

@ -662,6 +662,10 @@ export const getParentOptions = async (
/** /**
* *
* API * API
*
* :
* - parentValue: 단일 (: "공정검사")
* - parentValues: 다중 (: "공정검사,출하검사" )
*/ */
export const getCascadingOptions = async ( export const getCascadingOptions = async (
req: AuthenticatedRequest, req: AuthenticatedRequest,
@ -669,10 +673,26 @@ export const getCascadingOptions = async (
) => { ) => {
try { try {
const { code } = req.params; const { code } = req.params;
const { parentValue } = req.query; const { parentValue, parentValues } = req.query;
const companyCode = req.user?.companyCode || "*"; 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({ return res.json({
success: true, success: true,
data: [], data: [],
@ -714,13 +734,17 @@ export const getCascadingOptions = async (
const relation = relationResult.rows[0]; const relation = relationResult.rows[0];
// 자식 옵션 조회 // 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용
// SQL Injection 방지를 위해 파라미터화된 쿼리 사용
const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', ');
let optionsQuery = ` let optionsQuery = `
SELECT SELECT DISTINCT
${relation.child_value_column} as value, ${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} FROM ${relation.child_table}
WHERE ${relation.child_filter_column} = $1 WHERE ${relation.child_filter_column} IN (${placeholders})
`; `;
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우) // 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
@ -730,7 +754,8 @@ export const getCascadingOptions = async (
[relation.child_table] [relation.child_table]
); );
const optionsParams: any[] = [parentValue]; const optionsParams: any[] = [...parentValueArray];
let paramIndex = parentValueArray.length + 1;
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만 // company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
if ( if (
@ -738,8 +763,9 @@ export const getCascadingOptions = async (
tableInfoResult.rowCount > 0 && tableInfoResult.rowCount > 0 &&
companyCode !== "*" companyCode !== "*"
) { ) {
optionsQuery += ` AND company_code = $2`; optionsQuery += ` AND company_code = $${paramIndex}`;
optionsParams.push(companyCode); optionsParams.push(companyCode);
paramIndex++;
} }
// 정렬 // 정렬
@ -751,9 +777,9 @@ export const getCascadingOptions = async (
const optionsResult = await pool.query(optionsQuery, optionsParams); const optionsResult = await pool.query(optionsQuery, optionsParams);
logger.info("연쇄 옵션 조회", { logger.info("연쇄 옵션 조회 (다중 부모값 지원)", {
relationCode: code, relationCode: code,
parentValue, parentValues: parentValueArray,
optionsCount: optionsResult.rowCount, optionsCount: optionsResult.rowCount,
}); });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -53,3 +53,4 @@ export default router;

View File

@ -49,3 +49,4 @@ export default router;

View File

@ -65,3 +65,4 @@ export default router;

View File

@ -53,3 +53,4 @@ export default router;

View File

@ -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;

View File

@ -56,6 +56,11 @@ router.post("/upload-image", upload.single("image"), (req, res, next) =>
reportController.uploadImage(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) => router.get("/", (req, res, next) =>
reportController.getReports(req, res, next) reportController.getReports(req, res, next)

View File

@ -22,6 +22,15 @@ const router = Router();
// 모든 role 라우트에 인증 미들웨어 적용 // 모든 role 라우트에 인증 미들웨어 적용
router.use(authenticateToken); router.use(authenticateToken);
/**
* (/:id )
*/
// 현재 사용자가 속한 권한 그룹 조회
router.get("/user/my-groups", getUserRoleGroups);
// 특정 사용자가 속한 권한 그룹 조회
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
/** /**
* CRUD * CRUD
*/ */
@ -67,13 +76,4 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions);
// 메뉴 권한 설정 // 메뉴 권한 설정
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions); router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
/**
*
*/
// 현재 사용자가 속한 권한 그룹 조회
router.get("/user/my-groups", getUserRoleGroups);
// 특정 사용자가 속한 권한 그룹 조회
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
export default router; export default router;

File diff suppressed because it is too large Load Diff

View File

@ -477,6 +477,12 @@ export class ReportService {
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ) 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, [ await client.query(copyLayoutQuery, [
newLayoutId, newLayoutId,
newReportId, newReportId,
@ -487,7 +493,7 @@ export class ReportService {
originalLayout.margin_bottom, originalLayout.margin_bottom,
originalLayout.margin_left, originalLayout.margin_left,
originalLayout.margin_right, originalLayout.margin_right,
JSON.stringify(originalLayout.components), componentsData,
userId, userId,
]); ]);
} }
@ -561,7 +567,7 @@ export class ReportService {
} }
/** /**
* ( ) * ( ) -
*/ */
async saveLayout( async saveLayout(
reportId: string, reportId: string,
@ -569,6 +575,19 @@ export class ReportService {
userId: string userId: string
): Promise<boolean> { ): Promise<boolean> {
return transaction(async (client) => { 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. 레이아웃 저장 // 1. 레이아웃 저장
const existingQuery = ` const existingQuery = `
SELECT layout_id FROM report_layout WHERE report_id = $1 SELECT layout_id FROM report_layout WHERE report_id = $1
@ -576,7 +595,7 @@ export class ReportService {
const existing = await client.query(existingQuery, [reportId]); const existing = await client.query(existingQuery, [reportId]);
if (existing.rows.length > 0) { if (existing.rows.length > 0) {
// 업데이트 // 업데이트 - components 컬럼에 전체 layoutConfig 저장
const updateQuery = ` const updateQuery = `
UPDATE report_layout UPDATE report_layout
SET SET
@ -594,14 +613,14 @@ export class ReportService {
`; `;
await client.query(updateQuery, [ await client.query(updateQuery, [
data.canvasWidth, canvasWidth,
data.canvasHeight, canvasHeight,
data.pageOrientation, pageOrientation,
data.marginTop, margins.top,
data.marginBottom, margins.bottom,
data.marginLeft, margins.left,
data.marginRight, margins.right,
JSON.stringify(data.components), JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
userId, userId,
reportId, reportId,
]); ]);
@ -627,14 +646,14 @@ export class ReportService {
await client.query(insertQuery, [ await client.query(insertQuery, [
layoutId, layoutId,
reportId, reportId,
data.canvasWidth, canvasWidth,
data.canvasHeight, canvasHeight,
data.pageOrientation, pageOrientation,
data.marginTop, margins.top,
data.marginBottom, margins.bottom,
data.marginLeft, margins.left,
data.marginRight, margins.right,
JSON.stringify(data.components), JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
userId, userId,
]); ]);
} }

View File

@ -116,22 +116,38 @@ export interface UpdateReportRequest {
useYn?: string; 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 { export interface SaveLayoutRequest {
canvasWidth: number; layoutConfig: ReportLayoutConfig;
canvasHeight: number;
pageOrientation: string;
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
components: any[];
queries?: Array<{ queries?: Array<{
id: string; id: string;
name: string; name: string;
type: "MASTER" | "DETAIL"; type: "MASTER" | "DETAIL";
sqlQuery: string; sqlQuery: string;
parameters: string[]; parameters: string[];
externalConnectionId?: number;
}>; }>;
} }

View File

@ -585,3 +585,4 @@ const result = await executeNodeFlow(flowId, {

View File

@ -358,3 +358,4 @@

View File

@ -344,3 +344,4 @@ const getComponentValue = (componentId: string) => {
4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장 4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장

View File

@ -3,7 +3,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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"; import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
@ -11,6 +11,7 @@ import AutoFillTab from "./tabs/AutoFillTab";
import HierarchyTab from "./tabs/HierarchyTab"; import HierarchyTab from "./tabs/HierarchyTab";
import ConditionTab from "./tabs/ConditionTab"; import ConditionTab from "./tabs/ConditionTab";
import MutualExclusionTab from "./tabs/MutualExclusionTab"; import MutualExclusionTab from "./tabs/MutualExclusionTab";
import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab";
export default function CascadingManagementPage() { export default function CascadingManagementPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -20,7 +21,7 @@ export default function CascadingManagementPage() {
// URL 쿼리 파라미터에서 탭 설정 // URL 쿼리 파라미터에서 탭 설정
useEffect(() => { useEffect(() => {
const tab = searchParams.get("tab"); 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); setActiveTab(tab);
} }
}, [searchParams]); }, [searchParams]);
@ -46,7 +47,7 @@ export default function CascadingManagementPage() {
{/* 탭 네비게이션 */} {/* 탭 네비게이션 */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full"> <Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="grid w-full grid-cols-5"> <TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="relations" className="gap-2"> <TabsTrigger value="relations" className="gap-2">
<Link2 className="h-4 w-4" /> <Link2 className="h-4 w-4" />
<span className="hidden sm:inline">2 </span> <span className="hidden sm:inline">2 </span>
@ -72,6 +73,11 @@ export default function CascadingManagementPage() {
<span className="hidden sm:inline"> </span> <span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span> <span className="sm:hidden"></span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="category-value" className="gap-2">
<Tags className="h-4 w-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
</TabsList> </TabsList>
{/* 탭 컨텐츠 */} {/* 탭 컨텐츠 */}
@ -95,6 +101,10 @@ export default function CascadingManagementPage() {
<TabsContent value="exclusion"> <TabsContent value="exclusion">
<MutualExclusionTab /> <MutualExclusionTab />
</TabsContent> </TabsContent>
<TabsContent value="category-value">
<CategoryValueCascadingTab />
</TabsContent>
</div> </div>
</Tabs> </Tabs>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -56,6 +56,13 @@ export function MenuCopyDialog({
const [removeText, setRemoveText] = useState(""); const [removeText, setRemoveText] = useState("");
const [addPrefix, setAddPrefix] = useState(""); const [addPrefix, setAddPrefix] = useState("");
// 카테고리/코드 복사 옵션
const [copyCodeCategory, setCopyCodeCategory] = useState(false);
const [copyNumberingRules, setCopyNumberingRules] = useState(false);
const [copyCategoryMapping, setCopyCategoryMapping] = useState(false);
const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false);
const [copyCascadingRelation, setCopyCascadingRelation] = useState(false);
// 회사 목록 로드 // 회사 목록 로드
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@ -66,6 +73,11 @@ export function MenuCopyDialog({
setUseBulkRename(false); setUseBulkRename(false);
setRemoveText(""); setRemoveText("");
setAddPrefix(""); setAddPrefix("");
setCopyCodeCategory(false);
setCopyNumberingRules(false);
setCopyCategoryMapping(false);
setCopyTableTypeColumns(false);
setCopyCascadingRelation(false);
} }
}, [open]); }, [open]);
@ -112,10 +124,20 @@ export function MenuCopyDialog({
} }
: undefined; : undefined;
// 추가 복사 옵션
const additionalCopyOptions = {
copyCodeCategory,
copyNumberingRules,
copyCategoryMapping,
copyTableTypeColumns,
copyCascadingRelation,
};
const response = await menuApi.copyMenu( const response = await menuApi.copyMenu(
menuObjid, menuObjid,
targetCompanyCode, targetCompanyCode,
screenNameConfig screenNameConfig,
additionalCopyOptions
); );
if (response.success && response.data) { if (response.success && response.data) {
@ -264,19 +286,96 @@ export function MenuCopyDialog({
</div> </div>
)} )}
{/* 추가 복사 옵션 */}
{!result && (
<div className="space-y-3">
<p className="text-xs font-medium"> ():</p>
<div className="space-y-2 pl-2 border-l-2">
<div className="flex items-center gap-2">
<Checkbox
id="copyCodeCategory"
checked={copyCodeCategory}
onCheckedChange={(checked) => setCopyCodeCategory(checked as boolean)}
disabled={copying}
/>
<Label
htmlFor="copyCodeCategory"
className="text-xs cursor-pointer"
>
+
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="copyNumberingRules"
checked={copyNumberingRules}
onCheckedChange={(checked) => setCopyNumberingRules(checked as boolean)}
disabled={copying}
/>
<Label
htmlFor="copyNumberingRules"
className="text-xs cursor-pointer"
>
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="copyCategoryMapping"
checked={copyCategoryMapping}
onCheckedChange={(checked) => setCopyCategoryMapping(checked as boolean)}
disabled={copying}
/>
<Label
htmlFor="copyCategoryMapping"
className="text-xs cursor-pointer"
>
+
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="copyTableTypeColumns"
checked={copyTableTypeColumns}
onCheckedChange={(checked) => setCopyTableTypeColumns(checked as boolean)}
disabled={copying}
/>
<Label
htmlFor="copyTableTypeColumns"
className="text-xs cursor-pointer"
>
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="copyCascadingRelation"
checked={copyCascadingRelation}
onCheckedChange={(checked) => setCopyCascadingRelation(checked as boolean)}
disabled={copying}
/>
<Label
htmlFor="copyCascadingRelation"
className="text-xs cursor-pointer"
>
</Label>
</div>
</div>
</div>
)}
{/* 복사 항목 안내 */} {/* 복사 항목 안내 */}
{!result && ( {!result && (
<div className="rounded-md border p-3 text-xs"> <div className="rounded-md border p-3 text-xs">
<p className="font-medium mb-2"> :</p> <p className="font-medium mb-2"> :</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground"> <ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li> ( )</li> <li> ( )</li>
<li> + (, )</li> <li> + (, )</li>
<li> (, )</li> <li> (, )</li>
<li> + </li>
<li> + </li>
</ul> </ul>
<p className="mt-2 text-warning"> <p className="mt-2 text-muted-foreground">
. * , , .
</p> </p>
</div> </div>
)} )}
@ -294,10 +393,46 @@ export function MenuCopyDialog({
<span className="text-muted-foreground">:</span>{" "} <span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedScreens}</span> <span className="font-medium">{result.copiedScreens}</span>
</div> </div>
<div className="col-span-2"> <div>
<span className="text-muted-foreground">:</span>{" "} <span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedFlows}</span> <span className="font-medium">{result.copiedFlows}</span>
</div> </div>
{(result.copiedCodeCategories ?? 0) > 0 && (
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{result.copiedCodeCategories}</span>
</div>
)}
{(result.copiedCodes ?? 0) > 0 && (
<div>
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedCodes}</span>
</div>
)}
{(result.copiedNumberingRules ?? 0) > 0 && (
<div>
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedNumberingRules}</span>
</div>
)}
{(result.copiedCategoryMappings ?? 0) > 0 && (
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{result.copiedCategoryMappings}</span>
</div>
)}
{(result.copiedTableTypeColumns ?? 0) > 0 && (
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{result.copiedTableTypeColumns}</span>
</div>
)}
{(result.copiedCascadingRelations ?? 0) > 0 && (
<div>
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedCascadingRelations}</span>
</div>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -916,7 +916,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "weather" ? ( ) : element.type === "widget" && element.subtype === "weather" ? (
// 날씨 위젯 렌더링 // 날씨 위젯 렌더링
<div className="widget-interactive-area h-full w-full"> <div className="widget-interactive-area h-full w-full">
<WeatherWidget city="서울" refreshInterval={600000} /> <WeatherWidget element={element} city="서울" refreshInterval={600000} />
</div> </div>
) : element.type === "widget" && element.subtype === "exchange" ? ( ) : element.type === "widget" && element.subtype === "exchange" ? (
// 환율 위젯 렌더링 // 환율 위젯 렌더링

View File

@ -19,6 +19,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import type { PlacedObject, ToolType, Warehouse, Area, Location, ObjectType } from "@/types/digitalTwin"; import type { PlacedObject, ToolType, Warehouse, Area, Location, ObjectType } from "@/types/digitalTwin";
@ -2141,45 +2142,39 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</div> </div>
) : ( ) : (
<Accordion type="single" collapsible className="w-full"> <div className="max-h-[400px] overflow-y-auto rounded-md border">
<Table>
<TableHeader className="bg-muted sticky top-0">
<TableRow>
<TableHead className="w-[70px] whitespace-nowrap px-3 py-3 text-sm"></TableHead>
{(hierarchyConfig?.material?.displayColumns || []).map((col) => (
<TableHead key={col.column} className="px-3 py-3 text-sm">
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material, index) => { {materials.map((material, index) => {
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY"; const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY";
const displayColumns = hierarchyConfig?.material?.displayColumns || []; const displayColumns = hierarchyConfig?.material?.displayColumns || [];
const layerNumber = material[layerColumn] || index + 1;
const layerValue = material[layerColumn] || index + 1;
const keyValue = material[keyColumn] || `자재 ${index + 1}`;
return ( return (
<AccordionItem key={`${keyValue}-${index}`} value={`item-${index}`} className="border-b"> <TableRow key={material[keyColumn] || `material-${index}`}>
<AccordionTrigger className="px-2 py-3 hover:no-underline"> <TableCell className="whitespace-nowrap px-3 py-3 text-sm font-medium">{layerNumber}</TableCell>
<div className="flex w-full items-center justify-between pr-2"> {displayColumns.map((col) => (
<span className="text-sm font-medium"> {layerValue}</span> <TableCell key={col.column} className="px-3 py-3 text-sm">
<span className="text-muted-foreground max-w-[150px] truncate text-xs">{keyValue}</span> {material[col.column] || "-"}
</div> </TableCell>
</AccordionTrigger>
<AccordionContent className="px-2 pb-3">
{displayColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs">
</p>
) : (
<div className="space-y-1.5">
{displayColumns.map((item) => (
<div key={item.column} className="flex justify-between gap-2 text-xs">
<span className="text-muted-foreground shrink-0">{item.label}:</span>
<span className="text-right font-medium break-all">
{material[item.column] || "-"}
</span>
</div>
))} ))}
</div> </TableRow>
)}
</AccordionContent>
</AccordionItem>
); );
})} })}
</Accordion> </TableBody>
</Table>
</div>
)} )}
</div> </div>
) : selectedObject ? ( ) : selectedObject ? (

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo, useRef } from "react";
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react"; import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw, Maximize, Minimize } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -12,6 +12,7 @@ import type { PlacedObject, MaterialData } from "@/types/digitalTwin";
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin"; import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants"; import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { apiCall } from "@/lib/api/client";
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
ssr: false, ssr: false,
@ -26,6 +27,9 @@ interface DigitalTwinViewerProps {
layoutId: number; layoutId: number;
} }
// 외부 업체 역할 코드
const EXTERNAL_VENDOR_ROLE = "LSTHIRA_EXTERNAL_VENDOR";
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) { export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
const { toast } = useToast(); const { toast } = useToast();
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]); const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
@ -43,6 +47,73 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
const [filterType, setFilterType] = useState<string>("all"); const [filterType, setFilterType] = useState<string>("all");
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
// 외부 업체 모드
const [isExternalMode, setIsExternalMode] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [layoutKey, setLayoutKey] = useState(0); // 레이아웃 강제 리렌더링용
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null); // 마지막 갱신 시간
const canvasContainerRef = useRef<HTMLDivElement>(null);
// 외부 업체 역할 체크
useEffect(() => {
const checkExternalRole = async () => {
try {
const response = await apiCall<any[]>("GET", "/roles/user/my-groups");
console.log("=== 사용자 권한 그룹 조회 ===");
console.log("API 응답:", response);
console.log("찾는 역할:", EXTERNAL_VENDOR_ROLE);
if (response.success && response.data) {
console.log("권한 그룹 목록:", response.data);
// 사용자의 권한 그룹 중 LSTHIRA_EXTERNAL_VENDOR가 있는지 확인
const hasExternalRole = response.data.some((group: any) => {
console.log("체크 중인 그룹:", group.authCode, group.authName);
return group.authCode === EXTERNAL_VENDOR_ROLE || group.authName === EXTERNAL_VENDOR_ROLE;
});
console.log("외부 업체 역할 보유:", hasExternalRole);
setIsExternalMode(hasExternalRole);
}
} catch (error) {
console.error("역할 조회 실패:", error);
}
};
checkExternalRole();
}, []);
// 전체 화면 토글 (3D 캔버스 영역만)
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
// 3D 캔버스 컨테이너만 풀스크린
canvasContainerRef.current?.requestFullscreen();
setIsFullscreen(true);
} else {
document.exitFullscreen();
setIsFullscreen(false);
}
};
// 전체 화면 변경 감지
useEffect(() => {
const handleFullscreenChange = () => {
const isNowFullscreen = !!document.fullscreenElement;
setIsFullscreen(isNowFullscreen);
// 전체화면 종료 시 레이아웃 강제 리렌더링
if (!isNowFullscreen) {
setTimeout(() => {
setLayoutKey((prev) => prev + 1);
window.dispatchEvent(new Event("resize"));
}, 50);
}
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
// 레이아웃 데이터 로드 함수 // 레이아웃 데이터 로드 함수
const loadLayout = async () => { const loadLayout = async () => {
try { try {
@ -144,6 +215,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
}), }),
); );
} }
// 마지막 갱신 시간 기록
setLastRefreshedAt(new Date());
} else { } else {
throw new Error(response.error || "레이아웃 조회 실패"); throw new Error(response.error || "레이아웃 조회 실패");
} }
@ -180,6 +253,155 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); }, [layoutId]);
// 10초 주기 자동 갱신 (중앙 관제 화면 자동 새로고침)
useEffect(() => {
const AUTO_REFRESH_INTERVAL = 10000; // 10초
const silentRefresh = async () => {
// 로딩 중이거나 새로고침 중이면 스킵
if (isLoading || isRefreshing) return;
try {
// 레이아웃 데이터 조용히 갱신
const response = await getLayoutById(layoutId);
if (response.success && response.data) {
const { layout, objects } = response.data;
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
// hierarchy_config 파싱
let hierarchyConfigData: any = null;
if (layout.hierarchy_config) {
hierarchyConfigData =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
setHierarchyConfig(hierarchyConfigData);
}
// 객체 데이터 변환
const loadedObjects: PlacedObject[] = objects.map((obj: any) => {
const objectType = obj.object_type;
return {
id: obj.id,
type: objectType,
name: obj.object_name,
position: {
x: parseFloat(obj.position_x),
y: parseFloat(obj.position_y),
z: parseFloat(obj.position_z),
},
size: {
x: parseFloat(obj.size_x),
y: parseFloat(obj.size_y),
z: parseFloat(obj.size_z),
},
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
color: getObjectColor(objectType, obj.color),
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height
? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
hierarchyLevel: obj.hierarchy_level,
parentKey: obj.parent_key,
externalKey: obj.external_key,
};
});
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
if (dbConnectionId && hierarchyConfigData?.material) {
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.locaKey,
);
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
const materialCountPromises = locationObjects.map(async (obj) => {
try {
const matResponse = await getMaterials(dbConnectionId, {
tableName: hierarchyConfigData.material.tableName,
keyColumn: hierarchyConfigData.material.keyColumn,
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
layerColumn: hierarchyConfigData.material.layerColumn,
locaKey: obj.locaKey!,
});
if (matResponse.success && matResponse.data) {
return { id: obj.id, count: matResponse.data.length };
}
} catch {
// 자동 갱신 시에는 에러 로그 생략
}
return { id: obj.id, count: 0 };
});
const materialCounts = await Promise.all(materialCountPromises);
// materialCount 업데이트
const updatedObjects = loadedObjects.map((obj) => {
const countData = materialCounts.find((m) => m.id === obj.id);
if (countData && countData.count > 0) {
return { ...obj, materialCount: countData.count };
}
return obj;
});
setPlacedObjects(updatedObjects);
} else {
setPlacedObjects(loadedObjects);
}
// 선택된 객체가 있으면 자재 목록도 갱신
if (selectedObject && dbConnectionId && hierarchyConfigData?.material) {
const currentObj = loadedObjects.find((o) => o.id === selectedObject.id);
if (
currentObj &&
(currentObj.type === "location-bed" ||
currentObj.type === "location-temp" ||
currentObj.type === "location-dest") &&
currentObj.locaKey
) {
const matResponse = await getMaterials(dbConnectionId, {
tableName: hierarchyConfigData.material.tableName,
keyColumn: hierarchyConfigData.material.keyColumn,
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
layerColumn: hierarchyConfigData.material.layerColumn,
locaKey: currentObj.locaKey,
});
if (matResponse.success && matResponse.data) {
const layerColumn = hierarchyConfigData.material.layerColumn || "LOLAYER";
const sortedMaterials = matResponse.data.sort(
(a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0),
);
setMaterials(sortedMaterials);
}
}
}
// 마지막 갱신 시간 기록
setLastRefreshedAt(new Date());
}
} catch {
// 자동 갱신 실패 시 조용히 무시 (사용자 경험 방해 안 함)
}
};
// 10초마다 자동 갱신
const intervalId = setInterval(silentRefresh, AUTO_REFRESH_INTERVAL);
// 컴포넌트 언마운트 시 인터벌 정리
return () => clearInterval(intervalId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId, isLoading, isRefreshing, selectedObject]);
// Location의 자재 목록 로드 // Location의 자재 목록 로드
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
if (!hierarchyConfig?.material) { if (!hierarchyConfig?.material) {
@ -200,7 +422,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
}); });
if (response.success && response.data) { if (response.success && response.data) {
const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER"; const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER";
const sortedMaterials = response.data.sort((a: any, b: any) => (a[layerColumn] || 0) - (b[layerColumn] || 0)); // 층 내림차순 정렬 (높은 층이 위로)
const sortedMaterials = response.data.sort((a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0));
setMaterials(sortedMaterials); setMaterials(sortedMaterials);
} else { } else {
setMaterials([]); setMaterials([]);
@ -334,8 +557,28 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
<div className="flex items-center justify-between border-b p-4"> <div className="flex items-center justify-between border-b p-4">
<div> <div>
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2> <h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
<p className="text-muted-foreground text-sm"> </p> <div className="flex items-center gap-3">
<p className="text-muted-foreground text-sm">{isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"}</p>
{lastRefreshedAt && (
<span className="text-muted-foreground text-xs">
: {lastRefreshedAt.toLocaleTimeString("ko-KR")}
</span>
)}
</div> </div>
</div>
<div className="flex items-center gap-2">
{/* 전체 화면 버튼 - 외부 업체 모드에서만 표시 */}
{isExternalMode && (
<Button
variant="outline"
size="sm"
onClick={toggleFullscreen}
title={isFullscreen ? "전체 화면 종료" : "전체 화면"}
>
{isFullscreen ? <Minimize className="mr-2 h-4 w-4" /> : <Maximize className="mr-2 h-4 w-4" />}
{isFullscreen ? "종료" : "전체 화면"}
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -347,10 +590,12 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
{isRefreshing ? "갱신 중..." : "새로고침"} {isRefreshing ? "갱신 중..." : "새로고침"}
</Button> </Button>
</div> </div>
</div>
{/* 메인 영역 */} {/* 메인 영역 */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* 좌측: 검색/필터 */} {/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */}
{!isExternalMode && (
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r"> <div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
{/* 검색 */} {/* 검색 */}
@ -575,9 +820,15 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
)} )}
</div> </div>
</div> </div>
)}
{/* 중앙 + 우측 컨테이너 (전체화면 시 함께 표시) */}
<div
ref={canvasContainerRef}
className={`relative flex flex-1 overflow-hidden ${isFullscreen ? "bg-background" : ""}`}
>
{/* 중앙: 3D 캔버스 */} {/* 중앙: 3D 캔버스 */}
<div className="relative flex-1"> <div className="relative min-w-0 flex-1">
{!isLoading && ( {!isLoading && (
<Yard3DCanvas <Yard3DCanvas
placements={canvasPlacements} placements={canvasPlacements}
@ -590,7 +841,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
</div> </div>
{/* 우측: 정보 패널 */} {/* 우측: 정보 패널 */}
<div className="h-full w-[480px] flex-shrink-0 overflow-y-auto border-l"> <div className="h-full w-[480px] min-w-[480px] flex-shrink-0 overflow-y-auto border-l">
{selectedObject ? ( {selectedObject ? (
<div className="p-4"> <div className="p-4">
<div className="mb-4"> <div className="mb-4">
@ -624,7 +875,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
)} )}
</div> </div>
{/* 자재 목록 (Location인 경우) - 아코디언 */} {/* 자재 목록 (Location인 경우) - 테이블 형태 */}
{(selectedObject.type === "location-bed" || {(selectedObject.type === "location-bed" ||
selectedObject.type === "location-stp" || selectedObject.type === "location-stp" ||
selectedObject.type === "location-temp" || selectedObject.type === "location-temp" ||
@ -641,46 +892,42 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<Label className="mb-2 block text-sm font-semibold"> ({materials.length})</Label> <Label className="mb-2 block text-sm font-semibold"> ({materials.length})</Label>
{/* 테이블 형태로 전체 조회 */}
<div className="h-[580px] overflow-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="border-b px-3 py-3 text-left font-semibold whitespace-nowrap"></th>
{(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => (
<th key={colConfig.column} className="border-b px-3 py-3 text-left font-semibold">
{colConfig.label}
</th>
))}
</tr>
</thead>
<tbody>
{materials.map((material, index) => { {materials.map((material, index) => {
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
const displayColumns = hierarchyConfig?.material?.displayColumns || []; const displayColumns = hierarchyConfig?.material?.displayColumns || [];
return ( return (
<details <tr
key={`${material.STKKEY}-${index}`} key={`${material.STKKEY}-${index}`}
className="bg-muted group hover:bg-accent rounded-lg border transition-colors" className="hover:bg-accent border-b transition-colors last:border-0"
> >
<summary className="flex cursor-pointer items-center justify-between p-3 text-sm font-medium"> <td className="px-3 py-3 font-medium whitespace-nowrap">
<div className="flex-1"> {material[layerColumn]}
<div className="flex items-center gap-2"> </td>
<span className="font-semibold">
{material[hierarchyConfig?.material?.layerColumn || "LOLAYER"]}
</span>
{displayColumns[0] && (
<span className="text-muted-foreground text-xs">
{material[displayColumns[0].column]}
</span>
)}
</div>
</div>
<svg
className="text-muted-foreground h-4 w-4 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="space-y-2 border-t p-3 pt-3">
{displayColumns.map((colConfig: any) => ( {displayColumns.map((colConfig: any) => (
<div key={colConfig.column} className="flex justify-between text-xs"> <td key={colConfig.column} className="px-3 py-3">
<span className="text-muted-foreground">{colConfig.label}:</span> {material[colConfig.column] || "-"}
<span className="font-medium">{material[colConfig.column] || "-"}</span> </td>
</div>
))} ))}
</div> </tr>
</details>
); );
})} })}
</tbody>
</table>
</div>
</div> </div>
)} )}
</div> </div>
@ -692,6 +939,20 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
</div> </div>
)} )}
</div> </div>
{/* 풀스크린 모드일 때 종료 버튼 */}
{isFullscreen && (
<Button
variant="outline"
size="sm"
onClick={toggleFullscreen}
className="bg-background/80 absolute top-4 right-4 z-50 backdrop-blur-sm"
>
<Minimize className="mr-2 h-4 w-4" />
</Button>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@ -14,7 +14,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } 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 { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -149,7 +149,11 @@ export function ReportListTable({
{reports.map((report, index) => { {reports.map((report, index) => {
const rowNumber = (page - 1) * limit + index + 1; const rowNumber = (page - 1) * limit + index + 1;
return ( return (
<TableRow key={report.report_id}> <TableRow
key={report.report_id}
onClick={() => handleEdit(report.report_id)}
className="cursor-pointer hover:bg-muted/50"
>
<TableCell className="font-medium">{rowNumber}</TableCell> <TableCell className="font-medium">{rowNumber}</TableCell>
<TableCell> <TableCell>
<div> <div>
@ -162,34 +166,25 @@ export function ReportListTable({
<TableCell>{report.created_by || "-"}</TableCell> <TableCell>{report.created_by || "-"}</TableCell>
<TableCell>{formatDate(report.updated_at || report.created_at)}</TableCell> <TableCell>{formatDate(report.updated_at || report.created_at)}</TableCell>
<TableCell> <TableCell>
<div className="flex gap-2"> <div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button <Button
size="sm" size="icon"
variant="outline"
onClick={() => handleEdit(report.report_id)}
className="gap-1"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline" variant="outline"
onClick={() => handleCopy(report.report_id)} onClick={() => handleCopy(report.report_id)}
disabled={isCopying} disabled={isCopying}
className="gap-1" className="h-8 w-8"
title="복사"
> >
<Copy className="h-3 w-3" /> <Copy className="h-4 w-4" />
</Button> </Button>
<Button <Button
size="sm" size="icon"
variant="destructive" variant="destructive"
onClick={() => handleDeleteClick(report.report_id)} onClick={() => handleDeleteClick(report.report_id)}
className="gap-1" className="h-8 w-8"
title="삭제"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</TableCell> </TableCell>

View File

@ -23,6 +23,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
canvasWidth, canvasWidth,
canvasHeight, canvasHeight,
margins, margins,
layoutConfig,
currentPageId,
} = useReportDesigner(); } = useReportDesigner();
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
@ -270,6 +272,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
color: component.fontColor, color: component.fontColor,
fontWeight: component.fontWeight, fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right", textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap",
}} }}
className="w-full" className="w-full"
> >
@ -291,6 +294,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
color: component.fontColor, color: component.fontColor,
fontWeight: component.fontWeight, fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right", textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap",
}} }}
> >
{displayValue} {displayValue}
@ -561,6 +565,245 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
</div> </div>
); );
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 (
<div
className="flex h-full w-full items-center justify-center"
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
}}
>
{pageNumberText}
</div>
);
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 (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* 제목 */}
{showCardTitle && (
<>
<div
className="flex-shrink-0 px-2 py-1 font-semibold"
style={{
fontSize: `${titleFontSize}px`,
color: titleColor,
}}
>
{cardTitle}
</div>
{/* 구분선 */}
<div
className="mx-1 flex-shrink-0 border-b"
style={{ borderColor: component.borderColor || "#e5e7eb" }}
/>
</>
)}
{/* 항목 목록 */}
<div className="flex-1 overflow-auto px-2 py-1">
{cardItems.map((item: { label: string; value: string; fieldName?: string }, index: number) => (
<div key={index} className="flex py-0.5">
<span
className="flex-shrink-0 font-medium"
style={{
width: `${labelWidth}px`,
fontSize: `${labelFontSize}px`,
color: labelColor,
}}
>
{item.label}
</span>
<span
className="flex-1"
style={{
fontSize: `${valueFontSize}px`,
color: valueColor,
}}
>
{getCardItemValue(item)}
</span>
</div>
))}
</div>
</div>
);
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 (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* 항목 목록 */}
<div className="flex-1 overflow-auto px-2 py-1">
{calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, index: number) => {
const itemValue = getCalcItemValue(item);
return (
<div key={index} className="flex items-center justify-between py-1">
<span
className="flex-shrink-0"
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcLabelFontSize}px`,
color: calcLabelColor,
}}
>
{item.label}
</span>
<span
className="text-right"
style={{
fontSize: `${calcValueFontSize}px`,
color: calcValueColor,
}}
>
{formatNumber(itemValue)}
</span>
</div>
);
})}
</div>
{/* 구분선 */}
<div
className="mx-1 flex-shrink-0 border-t"
style={{ borderColor: component.borderColor || "#374151" }}
/>
{/* 결과 */}
<div className="flex items-center justify-between px-2 py-2">
<span
className="font-semibold"
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcResultFontSize}px`,
color: calcLabelColor,
}}
>
{resultLabel}
</span>
<span
className="text-right font-bold"
style={{
fontSize: `${calcResultFontSize}px`,
color: calcResultColor,
}}
>
{formatNumber(calcResult)}
</span>
</div>
</div>
);
default: default:
return <div> </div>; return <div> </div>;
} }

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useDrag } from "react-dnd"; 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 { interface ComponentItem {
type: string; type: string;
@ -12,11 +12,13 @@ interface ComponentItem {
const COMPONENTS: ComponentItem[] = [ const COMPONENTS: ComponentItem[] = [
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> }, { type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
{ type: "table", label: "테이블", icon: <Table className="h-4 w-4" /> }, { type: "table", label: "테이블", icon: <Table className="h-4 w-4" /> },
{ type: "label", label: "레이블", icon: <Tag className="h-4 w-4" /> },
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> }, { type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
{ type: "divider", label: "구분선", icon: <Minus className="h-4 w-4" /> }, { type: "divider", label: "구분선", icon: <Minus className="h-4 w-4" /> },
{ type: "signature", label: "서명란", icon: <PenLine className="h-4 w-4" /> }, { type: "signature", label: "서명란", icon: <PenLine className="h-4 w-4" /> },
{ type: "stamp", label: "도장란", icon: <StampIcon className="h-4 w-4" /> }, { type: "stamp", label: "도장란", icon: <StampIcon className="h-4 w-4" /> },
{ type: "pageNumber", label: "페이지번호", icon: <Hash className="h-4 w-4" /> },
{ type: "card", label: "정보카드", icon: <CreditCard className="h-4 w-4" /> },
{ type: "calculation", label: "계산", icon: <Calculator className="h-4 w-4" /> },
]; ];
function DraggableComponentItem({ type, label, icon }: ComponentItem) { function DraggableComponentItem({ type, label, icon }: ComponentItem) {

View File

@ -76,25 +76,25 @@ export function PageListPanel() {
}; };
return ( return (
<div className="bg-background flex h-full w-64 flex-col border-r"> <div className="bg-background flex h-full w-32 flex-col border-r">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between border-b p-3"> <div className="flex items-center justify-between border-b px-2 py-1.5">
<h3 className="text-sm font-semibold"> </h3> <h3 className="text-[10px] font-semibold"></h3>
<Button size="sm" variant="ghost" onClick={() => addPage()}> <Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => addPage()}>
<Plus className="h-4 w-4" /> <Plus className="h-3 w-3" />
</Button> </Button>
</div> </div>
{/* 페이지 목록 */} {/* 페이지 목록 */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<ScrollArea className="h-full p-2"> <ScrollArea className="h-full p-1">
<div className="space-y-2"> <div className="space-y-1">
{layoutConfig.pages {layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order) .sort((a, b) => a.page_order - b.page_order)
.map((page, index) => ( .map((page, index) => (
<div <div
key={page.page_id} key={page.page_id}
className={`group relative cursor-pointer rounded-md border p-2 transition-all ${ className={`group relative cursor-pointer rounded border p-1.5 transition-all ${
page.page_id === currentPageId page.page_id === currentPageId
? "border-primary bg-primary/10" ? "border-primary bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-accent/50" : "border-border hover:border-primary/50 hover:bg-accent/50"
@ -103,7 +103,7 @@ export function PageListPanel() {
onDragOver={(e) => handleDragOver(e, index)} onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)} onDrop={(e) => handleDrop(e, index)}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
{/* 드래그 핸들 */} {/* 드래그 핸들 */}
<div <div
draggable draggable
@ -115,13 +115,13 @@ export function PageListPanel() {
className="text-muted-foreground cursor-grab opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing" className="text-muted-foreground cursor-grab opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<GripVertical className="h-3 w-3" /> <GripVertical className="h-2.5 w-2.5" />
</div> </div>
{/* 페이지 정보 */} {/* 페이지 정보 */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{editingPageId === page.page_id ? ( {editingPageId === page.page_id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}> <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
<Input <Input
value={editingName} value={editingName}
onChange={(e) => setEditingName(e.target.value)} onChange={(e) => setEditingName(e.target.value)}
@ -129,21 +129,21 @@ export function PageListPanel() {
if (e.key === "Enter") handleSaveEdit(); if (e.key === "Enter") handleSaveEdit();
if (e.key === "Escape") handleCancelEdit(); if (e.key === "Escape") handleCancelEdit();
}} }}
className="h-6 text-xs" className="h-5 text-[10px]"
autoFocus autoFocus
/> />
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={handleSaveEdit}> <Button size="sm" variant="ghost" className="h-4 w-4 p-0" onClick={handleSaveEdit}>
<Check className="h-3 w-3" /> <Check className="h-2.5 w-2.5" />
</Button> </Button>
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={handleCancelEdit}> <Button size="sm" variant="ghost" className="h-4 w-4 p-0" onClick={handleCancelEdit}>
<X className="h-3 w-3" /> <X className="h-2.5 w-2.5" />
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="truncate text-xs font-medium">{page.page_name}</div> <div className="truncate text-[10px] font-medium">{page.page_name}</div>
)} )}
<div className="text-muted-foreground text-[10px]"> <div className="text-muted-foreground text-[8px]">
{page.width}x{page.height}mm {page.components.length} {page.width}x{page.height}mm
</div> </div>
</div> </div>
@ -153,10 +153,10 @@ export function PageListPanel() {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100" className="h-4 w-4 p-0 opacity-0 transition-opacity group-hover:opacity-100"
> >
<span className="sr-only"></span> <span className="sr-only"></span>
<span className="text-sm leading-none"></span> <span className="text-[10px] leading-none"></span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@ -199,9 +199,9 @@ export function PageListPanel() {
</div> </div>
{/* 푸터 */} {/* 푸터 */}
<div className="border-t p-2"> <div className="border-t p-1">
<Button size="sm" variant="outline" className="w-full" onClick={() => addPage()}> <Button size="sm" variant="outline" className="h-6 w-full text-[10px]" onClick={() => addPage()}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-1 h-3 w-3" />
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -201,7 +201,8 @@ export function QueryManager() {
setIsTestRunning({ ...isTestRunning, [query.id]: true }); setIsTestRunning({ ...isTestRunning, [query.id]: true });
try { try {
const testReportId = reportId === "new" ? "TEMP_TEST" : reportId; 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 externalConnectionId = (query as any).externalConnectionId || null;
const queryParams = parameterValues[query.id] || {}; const queryParams = parameterValues[query.id] || {};
@ -264,24 +265,24 @@ export function QueryManager() {
return ( return (
<AccordionItem key={query.id} value={query.id} className="border-b border-gray-200"> <AccordionItem key={query.id} value={query.id} className="border-b border-gray-200">
<AccordionTrigger className="px-0 py-2.5 hover:no-underline"> <div className="flex items-center gap-1">
<div className="flex w-full items-center justify-between pr-2"> <AccordionTrigger className="flex-1 px-0 py-2.5 hover:no-underline">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium">{query.name}</span> <span className="text-sm font-medium">{query.name}</span>
<Badge variant={query.type === "MASTER" ? "default" : "secondary"} className="text-xs"> <Badge variant={query.type === "MASTER" ? "default" : "secondary"} className="text-xs">
{query.type} {query.type}
</Badge> </Badge>
</div> </div>
</AccordionTrigger>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={(e) => handleDeleteQuery(query.id, e)} onClick={(e) => handleDeleteQuery(query.id, e)}
className="h-7 w-7 p-0" className="h-7 w-7 shrink-0 p-0"
> >
<Trash2 className="h-4 w-4 text-red-500" /> <Trash2 className="h-4 w-4 text-red-500" />
</Button> </Button>
</div> </div>
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-1 pr-0 pb-3 pl-0"> <AccordionContent className="space-y-4 pt-1 pr-0 pb-3 pl-0">
{/* 쿼리 이름 */} {/* 쿼리 이름 */}
<div className="space-y-2"> <div className="space-y-2">

View File

@ -65,6 +65,9 @@ export function ReportDesignerCanvas() {
} else if (item.componentType === "stamp") { } else if (item.componentType === "stamp") {
width = 70; width = 70;
height = 70; height = 70;
} else if (item.componentType === "pageNumber") {
width = 100;
height = 30;
} }
// 여백을 px로 변환 (1mm ≈ 3.7795px) // 여백을 px로 변환 (1mm ≈ 3.7795px)
@ -143,6 +146,55 @@ export function ReportDesignerCanvas() {
borderWidth: 0, borderWidth: 0,
borderColor: "#cccccc", 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" && { ...(item.componentType === "table" && {
queryId: undefined, queryId: undefined,
@ -297,13 +349,8 @@ export function ReportDesignerCanvas() {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100"> <div className="flex flex-1 flex-col overflow-hidden bg-gray-100">
{/* 작업 영역 제목 */}
<div className="border-b bg-white px-4 py-2 text-center text-sm font-medium text-gray-700">
{currentPage.page_name} ({currentPage.width} x {currentPage.height}mm)
</div>
{/* 캔버스 스크롤 영역 */} {/* 캔버스 스크롤 영역 */}
<div className="flex flex-1 items-center justify-center overflow-auto p-8"> <div className="flex flex-1 items-center justify-center overflow-auto px-8 pt-[280px] pb-8">
{/* 눈금자와 캔버스를 감싸는 컨테이너 */} {/* 눈금자와 캔버스를 감싸는 컨테이너 */}
<div className="inline-flex flex-col"> <div className="inline-flex flex-col">
{/* 좌상단 코너 + 가로 눈금자 */} {/* 좌상단 코너 + 가로 눈금자 */}

View File

@ -5,6 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -27,6 +28,7 @@ export function ReportDesignerRightPanel() {
currentPage, currentPage,
currentPageId, currentPageId,
updatePageSettings, updatePageSettings,
getQueryResult,
} = context; } = context;
const [activeTab, setActiveTab] = useState<string>("properties"); const [activeTab, setActiveTab] = useState<string>("properties");
const [uploadingImage, setUploadingImage] = useState(false); const [uploadingImage, setUploadingImage] = useState(false);
@ -918,6 +920,717 @@ export function ReportDesignerRightPanel() {
</Card> </Card>
)} )}
{/* 페이지 번호 설정 */}
{selectedComponent.type === "pageNumber" && (
<Card className="mt-4 border-purple-200 bg-purple-50">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-purple-900"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.pageNumberFormat || "number"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
pageNumberFormat: value as "number" | "numberTotal" | "koreanNumber",
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number"> (1, 2, 3...)</SelectItem>
<SelectItem value="numberTotal">/ (1 / 3)</SelectItem>
<SelectItem value="koreanNumber"> (1 )</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
)}
{/* 카드 컴포넌트 설정 */}
{selectedComponent.type === "card" && (
<Card className="mt-4 border-teal-200 bg-teal-50">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-teal-900"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 제목 표시 여부 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="showCardTitle"
checked={selectedComponent.showCardTitle !== false}
onChange={(e) =>
updateComponent(selectedComponent.id, {
showCardTitle: e.target.checked,
})
}
className="h-4 w-4"
/>
<Label htmlFor="showCardTitle" className="text-xs">
</Label>
</div>
{/* 제목 텍스트 */}
{selectedComponent.showCardTitle !== false && (
<div>
<Label className="text-xs"> </Label>
<Input
type="text"
value={selectedComponent.cardTitle || ""}
onChange={(e) =>
updateComponent(selectedComponent.id, {
cardTitle: e.target.value,
})
}
placeholder="정보 카드"
className="h-8"
/>
</div>
)}
{/* 라벨 너비 */}
<div>
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={selectedComponent.labelWidth || 80}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelWidth: Number(e.target.value),
})
}
min={40}
max={200}
className="h-8"
/>
</div>
{/* 테두리 표시 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="showCardBorder"
checked={selectedComponent.showCardBorder !== false}
onChange={(e) =>
updateComponent(selectedComponent.id, {
showCardBorder: e.target.checked,
borderWidth: e.target.checked ? 1 : 0,
})
}
className="h-4 w-4"
/>
<Label htmlFor="showCardBorder" className="text-xs">
</Label>
</div>
{/* 폰트 크기 설정 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.titleFontSize || 14}
onChange={(e) =>
updateComponent(selectedComponent.id, {
titleFontSize: Number(e.target.value),
})
}
min={10}
max={24}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.labelFontSize || 13}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelFontSize: Number(e.target.value),
})
}
min={10}
max={20}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.valueFontSize || 13}
onChange={(e) =>
updateComponent(selectedComponent.id, {
valueFontSize: Number(e.target.value),
})
}
min={10}
max={20}
className="h-8"
/>
</div>
</div>
{/* 색상 설정 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.titleColor || "#1e40af"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
titleColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.labelColor || "#374151"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.valueColor || "#000000"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
valueColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
</div>
{/* 항목 목록 관리 */}
<div className="mt-4 border-t pt-3">
<div className="mb-2 flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<Button
size="sm"
variant="outline"
className="h-6 text-xs"
onClick={() => {
const currentItems = selectedComponent.cardItems || [];
updateComponent(selectedComponent.id, {
cardItems: [
...currentItems,
{ label: `항목${currentItems.length + 1}`, value: "", fieldName: "" },
],
});
}}
>
+
</Button>
</div>
{/* 쿼리 선택 (데이터 바인딩용) */}
<div className="mb-2">
<Label className="text-xs"> ()</Label>
<Select
value={selectedComponent.queryId || "none"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
queryId: value === "none" ? undefined : value,
})
}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="쿼리 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{queries.map((q) => (
<SelectItem key={q.id} value={q.id}>
{q.name} ({q.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 항목 리스트 */}
<div className="max-h-48 space-y-2 overflow-y-auto">
{(selectedComponent.cardItems || []).map(
(item: { label: string; value: string; fieldName?: string }, index: number) => (
<div key={index} className="rounded border bg-white p-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
onClick={() => {
const currentItems = [...(selectedComponent.cardItems || [])];
currentItems.splice(index, 1);
updateComponent(selectedComponent.id, {
cardItems: currentItems,
});
}}
>
x
</Button>
</div>
<div className="grid grid-cols-2 gap-1">
<div>
<Label className="text-[10px]"></Label>
<Input
type="text"
value={item.label}
onChange={(e) => {
const currentItems = [...(selectedComponent.cardItems || [])];
currentItems[index] = { ...item, label: e.target.value };
updateComponent(selectedComponent.id, {
cardItems: currentItems,
});
}}
className="h-6 text-xs"
placeholder="항목명"
/>
</div>
{selectedComponent.queryId ? (
<div>
<Label className="text-[10px]"></Label>
<Select
value={item.fieldName || "none"}
onValueChange={(value) => {
const currentItems = [...(selectedComponent.cardItems || [])];
currentItems[index] = {
...item,
fieldName: value === "none" ? "" : value,
};
updateComponent(selectedComponent.id, {
cardItems: currentItems,
});
}}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{(() => {
const query = queries.find((q) => q.id === selectedComponent.queryId);
const result = query ? getQueryResult(query.id) : null;
if (result && result.fields) {
return result.fields.map((field: string) => (
<SelectItem key={field} value={field}>
{field}
</SelectItem>
));
}
return null;
})()}
</SelectContent>
</Select>
</div>
) : (
<div>
<Label className="text-[10px]"></Label>
<Input
type="text"
value={item.value}
onChange={(e) => {
const currentItems = [...(selectedComponent.cardItems || [])];
currentItems[index] = { ...item, value: e.target.value };
updateComponent(selectedComponent.id, {
cardItems: currentItems,
});
}}
className="h-6 text-xs"
placeholder="내용"
/>
</div>
)}
</div>
</div>
),
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* 계산 컴포넌트 설정 */}
{selectedComponent.type === "calculation" && (
<Card className="mt-4 border-orange-200 bg-orange-50">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-orange-900"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 결과 라벨 */}
<div>
<Label className="text-xs"> </Label>
<Input
type="text"
value={selectedComponent.resultLabel || "합계"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
resultLabel: e.target.value,
})
}
placeholder="합계 금액"
className="h-8"
/>
</div>
{/* 라벨 너비 */}
<div>
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={selectedComponent.labelWidth || 120}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelWidth: Number(e.target.value),
})
}
min={60}
max={200}
className="h-8"
/>
</div>
{/* 숫자 포맷 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.numberFormat || "currency"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
numberFormat: value as "none" | "comma" | "currency",
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="comma"> </SelectItem>
<SelectItem value="currency"> ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* 통화 접미사 */}
{selectedComponent.numberFormat === "currency" && (
<div>
<Label className="text-xs"> </Label>
<Input
type="text"
value={selectedComponent.currencySuffix || "원"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
currencySuffix: e.target.value,
})
}
placeholder="원"
className="h-8"
/>
</div>
)}
{/* 폰트 크기 설정 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.labelFontSize || 13}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelFontSize: Number(e.target.value),
})
}
min={10}
max={20}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.valueFontSize || 13}
onChange={(e) =>
updateComponent(selectedComponent.id, {
valueFontSize: Number(e.target.value),
})
}
min={10}
max={20}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.resultFontSize || 16}
onChange={(e) =>
updateComponent(selectedComponent.id, {
resultFontSize: Number(e.target.value),
})
}
min={12}
max={24}
className="h-8"
/>
</div>
</div>
{/* 색상 설정 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.labelColor || "#374151"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.valueColor || "#000000"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
valueColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.resultColor || "#2563eb"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
resultColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
</div>
{/* 계산 항목 목록 관리 */}
<div className="mt-4 border-t pt-3">
<div className="mb-2 flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<Button
size="sm"
variant="outline"
className="h-6 text-xs"
onClick={() => {
const currentItems = selectedComponent.calcItems || [];
updateComponent(selectedComponent.id, {
calcItems: [
...currentItems,
{
label: `항목${currentItems.length + 1}`,
value: 0,
operator: "+" as const,
fieldName: "",
},
],
});
}}
>
+
</Button>
</div>
{/* 쿼리 선택 (데이터 바인딩용) */}
<div className="mb-2">
<Label className="text-xs"> ()</Label>
<Select
value={selectedComponent.queryId || "none"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
queryId: value === "none" ? undefined : value,
})
}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="쿼리 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{queries.map((q) => (
<SelectItem key={q.id} value={q.id}>
{q.name} ({q.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 항목 리스트 */}
<div className="max-h-48 space-y-2 overflow-y-auto">
{(selectedComponent.calcItems || []).map((item, index: number) => (
<div key={index} className="rounded border bg-white p-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
onClick={() => {
const currentItems = [...(selectedComponent.calcItems || [])];
currentItems.splice(index, 1);
updateComponent(selectedComponent.id, {
calcItems: currentItems,
});
}}
>
x
</Button>
</div>
<div className={`grid gap-1 ${index === 0 ? "grid-cols-1" : "grid-cols-3"}`}>
<div className={index === 0 ? "" : "col-span-2"}>
<Label className="text-[10px]"></Label>
<Input
type="text"
value={item.label}
onChange={(e) => {
const currentItems = [...(selectedComponent.calcItems || [])];
currentItems[index] = { ...currentItems[index], label: e.target.value };
updateComponent(selectedComponent.id, {
calcItems: currentItems,
});
}}
className="h-6 text-xs"
placeholder="항목명"
/>
</div>
{/* 두 번째 항목부터 연산자 표시 */}
{index > 0 && (
<div>
<Label className="text-[10px]"></Label>
<Select
value={item.operator}
onValueChange={(value) => {
const currentItems = [...(selectedComponent.calcItems || [])];
currentItems[index] = {
...currentItems[index],
operator: value as "+" | "-" | "x" | "÷",
};
updateComponent(selectedComponent.id, {
calcItems: currentItems,
});
}}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="+">+</SelectItem>
<SelectItem value="-">-</SelectItem>
<SelectItem value="x">x</SelectItem>
<SelectItem value="÷">÷</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<div className="mt-1">
{selectedComponent.queryId ? (
<div>
<Label className="text-[10px]"></Label>
<Select
value={item.fieldName || "none"}
onValueChange={(value) => {
const currentItems = [...(selectedComponent.calcItems || [])];
currentItems[index] = {
...currentItems[index],
fieldName: value === "none" ? "" : value,
};
updateComponent(selectedComponent.id, {
calcItems: currentItems,
});
}}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{(() => {
const query = queries.find((q) => q.id === selectedComponent.queryId);
const result = query ? getQueryResult(query.id) : null;
if (result && result.fields) {
return result.fields.map((field: string) => (
<SelectItem key={field} value={field}>
{field}
</SelectItem>
));
}
return null;
})()}
</SelectContent>
</Select>
</div>
) : (
<div>
<Label className="text-[10px]"></Label>
<Input
type="number"
value={item.value}
onChange={(e) => {
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"
/>
</div>
)}
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */} {/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
{(selectedComponent.type === "text" || {(selectedComponent.type === "text" ||
selectedComponent.type === "label" || selectedComponent.type === "label" ||
@ -1120,16 +1833,16 @@ export function ReportDesignerRightPanel() {
{/* 기본값 (텍스트/라벨만) */} {/* 기본값 (텍스트/라벨만) */}
{(selectedComponent.type === "text" || selectedComponent.type === "label") && ( {(selectedComponent.type === "text" || selectedComponent.type === "label") && (
<div> <div>
<Label className="text-xs"></Label> <Label className="text-xs"> </Label>
<Input <Textarea
value={selectedComponent.defaultValue || ""} value={selectedComponent.defaultValue || ""}
onChange={(e) => onChange={(e) =>
updateComponent(selectedComponent.id, { updateComponent(selectedComponent.id, {
defaultValue: e.target.value, defaultValue: e.target.value,
}) })
} }
placeholder="데이터가 없을 때 표시할 값" placeholder="텍스트 내용 (엔터로 줄바꿈 가능)"
className="h-8" className="min-h-[80px] resize-y"
/> />
</div> </div>
)} )}

View File

@ -13,21 +13,6 @@ import { Printer, FileDown, FileText } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { useState } from "react"; import { useState } from "react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
// @ts-ignore - docx 라이브러리 타입 이슈
import {
Document,
Packer,
Paragraph,
TextRun,
Table,
TableCell,
TableRow,
WidthType,
ImageRun,
AlignmentType,
VerticalAlign,
convertInchesToTwip,
} from "docx";
import { getFullImageUrl } from "@/lib/api/client"; import { getFullImageUrl } from "@/lib/api/client";
interface ReportPreviewModalProps { interface ReportPreviewModalProps {
@ -73,6 +58,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
pageWidth: number, pageWidth: number,
pageHeight: number, pageHeight: number,
backgroundColor: string, backgroundColor: string,
pageIndex: number = 0,
totalPages: number = 1,
): string => { ): string => {
const componentsHTML = pageComponents const componentsHTML = pageComponents
.map((component) => { .map((component) => {
@ -82,7 +69,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
// Text/Label 컴포넌트 // Text/Label 컴포넌트
if (component.type === "text" || component.type === "label") { if (component.type === "text" || component.type === "label") {
const displayValue = getComponentValue(component); const displayValue = getComponentValue(component);
content = `<div style="font-size: ${component.fontSize || 13}px; color: ${component.fontColor || "#000000"}; font-weight: ${component.fontWeight || "normal"}; text-align: ${component.textAlign || "left"};">${displayValue}</div>`; content = `<div style="font-size: ${component.fontSize || 13}px; color: ${component.fontColor || "#000000"}; font-weight: ${component.fontWeight || "normal"}; text-align: ${component.textAlign || "left"}; white-space: pre-wrap;">${displayValue}</div>`;
} }
// Image 컴포넌트 // Image 컴포넌트
@ -154,6 +141,163 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
</div>`; </div>`;
} }
// PageNumber 컴포넌트
else if (component.type === "pageNumber") {
const format = component.pageNumberFormat || "number";
let pageNumberText = "";
switch (format) {
case "number":
pageNumberText = `${pageIndex + 1}`;
break;
case "numberTotal":
pageNumberText = `${pageIndex + 1} / ${totalPages}`;
break;
case "koreanNumber":
pageNumberText = `${pageIndex + 1} 페이지`;
break;
default:
pageNumberText = `${pageIndex + 1}`;
}
content = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; font-size: ${component.fontSize}px; color: ${component.fontColor}; font-weight: ${component.fontWeight}; text-align: ${component.textAlign};">${pageNumberText}</div>`;
}
// 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 = 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 borderColor = component.borderColor || "#e5e7eb";
// 쿼리 바인딩된 값 가져오기
const getCardValue = (item: { label: string; value: string; fieldName?: string }) => {
if (item.fieldName && 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;
};
const itemsHtml = cardItems
.map(
(item: { label: string; value: string; fieldName?: string }) => `
<div style="display: flex; padding: 2px 0;">
<span style="width: ${labelWidth}px; flex-shrink: 0; font-size: ${labelFontSize}px; color: ${labelColor}; font-weight: 500;">${item.label}</span>
<span style="flex: 1; font-size: ${valueFontSize}px; color: ${valueColor};">${getCardValue(item)}</span>
</div>
`
)
.join("");
content = `
<div style="display: flex; flex-direction: column; height: 100%; overflow: hidden;">
${
showCardTitle
? `
<div style="flex-shrink: 0; padding: 4px 8px; font-size: ${titleFontSize}px; font-weight: 600; color: ${titleColor};">
${cardTitle}
</div>
<div style="flex-shrink: 0; margin: 0 4px; border-bottom: 1px solid ${borderColor};"></div>
`
: ""
}
<div style="flex: 1; padding: 4px 8px; overflow: auto;">
${itemsHtml}
</div>
</div>`;
}
// 계산 컴포넌트
else if (component.type === "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 borderColor = component.borderColor || "#374151";
// 숫자 포맷팅 함수
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 && 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;
};
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
let calcResult = 0;
if (calcItems.length > 0) {
// 첫 번째 항목은 기준값
calcResult = 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 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 itemsHtml = calcItems
.map((item: { label: string; value: number | string; operator: string; fieldName?: string }) => {
const itemValue = getCalcItemValue(item);
return `
<div style="display: flex; justify-content: space-between; padding: 4px 8px;">
<span style="width: ${calcLabelWidth}px; font-size: ${calcLabelFontSize}px; color: ${calcLabelColor};">${item.label}</span>
<span style="font-size: ${calcValueFontSize}px; color: ${calcValueColor}; text-align: right;">${formatNumber(itemValue)}</span>
</div>
`;
})
.join("");
content = `
<div style="display: flex; flex-direction: column; height: 100%;">
<div style="flex: 1;">
${itemsHtml}
</div>
<div style="border-top: 1px solid ${borderColor}; margin: 4px 8px;"></div>
<div style="display: flex; justify-content: space-between; padding: 4px 8px;">
<span style="width: ${calcLabelWidth}px; font-size: ${calcResultFontSize}px; font-weight: 600; color: ${calcLabelColor};">${resultLabel}</span>
<span style="font-size: ${calcResultFontSize}px; font-weight: 700; color: ${calcResultColor}; text-align: right;">${formatNumber(calcResult)}</span>
</div>
</div>`;
}
// Table 컴포넌트 // Table 컴포넌트
else if (component.type === "table" && queryResult && queryResult.rows.length > 0) { else if (component.type === "table" && queryResult && queryResult.rows.length > 0) {
const columns = const columns =
@ -204,9 +348,20 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
// 모든 페이지 HTML 생성 (인쇄/PDF용) // 모든 페이지 HTML 생성 (인쇄/PDF용)
const generatePrintHTML = (): string => { const generatePrintHTML = (): string => {
const pagesHTML = layoutConfig.pages const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order);
.sort((a, b) => a.page_order - b.page_order) const totalPages = sortedPages.length;
.map((page) => generatePageHTML(page.components, page.width, page.height, page.background_color))
const pagesHTML = sortedPages
.map((page, pageIndex) =>
generatePageHTML(
Array.isArray(page.components) ? page.components : [],
page.width,
page.height,
page.background_color,
pageIndex,
totalPages,
),
)
.join('<div style="page-break-after: always;"></div>'); .join('<div style="page-break-after: always;"></div>');
return ` return `
@ -282,270 +437,94 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
}); });
}; };
// Base64를 Uint8Array로 변환 // 이미지 URL을 Base64로 변환
const base64ToUint8Array = (base64: string): Uint8Array => { const imageUrlToBase64 = async (url: string): Promise<string> => {
const base64Data = base64.split(",")[1] || base64;
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
};
// 컴포넌트를 TableCell로 변환
const createTableCell = (component: any, pageWidth: number, widthPercent?: number): TableCell | null => {
const cellWidth = widthPercent || 100;
if (component.type === "text" || component.type === "label") {
const value = getComponentValue(component);
return new TableCell({
children: [
new Paragraph({
children: [
new TextRun({
text: value,
size: (component.fontSize || 13) * 2,
color: component.fontColor?.replace("#", "") || "000000",
bold: component.fontWeight === "bold",
}),
],
alignment:
component.textAlign === "center"
? AlignmentType.CENTER
: component.textAlign === "right"
? AlignmentType.RIGHT
: AlignmentType.LEFT,
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
verticalAlign: VerticalAlign.CENTER,
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
} else if (component.type === "signature" || component.type === "stamp") {
if (component.imageUrl) {
try { try {
const imageData = base64ToUint8Array(component.imageUrl); // 이미 Base64인 경우 그대로 반환
return new TableCell({ if (url.startsWith("data:")) {
children: [ return url;
new Paragraph({
children: [
new ImageRun({
data: imageData,
transformation: {
width: component.width || 150,
height: component.height || 50,
},
}),
],
alignment: AlignmentType.CENTER,
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
verticalAlign: VerticalAlign.CENTER,
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
} catch {
return new TableCell({
children: [
new Paragraph({
children: [
new TextRun({
text: `[${component.type === "signature" ? "서명" : "도장"}]`,
size: 24,
}),
],
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
}
}
} else if (component.type === "table" && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows.length > 0) {
const headerCells = queryResult.fields.map(
(field) =>
new TableCell({
children: [new Paragraph({ text: field })],
width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE },
}),
);
const dataRows = queryResult.rows.map(
(row) =>
new TableRow({
children: queryResult.fields.map(
(field) =>
new TableCell({
children: [new Paragraph({ text: String(row[field] ?? "") })],
}),
),
}),
);
const table = new Table({
rows: [new TableRow({ children: headerCells }), ...dataRows],
width: { size: 100, type: WidthType.PERCENTAGE },
});
return new TableCell({
children: [table],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
}
} }
return null; // 서버 이미지 URL을 fetch하여 Base64로 변환
const fullUrl = getFullImageUrl(url);
const response = await fetch(fullUrl);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (error) {
console.error("이미지 변환 실패:", error);
return "";
}
}; };
// WORD 다운로드 // WORD 다운로드 (백엔드 API 사용 - 컴포넌트 데이터 전송)
const handleDownloadWord = async () => { const handleDownloadWord = async () => {
setIsExporting(true); setIsExporting(true);
try { try {
// 페이지별로 섹션 생성 toast({
const sections = layoutConfig.pages title: "처리 중",
.sort((a, b) => a.page_order - b.page_order) description: "WORD 파일을 생성하고 있습니다...",
.map((page) => {
// 페이지 크기 설정 (A4 기준)
const pageWidth = convertInchesToTwip(8.27); // A4 width in inches
const pageHeight = convertInchesToTwip(11.69); // A4 height in inches
const marginTop = convertInchesToTwip(page.margins.top / 96); // px to inches (96 DPI)
const marginBottom = convertInchesToTwip(page.margins.bottom / 96);
const marginLeft = convertInchesToTwip(page.margins.left / 96);
const marginRight = convertInchesToTwip(page.margins.right / 96);
// 페이지 내 컴포넌트를 Y좌표 기준으로 정렬
const sortedComponents = [...page.components].sort((a, b) => {
// Y좌표 우선, 같으면 X좌표
if (Math.abs(a.y - b.y) < 5) {
return a.x - b.x;
}
return a.y - b.y;
}); });
// 컴포넌트들을 행으로 그룹화 (같은 Y 범위 = 같은 행) // 이미지를 Base64로 변환하여 컴포넌트 데이터에 포함
const rows: Array<Array<(typeof sortedComponents)[0]>> = []; const pagesWithBase64 = await Promise.all(
const rowTolerance = 20; // Y 좌표 허용 오차 layoutConfig.pages.map(async (page) => {
const componentsWithBase64 = await Promise.all(
for (const component of sortedComponents) { (Array.isArray(page.components) ? page.components : []).map(async (component) => {
const existingRow = rows.find((row) => Math.abs(row[0].y - component.y) < rowTolerance); // 이미지가 있는 컴포넌트는 Base64로 변환
if (existingRow) { if (component.imageUrl) {
existingRow.push(component); try {
} else { const base64 = await imageUrlToBase64(component.imageUrl);
rows.push([component]); return { ...component, imageBase64: base64 };
} catch {
return component;
} }
} }
return component;
// 각 행 내에서 X좌표로 정렬
rows.forEach((row) => row.sort((a, b) => a.x - b.x));
// 페이지 전체를 큰 테이블로 구성 (레이아웃 제어용)
const tableRows: TableRow[] = [];
for (const row of rows) {
if (row.length === 1) {
// 단일 컴포넌트 - 전체 너비 사용
const component = row[0];
const cell = createTableCell(component, pageWidth);
if (cell) {
tableRows.push(
new TableRow({
children: [cell],
height: { value: component.height * 15, rule: 1 }, // 최소 높이 설정
}), }),
); );
} return { ...page, components: componentsWithBase64 };
} else {
// 여러 컴포넌트 - 가로 배치
const cells: TableCell[] = [];
const totalWidth = row.reduce((sum, c) => sum + c.width, 0);
for (const component of row) {
const widthPercent = (component.width / totalWidth) * 100;
const cell = createTableCell(component, pageWidth, widthPercent);
if (cell) {
cells.push(cell);
}
}
if (cells.length > 0) {
const maxHeight = Math.max(...row.map((c) => c.height));
tableRows.push(
new TableRow({
children: cells,
height: { value: maxHeight * 15, rule: 1 },
}), }),
); );
// 쿼리 결과 수집
const queryResults: Record<string, { fields: string[]; rows: Record<string, unknown>[] }> = {};
for (const page of layoutConfig.pages) {
const pageComponents = Array.isArray(page.components) ? page.components : [];
for (const component of pageComponents) {
if (component.queryId) {
const result = getQueryResult(component.queryId);
if (result) {
queryResults[component.queryId] = result;
}
} }
} }
} }
return {
properties: {
page: {
width: pageWidth,
height: pageHeight,
margin: {
top: marginTop,
bottom: marginBottom,
left: marginLeft,
right: marginRight,
},
},
},
children:
tableRows.length > 0
? [
new Table({
rows: tableRows,
width: { size: 100, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
insideHorizontal: { style: 0, size: 0, color: "FFFFFF" },
insideVertical: { style: 0, size: 0, color: "FFFFFF" },
},
}),
]
: [new Paragraph({ text: "" })],
};
});
// 문서 생성
const doc = new Document({
sections,
});
// Blob 생성 및 다운로드
const blob = await Packer.toBlob(doc);
const fileName = reportDetail?.report?.report_name_kor || "리포트"; const fileName = reportDetail?.report?.report_name_kor || "리포트";
const timestamp = new Date().toISOString().slice(0, 10);
// 백엔드 API 호출 (컴포넌트 데이터 전송)
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.post(
"/admin/reports/export-word",
{
layoutConfig: { ...layoutConfig, pages: pagesWithBase64 },
queryResults,
fileName,
},
{ responseType: "blob" },
);
// Blob 다운로드
const blob = new Blob([response.data], {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
const timestamp = new Date().toISOString().slice(0, 10);
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
@ -558,6 +537,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
description: "WORD 파일이 다운로드되었습니다.", description: "WORD 파일이 다운로드되었습니다.",
}); });
} catch (error) { } catch (error) {
console.error("WORD 변환 오류:", error);
const errorMessage = error instanceof Error ? error.message : "WORD 생성에 실패했습니다."; const errorMessage = error instanceof Error ? error.message : "WORD 생성에 실패했습니다.";
toast({ toast({
title: "오류", title: "오류",
@ -586,11 +566,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
.sort((a, b) => a.page_order - b.page_order) .sort((a, b) => a.page_order - b.page_order)
.map((page) => ( .map((page) => (
<div key={page.page_id} className="relative"> <div key={page.page_id} className="relative">
{/* 페이지 번호 라벨 */}
<div className="mb-2 text-center text-xs text-gray-500">
{page.page_order + 1} - {page.page_name}
</div>
{/* 페이지 컨텐츠 */} {/* 페이지 컨텐츠 */}
<div <div
className="relative mx-auto shadow-lg" className="relative mx-auto shadow-lg"
@ -600,7 +575,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
backgroundColor: page.background_color, backgroundColor: page.background_color,
}} }}
> >
{page.components.map((component) => { {(Array.isArray(page.components) ? page.components : []).map((component) => {
const displayValue = getComponentValue(component); const displayValue = getComponentValue(component);
const queryResult = component.queryId ? getQueryResult(component.queryId) : null; const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
@ -627,6 +602,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
color: component.fontColor, color: component.fontColor,
fontWeight: component.fontWeight, fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right", textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap",
}} }}
> >
{displayValue} {displayValue}
@ -640,6 +616,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
color: component.fontColor, color: component.fontColor,
fontWeight: component.fontWeight, fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right", textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap",
}} }}
> >
{displayValue} {displayValue}
@ -886,6 +863,256 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
</div> </div>
</div> </div>
)} )}
{component.type === "pageNumber" && (() => {
const format = component.pageNumberFormat || "number";
const pageIndex = layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.findIndex((p) => p.page_id === page.page_id);
const totalPages = layoutConfig.pages.length;
let pageNumberText = "";
switch (format) {
case "number":
pageNumberText = `${pageIndex + 1}`;
break;
case "numberTotal":
pageNumberText = `${pageIndex + 1} / ${totalPages}`;
break;
case "koreanNumber":
pageNumberText = `${pageIndex + 1} 페이지`;
break;
default:
pageNumberText = `${pageIndex + 1}`;
}
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
}}
>
{pageNumberText}
</div>
);
})()}
{/* Card 컴포넌트 */}
{component.type === "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 borderColor = component.borderColor || "#e5e7eb";
// 쿼리 바인딩된 값 가져오기
const getCardValue = (item: { label: string; value: string; fieldName?: string }) => {
if (item.fieldName && component.queryId) {
const qResult = getQueryResult(component.queryId);
if (qResult && 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;
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
}}
>
{showCardTitle && (
<>
<div
style={{
flexShrink: 0,
padding: "4px 8px",
fontSize: `${titleFontSize}px`,
fontWeight: 600,
color: titleColor,
}}
>
{cardTitle}
</div>
<div
style={{
flexShrink: 0,
margin: "0 4px",
borderBottom: `1px solid ${borderColor}`,
}}
/>
</>
)}
<div style={{ flex: 1, padding: "4px 8px", overflow: "auto" }}>
{cardItems.map((item: { label: string; value: string; fieldName?: string }, idx: number) => (
<div key={idx} style={{ display: "flex", padding: "2px 0" }}>
<span
style={{
width: `${labelWidth}px`,
flexShrink: 0,
fontSize: `${labelFontSize}px`,
color: labelColor,
fontWeight: 500,
}}
>
{item.label}
</span>
<span
style={{
flex: 1,
fontSize: `${valueFontSize}px`,
color: valueColor,
}}
>
{getCardValue(item)}
</span>
</div>
))}
</div>
</div>
);
})()}
{/* 계산 컴포넌트 */}
{component.type === "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 borderColor = component.borderColor || "#374151";
// 숫자 포맷팅 함수
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 qResult = getQueryResult(component.queryId);
if (qResult && 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 = 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 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;
}
}
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<div style={{ flex: 1 }}>
{calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, idx: number) => {
const itemValue = getCalcItemValue(item);
return (
<div key={idx} style={{ display: "flex", justifyContent: "space-between", padding: "4px 8px" }}>
<span
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcLabelFontSize}px`,
color: calcLabelColor,
}}
>
{item.label}
</span>
<span
style={{
fontSize: `${calcValueFontSize}px`,
color: calcValueColor,
textAlign: "right",
}}
>
{formatNumber(itemValue)}
</span>
</div>
);
})}
</div>
<div style={{ borderTop: `1px solid ${borderColor}`, margin: "4px 8px" }} />
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 8px" }}>
<span
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcResultFontSize}px`,
fontWeight: 600,
color: calcLabelColor,
}}
>
{resultLabel}
</span>
<span
style={{
fontSize: `${calcResultFontSize}px`,
fontWeight: 700,
color: calcResultColor,
textAlign: "right",
}}
>
{formatNumber(calcResult)}
</span>
</div>
</div>
);
})()}
</div> </div>
); );
})} })}

View File

@ -6,7 +6,6 @@ import { Trash2, Loader2, RefreshCw } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { reportApi } from "@/lib/api/reportApi"; import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
interface Template { interface Template {
template_id: string; template_id: string;
@ -17,7 +16,6 @@ interface Template {
export function TemplatePalette() { export function TemplatePalette() {
const { applyTemplate } = useReportDesigner(); const { applyTemplate } = useReportDesigner();
const [systemTemplates, setSystemTemplates] = useState<Template[]>([]);
const [customTemplates, setCustomTemplates] = useState<Template[]>([]); const [customTemplates, setCustomTemplates] = useState<Template[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
@ -28,7 +26,6 @@ export function TemplatePalette() {
try { try {
const response = await reportApi.getTemplates(); const response = await reportApi.getTemplates();
if (response.success && response.data) { if (response.success && response.data) {
setSystemTemplates(Array.isArray(response.data.system) ? response.data.system : []);
setCustomTemplates(Array.isArray(response.data.custom) ? response.data.custom : []); setCustomTemplates(Array.isArray(response.data.custom) ? response.data.custom : []);
} }
} catch (error) { } catch (error) {
@ -79,31 +76,10 @@ export function TemplatePalette() {
}; };
return ( return (
<div className="space-y-4">
{/* 시스템 템플릿 (DB에서 조회) */}
{systemTemplates.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-gray-600"> 릿</p>
</div>
{systemTemplates.map((template) => (
<Button
key={template.template_id}
variant="outline"
size="sm"
className="w-full justify-start gap-2 text-sm"
onClick={() => handleApplyTemplate(template.template_id)}
>
<span>{template.template_name_kor}</span>
</Button>
))}
</div>
)}
{/* 사용자 정의 템플릿 */} {/* 사용자 정의 템플릿 */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-end">
<p className="text-xs font-semibold text-gray-600"> 릿</p>
<Button variant="ghost" size="sm" onClick={fetchTemplates} disabled={isLoading} className="h-6 w-6 p-0"> <Button variant="ghost" size="sm" onClick={fetchTemplates} disabled={isLoading} className="h-6 w-6 p-0">
<RefreshCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} /> <RefreshCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
</Button> </Button>

View File

@ -976,6 +976,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const groupedDataProp = groupData.length > 0 ? groupData : undefined; const groupedDataProp = groupData.length > 0 ? groupData : undefined;
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
// 최상위 컴포넌트 또는 조건부 컨테이너 내부 화면에 universal-form-modal이 있는지 확인
const hasUniversalFormModal = screenData.components.some(
(c) => {
// 최상위에 universal-form-modal이 있는 경우
if (c.componentType === "universal-form-modal") return true;
// 조건부 컨테이너 내부에 universal-form-modal이 있는 경우
// (조건부 컨테이너가 있으면 내부 화면에서 universal-form-modal을 사용하는 것으로 가정)
if (c.componentType === "conditional-container") return true;
return false;
}
);
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가 // 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
const enrichedFormData = { const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData), ...(groupData.length > 0 ? groupData[0] : formData),
@ -1024,7 +1037,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
id: modalState.screenId!, id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName, tableName: screenData.screenInfo?.tableName,
}} }}
onSave={handleSave} // 🆕 UniversalFormModal이 있으면 onSave 전달 안 함 (자체 저장 로직 사용)
// ModalRepeaterTable만 있으면 기존대로 onSave 전달 (호환성 유지)
onSave={hasUniversalFormModal ? undefined : handleSave}
isInModal={true} isInModal={true}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달 // 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupedDataProp} groupedData={groupedDataProp}

View File

@ -13,7 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { GripVertical, Eye, EyeOff } from "lucide-react"; import { GripVertical, Eye, EyeOff, Lock } from "lucide-react";
import { ColumnVisibility } from "@/types/table-options"; import { ColumnVisibility } from "@/types/table-options";
interface Props { interface Props {
@ -30,6 +30,7 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]); const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null); const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
// 테이블 정보 로드 // 테이블 정보 로드
useEffect(() => { useEffect(() => {
@ -42,6 +43,8 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
order: 0, order: 0,
})) }))
); );
// 현재 틀고정 컬럼 수 로드
setFrozenColumnCount(table.frozenColumnCount ?? 0);
} }
}, [table]); }, [table]);
@ -94,6 +97,11 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
table.onColumnOrderChange(newOrder); table.onColumnOrderChange(newOrder);
} }
// 틀고정 컬럼 수 변경 콜백 호출
if (table?.onFrozenColumnCountChange) {
table.onFrozenColumnCountChange(frozenColumnCount);
}
onClose(); onClose();
}; };
@ -107,9 +115,18 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
order: 0, order: 0,
})) }))
); );
setFrozenColumnCount(0);
} }
}; };
// 틀고정 컬럼 수 변경 핸들러
const handleFrozenColumnCountChange = (value: string) => {
const count = parseInt(value) || 0;
// 최대값은 표시 가능한 컬럼 수
const maxCount = localColumns.filter((col) => col.visible).length;
setFrozenColumnCount(Math.min(Math.max(0, count), maxCount));
};
const visibleCount = localColumns.filter((col) => col.visible).length; const visibleCount = localColumns.filter((col) => col.visible).length;
return ( return (
@ -126,11 +143,34 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
</DialogHeader> </DialogHeader>
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4">
{/* 상태 표시 */} {/* 상태 표시 및 틀고정 설정 */}
<div className="flex items-center justify-between rounded-lg border bg-muted/50 p-3"> <div className="flex flex-col gap-3 rounded-lg border bg-muted/50 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div className="text-xs text-muted-foreground sm:text-sm"> <div className="text-xs text-muted-foreground sm:text-sm">
{visibleCount}/{localColumns.length} {visibleCount}/{localColumns.length}
</div> </div>
{/* 틀고정 설정 */}
<div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-muted-foreground" />
<Label className="text-xs text-muted-foreground whitespace-nowrap">
:
</Label>
<Input
type="number"
value={frozenColumnCount}
onChange={(e) => handleFrozenColumnCountChange(e.target.value)}
className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
min={0}
max={visibleCount}
placeholder="0"
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">
</span>
</div>
</div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -148,6 +188,12 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
const columnMeta = table?.columns.find( const columnMeta = table?.columns.find(
(c) => c.columnName === col.columnName (c) => c.columnName === col.columnName
); );
// 표시 가능한 컬럼 중 몇 번째인지 계산 (틀고정 표시용)
const visibleIndex = localColumns
.slice(0, index + 1)
.filter((c) => c.visible).length;
const isFrozen = col.visible && visibleIndex <= frozenColumnCount;
return ( return (
<div <div
key={col.columnName} key={col.columnName}
@ -155,7 +201,11 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
onDragStart={() => handleDragStart(index)} onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)} onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50 cursor-move" className={`flex items-center gap-3 rounded-lg border p-3 transition-colors cursor-move ${
isFrozen
? "bg-blue-50 border-blue-200 dark:bg-blue-950/30 dark:border-blue-800"
: "bg-background hover:bg-muted/50"
}`}
> >
{/* 드래그 핸들 */} {/* 드래그 핸들 */}
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" /> <GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" />
@ -171,8 +221,10 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
} }
/> />
{/* 가시성 아이콘 */} {/* 가시성/틀고정 아이콘 */}
{col.visible ? ( {isFrozen ? (
<Lock className="h-4 w-4 shrink-0 text-blue-500" />
) : col.visible ? (
<Eye className="h-4 w-4 shrink-0 text-primary" /> <Eye className="h-4 w-4 shrink-0 text-primary" />
) : ( ) : (
<EyeOff className="h-4 w-4 shrink-0 text-muted-foreground" /> <EyeOff className="h-4 w-4 shrink-0 text-muted-foreground" />
@ -180,8 +232,15 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
{/* 컬럼명 */} {/* 컬럼명 */}
<div className="flex-1"> <div className="flex-1">
<div className="text-xs font-medium sm:text-sm"> <div className="flex items-center gap-2">
<span className="text-xs font-medium sm:text-sm">
{columnMeta?.columnLabel} {columnMeta?.columnLabel}
</span>
{isFrozen && (
<span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium">
()
</span>
)}
</div> </div>
<div className="text-[10px] text-muted-foreground sm:text-xs"> <div className="text-[10px] text-muted-foreground sm:text-xs">
{col.columnName} {col.columnName}
@ -217,7 +276,7 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button <Button
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
> >

View File

@ -138,3 +138,4 @@ export const useActiveTabOptional = () => {
}; };

View File

@ -162,8 +162,8 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 현재 페이지 계산 // 현재 페이지 계산
const currentPage = layoutConfig.pages.find((p) => p.page_id === currentPageId); const currentPage = layoutConfig.pages.find((p) => p.page_id === currentPageId);
// 현재 페이지의 컴포넌트 (읽기 전용) // 현재 페이지의 컴포넌트 (읽기 전용) - 배열인지 확인
const components = currentPage?.components || []; const components = Array.isArray(currentPage?.components) ? currentPage.components : [];
// currentPageId를 ref로 저장하여 클로저 문제 해결 // currentPageId를 ref로 저장하여 클로저 문제 해결
const currentPageIdRef = useRef<string | null>(currentPageId); const currentPageIdRef = useRef<string | null>(currentPageId);

View File

@ -43,25 +43,24 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
/** /**
* *
* :
* 1. selectedTableId를
* 2. unregister가 selectedTableId를
*/ */
const unregisterTable = useCallback( const unregisterTable = useCallback(
(tableId: string) => { (tableId: string) => {
setRegisteredTables((prev) => { setRegisteredTables((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev);
const removed = newMap.delete(tableId); newMap.delete(tableId);
if (removed) {
// 선택된 테이블이 제거되면 첫 번째 테이블 선택
if (selectedTableId === tableId) {
const firstTableId = newMap.keys().next().value;
setSelectedTableId(firstTableId || null);
}
}
return newMap; return newMap;
}); });
// 🚫 selectedTableId를 변경하지 않음
// 이유: useEffect 재실행 시 cleanup → register 순서로 호출되는데,
// cleanup에서 selectedTableId를 null로 만들면 필터 설정이 초기화됨
// 다른 테이블이 선택되어야 하면 TableSearchWidget에서 자동 선택함
}, },
[selectedTableId] [] // 의존성 없음 - 무한 루프 방지
); );
/** /**

View File

@ -195,3 +195,4 @@ export function applyAutoFillToFormData(

View File

@ -25,7 +25,7 @@
* }); * });
*/ */
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { CascadingDropdownConfig } from "@/types/screen-management"; import { CascadingDropdownConfig } from "@/types/screen-management";
@ -38,12 +38,16 @@ export interface CascadingOption {
export interface UseCascadingDropdownProps { export interface UseCascadingDropdownProps {
/** 🆕 연쇄 관계 코드 (관계 관리에서 정의한 코드) */ /** 🆕 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
relationCode?: string; relationCode?: string;
/** 🆕 카테고리 값 연쇄 관계 코드 (카테고리 값 연쇄관계용) */
categoryRelationCode?: string;
/** 🆕 역할: parent(부모-전체 옵션) 또는 child(자식-필터링된 옵션) */ /** 🆕 역할: parent(부모-전체 옵션) 또는 child(자식-필터링된 옵션) */
role?: "parent" | "child"; role?: "parent" | "child";
/** @deprecated 직접 설정 방식 - relationCode 사용 권장 */ /** @deprecated 직접 설정 방식 - relationCode 사용 권장 */
config?: CascadingDropdownConfig; config?: CascadingDropdownConfig;
/** 부모 필드의 현재 값 (자식 역할일 때 필요) */ /** 부모 필드의 현재 값 (자식 역할일 때 필요) - 단일 값 또는 배열(다중 선택) */
parentValue?: string | number | null; parentValue?: string | number | null;
/** 🆕 다중 부모값 (배열) - parentValue보다 우선 */
parentValues?: (string | number)[];
/** 초기 옵션 (캐시된 데이터가 있을 경우) */ /** 초기 옵션 (캐시된 데이터가 있을 경우) */
initialOptions?: CascadingOption[]; initialOptions?: CascadingOption[];
} }
@ -71,9 +75,11 @@ const CACHE_TTL = 5 * 60 * 1000; // 5분
export function useCascadingDropdown({ export function useCascadingDropdown({
relationCode, relationCode,
categoryRelationCode,
role = "child", // 기본값은 자식 역할 (기존 동작 유지) role = "child", // 기본값은 자식 역할 (기존 동작 유지)
config, config,
parentValue, parentValue,
parentValues,
initialOptions = [], initialOptions = [],
}: UseCascadingDropdownProps): UseCascadingDropdownResult { }: UseCascadingDropdownProps): UseCascadingDropdownResult {
const [options, setOptions] = useState<CascadingOption[]>(initialOptions); const [options, setOptions] = useState<CascadingOption[]>(initialOptions);
@ -85,25 +91,50 @@ export function useCascadingDropdown({
const prevParentValueRef = useRef<string | number | null | undefined>(undefined); const prevParentValueRef = useRef<string | number | null | undefined>(undefined);
// 관계 코드 또는 직접 설정 중 하나라도 있는지 확인 // 관계 코드 또는 직접 설정 중 하나라도 있는지 확인
const isEnabled = !!relationCode || config?.enabled; const isEnabled = !!relationCode || !!categoryRelationCode || config?.enabled;
// 유효한 부모값 배열 계산 (다중 또는 단일) - 메모이제이션으로 불필요한 리렌더 방지
const effectiveParentValues: string[] = useMemo(() => {
if (parentValues && parentValues.length > 0) {
return parentValues.map(v => String(v));
}
if (parentValue !== null && parentValue !== undefined) {
return [String(parentValue)];
}
return [];
}, [parentValues, parentValue]);
// 부모값 배열의 문자열 키 (의존성 비교용)
const parentValuesKey = useMemo(() => JSON.stringify(effectiveParentValues), [effectiveParentValues]);
// 캐시 키 생성 // 캐시 키 생성
const getCacheKey = useCallback(() => { const getCacheKey = useCallback(() => {
if (categoryRelationCode) {
// 카테고리 값 연쇄관계
if (role === "parent") {
return `category-value:${categoryRelationCode}:parent:all`;
}
if (effectiveParentValues.length === 0) return null;
const sortedValues = [...effectiveParentValues].sort().join(',');
return `category-value:${categoryRelationCode}:child:${sortedValues}`;
}
if (relationCode) { if (relationCode) {
// 부모 역할: 전체 옵션 캐시 // 부모 역할: 전체 옵션 캐시
if (role === "parent") { if (role === "parent") {
return `relation:${relationCode}:parent:all`; return `relation:${relationCode}:parent:all`;
} }
// 자식 역할: 부모 값별 캐시 // 자식 역할: 부모 값별 캐시 (다중 부모값 지원)
if (!parentValue) return null; if (effectiveParentValues.length === 0) return null;
return `relation:${relationCode}:child:${parentValue}`; const sortedValues = [...effectiveParentValues].sort().join(',');
return `relation:${relationCode}:child:${sortedValues}`;
} }
if (config) { if (config) {
if (!parentValue) return null; if (effectiveParentValues.length === 0) return null;
return `${config.sourceTable}:${config.parentKeyColumn}:${parentValue}`; const sortedValues = [...effectiveParentValues].sort().join(',');
return `${config.sourceTable}:${config.parentKeyColumn}:${sortedValues}`;
} }
return null; return null;
}, [relationCode, role, config, parentValue]); }, [categoryRelationCode, relationCode, role, config, effectiveParentValues]);
// 🆕 부모 역할 옵션 로드 (관계의 parent_table에서 전체 옵션 로드) // 🆕 부모 역할 옵션 로드 (관계의 parent_table에서 전체 옵션 로드)
const loadParentOptions = useCallback(async () => { const loadParentOptions = useCallback(async () => {
@ -158,9 +189,9 @@ export function useCascadingDropdown({
} }
}, [relationCode, getCacheKey]); }, [relationCode, getCacheKey]);
// 자식 역할 옵션 로드 (관계 코드 방식) // 자식 역할 옵션 로드 (관계 코드 방식) - 다중 부모값 지원
const loadChildOptions = useCallback(async () => { const loadChildOptions = useCallback(async () => {
if (!relationCode || !parentValue) { if (!relationCode || effectiveParentValues.length === 0) {
setOptions([]); setOptions([]);
return; return;
} }
@ -180,8 +211,18 @@ export function useCascadingDropdown({
setError(null); setError(null);
try { try {
// 관계 코드로 옵션 조회 API 호출 (자식 역할 - 필터링된 옵션) // 다중 부모값 지원: parentValues 파라미터 사용
const response = await apiClient.get(`/cascading-relations/options/${relationCode}?parentValue=${encodeURIComponent(String(parentValue))}`); let url: string;
if (effectiveParentValues.length === 1) {
// 단일 값 (기존 호환)
url = `/cascading-relations/options/${relationCode}?parentValue=${encodeURIComponent(effectiveParentValues[0])}`;
} else {
// 다중 값
const parentValuesParam = effectiveParentValues.join(',');
url = `/cascading-relations/options/${relationCode}?parentValues=${encodeURIComponent(parentValuesParam)}`;
}
const response = await apiClient.get(url);
if (response.data?.success) { if (response.data?.success) {
const loadedOptions: CascadingOption[] = response.data.data || []; const loadedOptions: CascadingOption[] = response.data.data || [];
@ -195,9 +236,9 @@ export function useCascadingDropdown({
}); });
} }
console.log("✅ Child options 로드 완료:", { console.log("✅ Child options 로드 완료 (다중 부모값 지원):", {
relationCode, relationCode,
parentValue, parentValues: effectiveParentValues,
count: loadedOptions.length, count: loadedOptions.length,
}); });
} else { } else {
@ -210,7 +251,121 @@ export function useCascadingDropdown({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [relationCode, parentValue, getCacheKey]); }, [relationCode, effectiveParentValues, getCacheKey]);
// 🆕 카테고리 값 연쇄관계 - 부모 옵션 로드
const loadCategoryParentOptions = useCallback(async () => {
if (!categoryRelationCode) {
setOptions([]);
return;
}
const cacheKey = getCacheKey();
// 캐시 확인
if (cacheKey) {
const cached = optionsCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
setOptions(cached.options);
return;
}
}
setLoading(true);
setError(null);
try {
const response = await apiClient.get(`/category-value-cascading/parent-options/${categoryRelationCode}`);
if (response.data?.success) {
const loadedOptions: CascadingOption[] = response.data.data || [];
setOptions(loadedOptions);
// 캐시 저장
if (cacheKey) {
optionsCache.set(cacheKey, {
options: loadedOptions,
timestamp: Date.now(),
});
}
console.log("✅ Category parent options 로드 완료:", {
categoryRelationCode,
count: loadedOptions.length,
});
} else {
throw new Error(response.data?.message || "옵션 로드 실패");
}
} catch (err: any) {
console.error("❌ Category parent options 로드 실패:", err);
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
setOptions([]);
} finally {
setLoading(false);
}
}, [categoryRelationCode, getCacheKey]);
// 🆕 카테고리 값 연쇄관계 - 자식 옵션 로드 (다중 부모값 지원)
const loadCategoryChildOptions = useCallback(async () => {
if (!categoryRelationCode || effectiveParentValues.length === 0) {
setOptions([]);
return;
}
const cacheKey = getCacheKey();
// 캐시 확인
if (cacheKey) {
const cached = optionsCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
setOptions(cached.options);
return;
}
}
setLoading(true);
setError(null);
try {
// 다중 부모값 지원
let url: string;
if (effectiveParentValues.length === 1) {
url = `/category-value-cascading/options/${categoryRelationCode}?parentValue=${encodeURIComponent(effectiveParentValues[0])}`;
} else {
const parentValuesParam = effectiveParentValues.join(',');
url = `/category-value-cascading/options/${categoryRelationCode}?parentValues=${encodeURIComponent(parentValuesParam)}`;
}
const response = await apiClient.get(url);
if (response.data?.success) {
const loadedOptions: CascadingOption[] = response.data.data || [];
setOptions(loadedOptions);
// 캐시 저장
if (cacheKey) {
optionsCache.set(cacheKey, {
options: loadedOptions,
timestamp: Date.now(),
});
}
console.log("✅ Category child options 로드 완료 (다중 부모값 지원):", {
categoryRelationCode,
parentValues: effectiveParentValues,
count: loadedOptions.length,
});
} else {
throw new Error(response.data?.message || "옵션 로드 실패");
}
} catch (err: any) {
console.error("❌ Category child options 로드 실패:", err);
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
setOptions([]);
} finally {
setLoading(false);
}
}, [categoryRelationCode, effectiveParentValues, getCacheKey]);
// 옵션 로드 (직접 설정 방식 - 레거시) // 옵션 로드 (직접 설정 방식 - 레거시)
const loadOptionsByConfig = useCallback(async () => { const loadOptionsByConfig = useCallback(async () => {
@ -279,7 +434,14 @@ export function useCascadingDropdown({
// 통합 로드 함수 // 통합 로드 함수
const loadOptions = useCallback(() => { const loadOptions = useCallback(() => {
if (relationCode) { // 카테고리 값 연쇄관계 우선
if (categoryRelationCode) {
if (role === "parent") {
loadCategoryParentOptions();
} else {
loadCategoryChildOptions();
}
} else if (relationCode) {
// 역할에 따라 다른 로드 함수 호출 // 역할에 따라 다른 로드 함수 호출
if (role === "parent") { if (role === "parent") {
loadParentOptions(); loadParentOptions();
@ -291,7 +453,7 @@ export function useCascadingDropdown({
} else { } else {
setOptions([]); setOptions([]);
} }
}, [relationCode, role, config?.enabled, loadParentOptions, loadChildOptions, loadOptionsByConfig]); }, [categoryRelationCode, relationCode, role, config?.enabled, loadCategoryParentOptions, loadCategoryChildOptions, loadParentOptions, loadChildOptions, loadOptionsByConfig]);
// 옵션 로드 트리거 // 옵션 로드 트리거
useEffect(() => { useEffect(() => {
@ -300,24 +462,28 @@ export function useCascadingDropdown({
return; return;
} }
// 부모 역할: 즉시 전체 옵션 로드 // 부모 역할: 즉시 전체 옵션 로드 (최초 1회만)
if (role === "parent") { if (role === "parent") {
loadOptions(); loadOptions();
return; return;
} }
// 자식 역할: 부모 값이 있을 때만 로드 // 자식 역할: 부모 값이 있을 때만 로드
// 부모 값이 변경되었는지 확인 // 부모 값 배열의 변경 감지를 위해 JSON 문자열 비교
const parentChanged = prevParentValueRef.current !== parentValue; const prevParentKey = prevParentValueRef.current;
prevParentValueRef.current = parentValue;
if (parentValue) { if (prevParentKey !== parentValuesKey) {
prevParentValueRef.current = parentValuesKey as any;
if (effectiveParentValues.length > 0) {
loadOptions(); loadOptions();
} else { } else {
// 부모 값이 없으면 옵션 초기화 // 부모 값이 없으면 옵션 초기화
setOptions([]); setOptions([]);
} }
}, [isEnabled, role, parentValue, loadOptions]); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEnabled, role, parentValuesKey]);
// 옵션 새로고침 // 옵션 새로고침
const refresh = useCallback(() => { const refresh = useCallback(() => {

View File

@ -52,6 +52,7 @@ export interface CascadingRelationUpdateInput extends Partial<CascadingRelationC
export interface CascadingOption { export interface CascadingOption {
value: string; value: string;
label: string; label: string;
parent_value?: string; // 다중 부모 선택 시 어떤 부모에 속하는지 구분용
} }
/** /**
@ -99,10 +100,28 @@ export const getCascadingRelationByCode = async (code: string) => {
/** /**
* *
*
*/ */
export const getCascadingOptions = async (code: string, parentValue: string): Promise<{ success: boolean; data?: CascadingOption[]; error?: string }> => { export const getCascadingOptions = async (
code: string,
parentValue: string | string[]
): Promise<{ success: boolean; data?: CascadingOption[]; error?: string }> => {
try { try {
const response = await apiClient.get(`/cascading-relations/options/${code}?parentValue=${encodeURIComponent(parentValue)}`); let url: string;
if (Array.isArray(parentValue)) {
// 다중 부모값: parentValues 파라미터 사용
if (parentValue.length === 0) {
return { success: true, data: [] };
}
const parentValuesParam = parentValue.join(',');
url = `/cascading-relations/options/${code}?parentValues=${encodeURIComponent(parentValuesParam)}`;
} else {
// 단일 부모값: 기존 호환
url = `/cascading-relations/options/${code}?parentValue=${encodeURIComponent(parentValue)}`;
}
const response = await apiClient.get(url);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error("연쇄 옵션 조회 실패:", error); console.error("연쇄 옵션 조회 실패:", error);

View File

@ -0,0 +1,255 @@
import { apiClient } from "./client";
// ============================================
// 타입 정의
// ============================================
export interface CategoryValueCascadingGroup {
group_id: number;
relation_code: string;
relation_name: string;
description?: string;
parent_table_name: string;
parent_column_name: string;
parent_menu_objid?: number;
child_table_name: string;
child_column_name: string;
child_menu_objid?: number;
clear_on_parent_change?: string;
show_group_label?: string;
empty_parent_message?: string;
no_options_message?: string;
company_code: string;
is_active?: string;
created_by?: string;
created_date?: string;
updated_by?: string;
updated_date?: string;
// 상세 조회 시 포함
mappings?: CategoryValueCascadingMapping[];
mappingsByParent?: Record<string, { childValueCode: string; childValueLabel: string; displayOrder: number }[]>;
}
export interface CategoryValueCascadingMapping {
mapping_id?: number;
parent_value_code: string;
parent_value_label?: string;
child_value_code: string;
child_value_label?: string;
display_order?: number;
}
export interface CategoryValueCascadingGroupInput {
relationCode: string;
relationName: string;
description?: string;
parentTableName: string;
parentColumnName: string;
parentMenuObjid?: number;
childTableName: string;
childColumnName: string;
childMenuObjid?: number;
clearOnParentChange?: boolean;
showGroupLabel?: boolean;
emptyParentMessage?: string;
noOptionsMessage?: string;
}
export interface CategoryValueCascadingMappingInput {
parentValueCode: string;
parentValueLabel?: string;
childValueCode: string;
childValueLabel?: string;
displayOrder?: number;
}
export interface CategoryValueCascadingOption {
value: string;
label: string;
parent_value?: string;
parent_label?: string;
display_order?: number;
}
// ============================================
// API 함수
// ============================================
/**
*
*/
export const getCategoryValueCascadingGroups = async (isActive?: string) => {
try {
const params = new URLSearchParams();
if (isActive !== undefined) {
params.append("isActive", isActive);
}
const response = await apiClient.get(`/category-value-cascading/groups?${params.toString()}`);
return response.data;
} catch (error: any) {
console.error("카테고리 값 연쇄관계 그룹 목록 조회 실패:", error);
return { success: false, error: error.message };
}
};
/**
*
*/
export const getCategoryValueCascadingGroupById = async (groupId: number) => {
try {
const response = await apiClient.get(`/category-value-cascading/groups/${groupId}`);
return response.data;
} catch (error: any) {
console.error("카테고리 값 연쇄관계 그룹 상세 조회 실패:", error);
return { success: false, error: error.message };
}
};
/**
*
*/
export const getCategoryValueCascadingByCode = async (code: string) => {
try {
const response = await apiClient.get(`/category-value-cascading/code/${code}`);
return response.data;
} catch (error: any) {
console.error("카테고리 값 연쇄관계 코드 조회 실패:", error);
return { success: false, error: error.message };
}
};
/**
*
*/
export const createCategoryValueCascadingGroup = async (data: CategoryValueCascadingGroupInput) => {
try {
const response = await apiClient.post("/category-value-cascading/groups", data);
return response.data;
} catch (error: any) {
console.error("카테고리 값 연쇄관계 그룹 생성 실패:", error);
return { success: false, error: error.message };
}
};
/**
*
*/
export const updateCategoryValueCascadingGroup = async (
groupId: number,
data: Partial<CategoryValueCascadingGroupInput> & { isActive?: boolean }
) => {
try {
const response = await apiClient.put(`/category-value-cascading/groups/${groupId}`, data);
return response.data;
} catch (error: any) {
console.error("카테고리 값 연쇄관계 그룹 수정 실패:", error);
return { success: false, error: error.message };
}
};
/**
*
*/
export const deleteCategoryValueCascadingGroup = async (groupId: number) => {
try {
const response = await apiClient.delete(`/category-value-cascading/groups/${groupId}`);
return response.data;
} catch (error: any) {
console.error("카테고리 값 연쇄관계 그룹 삭제 실패:", error);
return { success: false, error: error.message };
}
};
/**
*
*/
export const saveCategoryValueCascadingMappings = async (
groupId: number,
mappings: CategoryValueCascadingMappingInput[]
) => {
try {
const response = await apiClient.post(`/category-value-cascading/groups/${groupId}/mappings`, { mappings });
return response.data;
} catch (error: any) {
console.error("카테고리 값 연쇄관계 매핑 저장 실패:", error);
return { success: false, error: error.message };
}
};
/**
* ( )
*
*/
export const getCategoryValueCascadingOptions = async (
code: string,
parentValue: string | string[]
): Promise<{ success: boolean; data?: CategoryValueCascadingOption[]; showGroupLabel?: boolean; error?: string }> => {
try {
let url: string;
if (Array.isArray(parentValue)) {
if (parentValue.length === 0) {
return { success: true, data: [] };
}
const parentValuesParam = parentValue.join(',');
url = `/category-value-cascading/options/${code}?parentValues=${encodeURIComponent(parentValuesParam)}`;
} else {
url = `/category-value-cascading/options/${code}?parentValue=${encodeURIComponent(parentValue)}`;
}
const response = await apiClient.get(url);
return response.data;
} catch (error: any) {
console.error("카테고리 값 연쇄 옵션 조회 실패:", error);
return { success: false, error: error.message };
}
};
/**
*
*/
export const getCategoryValueCascadingParentOptions = async (code: string) => {
try {
const response = await apiClient.get(`/category-value-cascading/parent-options/${code}`);
return response.data;
} catch (error: any) {
console.error("부모 카테고리 값 조회 실패:", error);
return { success: false, error: error.message };
}
};
/**
* ( UI용)
*/
export const getCategoryValueCascadingChildOptions = async (code: string) => {
try {
const response = await apiClient.get(`/category-value-cascading/child-options/${code}`);
return response.data;
} catch (error: any) {
console.error("자식 카테고리 값 조회 실패:", error);
return { success: false, error: error.message };
}
};
// ============================================
// API 객체 export
// ============================================
export const categoryValueCascadingApi = {
// 그룹 CRUD
getGroups: getCategoryValueCascadingGroups,
getGroupById: getCategoryValueCascadingGroupById,
getByCode: getCategoryValueCascadingByCode,
createGroup: createCategoryValueCascadingGroup,
updateGroup: updateCategoryValueCascadingGroup,
deleteGroup: deleteCategoryValueCascadingGroup,
// 매핑
saveMappings: saveCategoryValueCascadingMappings,
// 옵션 조회
getOptions: getCategoryValueCascadingOptions,
getParentOptions: getCategoryValueCascadingParentOptions,
getChildOptions: getCategoryValueCascadingChildOptions,
};

View File

@ -163,13 +163,20 @@ export const menuApi = {
} }
}, },
// 메뉴 복사 // 메뉴 복사 (타임아웃 5분 - 대량 데이터 처리)
copyMenu: async ( copyMenu: async (
menuObjid: number, menuObjid: number,
targetCompanyCode: string, targetCompanyCode: string,
screenNameConfig?: { screenNameConfig?: {
removeText?: string; removeText?: string;
addPrefix?: string; addPrefix?: string;
},
additionalCopyOptions?: {
copyCodeCategory?: boolean;
copyNumberingRules?: boolean;
copyCategoryMapping?: boolean;
copyTableTypeColumns?: boolean;
copyCascadingRelation?: boolean;
} }
): Promise<ApiResponse<MenuCopyResult>> => { ): Promise<ApiResponse<MenuCopyResult>> => {
try { try {
@ -177,12 +184,26 @@ export const menuApi = {
`/admin/menus/${menuObjid}/copy`, `/admin/menus/${menuObjid}/copy`,
{ {
targetCompanyCode, targetCompanyCode,
screenNameConfig screenNameConfig,
additionalCopyOptions
},
{
timeout: 300000, // 5분 (메뉴 복사는 많은 데이터를 처리하므로 긴 타임아웃 필요)
} }
); );
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error("❌ 메뉴 복사 실패:", error); console.error("❌ 메뉴 복사 실패:", error);
// 타임아웃 에러 구분 처리
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
return {
success: false,
message: "메뉴 복사 요청 시간이 초과되었습니다. 백엔드에서 작업이 완료되었을 수 있으니 잠시 후 확인해주세요.",
errorCode: "MENU_COPY_TIMEOUT",
};
}
return { return {
success: false, success: false,
message: error.response?.data?.message || "메뉴 복사 중 오류가 발생했습니다", message: error.response?.data?.message || "메뉴 복사 중 오류가 발생했습니다",
@ -199,6 +220,12 @@ export interface MenuCopyResult {
copiedMenus: number; copiedMenus: number;
copiedScreens: number; copiedScreens: number;
copiedFlows: number; copiedFlows: number;
copiedCodeCategories?: number;
copiedCodes?: number;
copiedNumberingRules?: number;
copiedCategoryMappings?: number;
copiedTableTypeColumns?: number;
copiedCascadingRelations?: number;
menuIdMap: Record<number, number>; menuIdMap: Record<number, number>;
screenIdMap: Record<number, number>; screenIdMap: Record<number, number>;
flowIdMap: Record<number, number>; flowIdMap: Record<number, number>;

View File

@ -60,6 +60,9 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
// 변환된 값 캐시 (중복 변환 방지) // 변환된 값 캐시 (중복 변환 방지)
const convertedCache = useRef(new Map<string, string>()); const convertedCache = useRef(new Map<string, string>());
// 초기화 완료 플래그 (무한 루프 방지)
const initialLoadDone = useRef(false);
// 공통 코드 카테고리 추출 (메모이제이션) // 공통 코드 카테고리 추출 (메모이제이션)
const codeCategories = useMemo(() => { const codeCategories = useMemo(() => {
return Object.values(columnMeta) return Object.values(columnMeta)
@ -293,24 +296,40 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
[codeCategories, batchLoadCodes, updateMetrics], [codeCategories, batchLoadCodes, updateMetrics],
); );
// 초기화 시 공통 코드 프리로딩 // 초기화 시 공통 코드 프리로딩 (한 번만 실행)
useEffect(() => { useEffect(() => {
// 이미 초기화되었으면 스킵 (무한 루프 방지)
if (initialLoadDone.current) return;
initialLoadDone.current = true;
preloadCommonCodesOnMount(); preloadCommonCodesOnMount();
}, [preloadCommonCodesOnMount]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 컬럼 메타 변경 시 필요한 코드 추가 로딩 // 컬럼 메타 변경 시 필요한 코드 추가 로딩
// 이미 로딩 중이면 스킵하여 무한 루프 방지
const loadedCategoriesRef = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
// 이미 최적화 중이거나 초기화 전이면 스킵
if (isOptimizing) return;
if (codeCategories.length > 0) { if (codeCategories.length > 0) {
const unloadedCategories = codeCategories.filter((category) => { const unloadedCategories = codeCategories.filter((category) => {
// 이미 로드 요청을 보낸 카테고리는 스킵
if (loadedCategoriesRef.current.has(category)) return false;
return codeCache.getCodeSync(category) === null; return codeCache.getCodeSync(category) === null;
}); });
if (unloadedCategories.length > 0) { if (unloadedCategories.length > 0) {
// 로딩 요청 카테고리 기록
unloadedCategories.forEach(cat => loadedCategoriesRef.current.add(cat));
console.log(`🔄 새로운 코드 카테고리 감지, 추가 로딩: ${unloadedCategories.join(", ")}`); console.log(`🔄 새로운 코드 카테고리 감지, 추가 로딩: ${unloadedCategories.join(", ")}`);
batchLoadCodes(unloadedCategories); batchLoadCodes(unloadedCategories);
} }
} }
}, [codeCategories, batchLoadCodes]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [codeCategories.join(",")]); // 배열 내용 기반 의존성
// 주기적으로 메트릭 업데이트 // 주기적으로 메트릭 업데이트
useEffect(() => { useEffect(() => {

View File

@ -416,6 +416,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨) // originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨)
_initialData: originalData || formData, _initialData: originalData || formData,
_originalData: originalData, _originalData: originalData,
// 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용)
parentTabId: props.parentTabId,
parentTabsComponentId: props.parentTabsComponentId,
}; };
// 렌더러가 클래스인지 함수인지 확인 // 렌더러가 클래스인지 함수인지 확인

View File

@ -150,6 +150,12 @@ export function ConditionalSectionViewer({
/* 실행 모드: 실제 화면 렌더링 */ /* 실행 모드: 실제 화면 렌더링 */
<div className="w-full"> <div className="w-full">
{/* 화면 크기만큼의 절대 위치 캔버스 */} {/* 화면 크기만큼의 절대 위치 캔버스 */}
{/* UniversalFormModal이 있으면 onSave 전달하지 않음 (자체 저장 로직 사용) */}
{(() => {
const hasUniversalFormModal = components.some(
(c) => c.componentType === "universal-form-modal"
);
return (
<div <div
className="relative mx-auto" className="relative mx-auto"
style={{ style={{
@ -184,12 +190,14 @@ export function ConditionalSectionViewer({
formData={enhancedFormData} formData={enhancedFormData}
onFormDataChange={onFormDataChange} onFormDataChange={onFormDataChange}
groupedData={groupedData} groupedData={groupedData}
onSave={onSave} onSave={hasUniversalFormModal ? undefined : onSave}
/> />
</div> </div>
); );
})} })}
</div> </div>
);
})()}
</div> </div>
)} )}
</> </>

View File

@ -172,19 +172,38 @@ export function EntitySearchInputComponent({
// value가 변경되면 표시값 업데이트 (외래키 값으로 데이터 조회) // value가 변경되면 표시값 업데이트 (외래키 값으로 데이터 조회)
useEffect(() => { useEffect(() => {
const loadDisplayValue = async () => { const loadDisplayValue = async () => {
if (value && selectedData) { // value가 없으면 초기화
// 이미 selectedData가 있으면 표시값만 업데이트 if (!value) {
setDisplayValue("");
setSelectedData(null);
return;
}
// 이미 selectedData가 있고 value와 일치하면 표시값만 업데이트
if (selectedData && String(selectedData[valueField]) === String(value)) {
setDisplayValue(selectedData[displayField] || ""); setDisplayValue(selectedData[displayField] || "");
} else if (value && mode === "select" && effectiveOptions.length > 0) { return;
// select 모드에서 value가 있고 options가 로드된 경우 }
const found = effectiveOptions.find((opt) => opt[valueField] === value);
// select 모드에서 options가 로드된 경우 먼저 옵션에서 찾기
if (mode === "select" && effectiveOptions.length > 0) {
// 타입 변환하여 비교 (숫자 vs 문자열 문제 해결)
const found = effectiveOptions.find((opt) => String(opt[valueField]) === String(value));
if (found) { if (found) {
setSelectedData(found); setSelectedData(found);
setDisplayValue(found[displayField] || ""); setDisplayValue(found[displayField] || "");
console.log("✅ [EntitySearchInput] 옵션에서 초기값 찾음:", { value, found });
return;
} }
} else if (value && !selectedData && tableName) { // 옵션에서 찾지 못한 경우 API로 조회 진행
// value는 있지만 selectedData가 없는 경우 (초기 로드 시) console.log("⚠️ [EntitySearchInput] 옵션에서 찾지 못함, API로 조회:", {
value,
optionsCount: effectiveOptions.length,
});
}
// API로 해당 데이터 조회 // API로 해당 데이터 조회
if (tableName) {
try { try {
console.log("🔍 [EntitySearchInput] 초기값 조회:", { value, tableName, valueField }); console.log("🔍 [EntitySearchInput] 초기값 조회:", { value, tableName, valueField });
const response = await dynamicFormApi.getTableData(tableName, { const response = await dynamicFormApi.getTableData(tableName, {
@ -222,9 +241,6 @@ export function EntitySearchInputComponent({
// 에러 시 value 자체를 표시 // 에러 시 value 자체를 표시
setDisplayValue(String(value)); setDisplayValue(String(value));
} }
} else if (!value) {
setDisplayValue("");
setSelectedData(null);
} }
}; };

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -12,9 +12,11 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, Loader2 } from "lucide-react"; import { Search, Loader2 } from "lucide-react";
import { useEntitySearch } from "../entity-search-input/useEntitySearch"; import { useEntitySearch } from "../entity-search-input/useEntitySearch";
import { ItemSelectionModalProps } from "./types"; import { ItemSelectionModalProps, ModalFilterConfig } from "./types";
import { apiClient } from "@/lib/api/client";
export function ItemSelectionModal({ export function ItemSelectionModal({
open, open,
@ -29,28 +31,135 @@ export function ItemSelectionModal({
uniqueField, uniqueField,
onSelect, onSelect,
columnLabels = {}, columnLabels = {},
modalFilters = [],
}: ItemSelectionModalProps) { }: ItemSelectionModalProps) {
const [localSearchText, setLocalSearchText] = useState(""); const [localSearchText, setLocalSearchText] = useState("");
const [selectedItems, setSelectedItems] = useState<any[]>([]); const [selectedItems, setSelectedItems] = useState<any[]>([]);
// 모달 필터 값 상태
const [modalFilterValues, setModalFilterValues] = useState<Record<string, any>>({});
// 카테고리 옵션 상태 (categoryRef별로 로드된 옵션)
const [categoryOptions, setCategoryOptions] = useState<Record<string, { value: string; label: string }[]>>({});
// 모달 필터 값과 기본 filterCondition을 합친 최종 필터 조건
const combinedFilterCondition = useMemo(() => {
const combined = { ...filterCondition };
// 모달 필터 값 추가 (빈 값은 제외)
for (const [key, value] of Object.entries(modalFilterValues)) {
if (value !== undefined && value !== null && value !== "") {
combined[key] = value;
}
}
return combined;
}, [filterCondition, modalFilterValues]);
const { results, loading, error, search, clearSearch } = useEntitySearch({ const { results, loading, error, search, clearSearch } = useEntitySearch({
tableName: sourceTable, tableName: sourceTable,
searchFields: sourceSearchFields.length > 0 ? sourceSearchFields : sourceColumns, searchFields: sourceSearchFields.length > 0 ? sourceSearchFields : sourceColumns,
filterCondition, filterCondition: combinedFilterCondition,
}); });
// 모달 열릴 때 초기 검색 // 필터 옵션 로드 - 소스 테이블 컬럼의 distinct 값 조회
const loadFilterOptions = async (filter: ModalFilterConfig) => {
// 드롭다운 타입만 옵션 로드 필요 (select, category 지원)
const isDropdownType = filter.type === "select" || filter.type === "category";
if (!isDropdownType) return;
const cacheKey = `${sourceTable}.${filter.column}`;
// 이미 로드된 경우 스킵
if (categoryOptions[cacheKey]) return;
try {
// 소스 테이블에서 해당 컬럼의 데이터 조회 (POST 메서드 사용)
// 백엔드는 'size' 파라미터를 사용함
const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, {
page: 1,
size: 10000, // 모든 데이터 조회를 위해 큰 값 설정
});
if (response.data?.success) {
// 응답 구조에 따라 rows 추출
const rows = response.data.data?.rows || response.data.data?.data || response.data.data || [];
if (Array.isArray(rows)) {
// 컬럼 값 중복 제거
const uniqueValues = new Set<string>();
for (const row of rows) {
const val = row[filter.column];
if (val !== null && val !== undefined && val !== "") {
uniqueValues.add(String(val));
}
}
// 정렬 후 옵션으로 변환
const options = Array.from(uniqueValues)
.sort()
.map((val) => ({
value: val,
label: val,
}));
setCategoryOptions((prev) => ({
...prev,
[cacheKey]: options,
}));
}
}
} catch (error) {
console.error(`필터 옵션 로드 실패 (${cacheKey}):`, error);
setCategoryOptions((prev) => ({
...prev,
[cacheKey]: [],
}));
}
};
// 모달 열릴 때 초기 검색 및 필터 초기화
useEffect(() => { useEffect(() => {
if (open) { if (open) {
// 모달 필터 기본값 설정 & 옵션 로드
const initialFilterValues: Record<string, any> = {};
for (const filter of modalFilters) {
if (filter.defaultValue !== undefined) {
initialFilterValues[filter.column] = filter.defaultValue;
}
// 드롭다운 타입이면 옵션 로드 (소스 테이블에서 distinct 값 조회)
const isDropdownType = filter.type === "select" || filter.type === "category";
if (isDropdownType) {
loadFilterOptions(filter);
}
}
setModalFilterValues(initialFilterValues);
search("", 1); // 빈 검색어로 전체 목록 조회 search("", 1); // 빈 검색어로 전체 목록 조회
setSelectedItems([]); setSelectedItems([]);
} else { } else {
clearSearch(); clearSearch();
setLocalSearchText(""); setLocalSearchText("");
setSelectedItems([]); setSelectedItems([]);
setModalFilterValues({});
} }
}, [open]); }, [open]);
// 모달 필터 값 변경 시 재검색
useEffect(() => {
if (open) {
search(localSearchText, 1);
}
}, [modalFilterValues]);
// 모달 필터 값 변경 핸들러
const handleModalFilterChange = (column: string, value: any) => {
setModalFilterValues((prev) => ({
...prev,
[column]: value,
}));
};
const handleSearch = () => { const handleSearch = () => {
search(localSearchText, 1); search(localSearchText, 1);
}; };
@ -202,6 +311,51 @@ export function ItemSelectionModal({
</Button> </Button>
</div> </div>
{/* 모달 필터 */}
{modalFilters.length > 0 && (
<div className="flex flex-wrap gap-3 items-center py-2 px-1 bg-muted/30 rounded-md">
{modalFilters.map((filter) => {
// 소스 테이블의 해당 컬럼에서 로드된 옵션
const options = categoryOptions[`${sourceTable}.${filter.column}`] || [];
// 드롭다운 타입인지 확인 (select, category 모두 드롭다운으로 처리)
const isDropdownType = filter.type === "select" || filter.type === "category";
return (
<div key={filter.column} className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">{filter.label}:</span>
{isDropdownType && (
<Select
value={modalFilterValues[filter.column] || "__all__"}
onValueChange={(value) => handleModalFilterChange(filter.column, value === "__all__" ? "" : value)}
>
<SelectTrigger className="h-7 text-xs w-[140px]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value || `__empty_${opt.label}__`}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{filter.type === "text" && (
<Input
value={modalFilterValues[filter.column] || ""}
onChange={(e) => handleModalFilterChange(filter.column, e.target.value)}
placeholder={filter.label}
className="h-7 text-xs w-[120px]"
/>
)}
</div>
);
})}
</div>
)}
{/* 선택된 항목 수 */} {/* 선택된 항목 수 */}
{selectedItems.length > 0 && ( {selectedItems.length > 0 && (
<div className="text-sm text-primary"> <div className="text-sm text-primary">

View File

@ -205,6 +205,9 @@ export function ModalRepeaterTableComponent({
const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true; const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true;
const calculationRules = componentConfig?.calculationRules || propCalculationRules || []; const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
// 모달 필터 설정
const modalFilters = componentConfig?.modalFilters || [];
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용 // ✅ value는 formData[columnName] 우선, 없으면 prop 사용
const columnName = component?.columnName; const columnName = component?.columnName;
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || []; const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
@ -889,6 +892,7 @@ export function ModalRepeaterTableComponent({
uniqueField={uniqueField} uniqueField={uniqueField}
onSelect={handleAddItems} onSelect={handleAddItems}
columnLabels={columnLabels} columnLabels={columnLabels}
modalFilters={modalFilters}
/> />
</div> </div>
); );

View File

@ -9,7 +9,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react"; import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep } from "./types"; import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep, ModalFilterConfig } from "./types";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -842,6 +842,97 @@ export function ModalRepeaterTableConfigPanel({
/> />
</div> </div>
</div> </div>
{/* 모달 필터 설정 */}
<div className="space-y-2 pt-4 border-t">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const filters = localConfig.modalFilters || [];
updateConfig({
modalFilters: [...filters, { column: "", label: "", type: "select" }],
});
}}
className="h-7 text-xs"
disabled={!localConfig.sourceTable}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
. .
</p>
{(localConfig.modalFilters || []).length > 0 && (
<div className="space-y-2 mt-2">
{(localConfig.modalFilters || []).map((filter, index) => (
<div key={index} className="flex items-center gap-2 p-2 border rounded-md bg-muted/30">
<Select
value={filter.column}
onValueChange={(value) => {
const filters = [...(localConfig.modalFilters || [])];
filters[index] = { ...filters[index], column: value };
updateConfig({ modalFilters: filters });
}}
disabled={!localConfig.sourceTable || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs w-[140px]">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.label}
onChange={(e) => {
const filters = [...(localConfig.modalFilters || [])];
filters[index] = { ...filters[index], label: e.target.value };
updateConfig({ modalFilters: filters });
}}
placeholder="라벨"
className="h-8 text-xs w-[100px]"
/>
<Select
value={filter.type}
onValueChange={(value: "select" | "text") => {
const filters = [...(localConfig.modalFilters || [])];
filters[index] = { ...filters[index], type: value };
updateConfig({ modalFilters: filters });
}}
>
<SelectTrigger className="h-8 text-xs w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="select"></SelectItem>
<SelectItem value="text"></SelectItem>
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"
onClick={() => {
const filters = [...(localConfig.modalFilters || [])];
filters.splice(index, 1);
updateConfig({ modalFilters: filters });
}}
className="h-7 w-7 text-destructive hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
</div> </div>
{/* 반복 테이블 컬럼 관리 */} {/* 반복 테이블 컬럼 관리 */}

View File

@ -40,14 +40,7 @@ interface SortableRowProps {
} }
function SortableRow({ id, children, className }: SortableRowProps) { function SortableRow({ id, children, className }: SortableRowProps) {
const { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style: React.CSSProperties = { const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@ -94,8 +87,8 @@ export function RepeaterTable({
// 컨테이너 ref - 실제 너비 측정용 // 컨테이너 ref - 실제 너비 측정용
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// 균등 분배 모드 상태 (true일 때 테이블이 컨테이너에 맞춤) // 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행)
const [isEqualizedMode, setIsEqualizedMode] = useState(false); const initializedRef = useRef(false);
// DnD 센서 설정 // DnD 센서 설정
const sensors = useSensors( const sensors = useSensors(
@ -106,7 +99,7 @@ export function RepeaterTable({
}), }),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}) }),
); );
// 드래그 종료 핸들러 // 드래그 종료 핸들러
@ -178,89 +171,103 @@ export function RepeaterTable({
startX: e.clientX, startX: e.clientX,
startWidth: columnWidths[field] || 120, startWidth: columnWidths[field] || 120,
}); });
// 수동 조정 시 균등 분배 모드 해제
setIsEqualizedMode(false);
}; };
// 컬럼 확장 상태 추적 (토글용) // 컨테이너 가용 너비 계산
const [expandedColumns, setExpandedColumns] = useState<Set<string>>(new Set()); const getAvailableWidth = (): number => {
if (!containerRef.current) return 800;
const containerWidth = containerRef.current.offsetWidth;
// 드래그 핸들(32px) + 체크박스 컬럼(40px) + border(2px)
return containerWidth - 74;
};
// 데이터 기준 최적 너비 계산 // 텍스트 너비 계산 (한글/영문/숫자 혼합 고려)
const calculateAutoFitWidth = (field: string): number => { const measureTextWidth = (text: string): number => {
const column = columns.find(col => col.field === field); if (!text) return 0;
if (!column) return 120; let width = 0;
for (const char of text) {
if (/[가-힣]/.test(char)) {
width += 15; // 한글 (text-xs 12px 기준)
} else if (/[a-zA-Z]/.test(char)) {
width += 9; // 영문
} else if (/[0-9]/.test(char)) {
width += 8; // 숫자
} else if (/[_\-.]/.test(char)) {
width += 6; // 특수문자
} else if (/[\(\)]/.test(char)) {
width += 6; // 괄호
} else {
width += 8; // 기타
}
}
return width;
};
// 헤더 텍스트 길이 (대략 8px per character + padding) // 해당 컬럼의 가장 긴 글자 너비 계산
const headerWidth = (column.label?.length || field.length) * 8 + 40; // equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용)
const calculateColumnContentWidth = (field: string, equalWidth: number): number => {
const column = columns.find((col) => col.field === field);
if (!column) return equalWidth;
// 데이터 중 가장 긴 텍스트 찾기 // 날짜 필드는 110px (yyyy-MM-dd)
if (column.type === "date") {
return 110;
}
// 해당 컬럼에 값이 있는지 확인
let hasValue = false;
let maxDataWidth = 0; let maxDataWidth = 0;
data.forEach(row => {
data.forEach((row) => {
const value = row[field]; const value = row[field];
if (value !== undefined && value !== null) { if (value !== undefined && value !== null && value !== "") {
hasValue = true;
let displayText = String(value); let displayText = String(value);
// 숫자는 천단위 구분자 포함 if (typeof value === "number") {
if (typeof value === 'number') {
displayText = value.toLocaleString(); displayText = value.toLocaleString();
} }
// 날짜는 yyyy-mm-dd 형식
if (column.type === 'date' && displayText.includes('T')) {
displayText = displayText.split('T')[0];
}
// 대략적인 너비 계산 (8px per character + padding) const textWidth = measureTextWidth(displayText) + 20; // padding
const textWidth = displayText.length * 8 + 32;
maxDataWidth = Math.max(maxDataWidth, textWidth); maxDataWidth = Math.max(maxDataWidth, textWidth);
} }
}); });
// 헤더와 데이터 중 큰 값 사용, 최소 60px, 최대 400px // 값이 없으면 균등 분배 너비 사용
const optimalWidth = Math.max(headerWidth, maxDataWidth); if (!hasValue) {
return Math.min(Math.max(optimalWidth, 60), 400); return equalWidth;
};
// 더블클릭으로 auto-fit / 기본 너비 토글
const handleDoubleClick = (field: string) => {
// 개별 컬럼 조정 시 균등 분배 모드 해제
setIsEqualizedMode(false);
setExpandedColumns(prev => {
const newSet = new Set(prev);
if (newSet.has(field)) {
// 확장 상태 → 기본 너비로 복구
newSet.delete(field);
setColumnWidths(prevWidths => ({
...prevWidths,
[field]: defaultWidths[field] || 120,
}));
} else {
// 기본 상태 → 데이터 기준 auto-fit
newSet.add(field);
const autoWidth = calculateAutoFitWidth(field);
setColumnWidths(prevWidths => ({
...prevWidths,
[field]: autoWidth,
}));
} }
return newSet;
}); // 헤더 텍스트 너비 (동적 데이터 소스가 있으면 headerLabel 사용)
let headerText = column.label || field;
if (column.dynamicDataSource?.enabled && column.dynamicDataSource.options.length > 0) {
const activeOptionId = activeDataSources[field] || column.dynamicDataSource.defaultOptionId;
const activeOption = column.dynamicDataSource.options.find((opt) => opt.id === activeOptionId)
|| column.dynamicDataSource.options[0];
if (activeOption?.headerLabel) {
headerText = activeOption.headerLabel;
}
}
const headerWidth = measureTextWidth(headerText) + 32; // padding + 드롭다운 아이콘
// 헤더와 데이터 중 큰 값 사용
return Math.max(headerWidth, maxDataWidth);
}; };
// 균등 분배 트리거 감지 // 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤
useEffect(() => { const handleDoubleClick = (field: string) => {
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return; const availableWidth = getAvailableWidth();
if (!containerRef.current) return; const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
const contentWidth = calculateColumnContentWidth(field, equalWidth);
setColumnWidths((prev) => ({
...prev,
[field]: contentWidth,
}));
};
// 실제 컨테이너 너비 측정 // 균등 분배: 컬럼 수로 테이블 너비를 균등 분배
const containerWidth = containerRef.current.offsetWidth; const applyEqualizeWidths = () => {
const availableWidth = getAvailableWidth();
// 체크박스 컬럼 너비(40px) + 테이블 border(2px) 제외한 가용 너비 계산
const checkboxColumnWidth = 40;
const borderWidth = 2;
const availableWidth = containerWidth - checkboxColumnWidth - borderWidth;
// 컬럼 수로 나눠서 균등 분배 (최소 60px 보장)
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length)); const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
const newWidths: Record<string, number> = {}; const newWidths: Record<string, number> = {};
@ -269,9 +276,62 @@ export function RepeaterTable({
}); });
setColumnWidths(newWidths); setColumnWidths(newWidths);
setExpandedColumns(new Set()); // 확장 상태 초기화 };
setIsEqualizedMode(true); // 균등 분배 모드 활성화
}, [equalizeWidthsTrigger, columns]); // 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배
const applyAutoFitWidths = () => {
if (columns.length === 0) return;
// 균등 분배 너비 계산 (값이 없는 컬럼의 최소값)
const availableWidth = getAvailableWidth();
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
// 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용)
const newWidths: Record<string, number> = {};
columns.forEach((col) => {
newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth);
});
// 2. 컨테이너 너비와 비교
const totalContentWidth = Object.values(newWidths).reduce((sum, w) => sum + w, 0);
// 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지)
if (totalContentWidth < availableWidth) {
const extraSpace = availableWidth - totalContentWidth;
const extraPerColumn = Math.floor(extraSpace / columns.length);
columns.forEach((col) => {
newWidths[col.field] += extraPerColumn;
});
}
// 컨테이너보다 크면 그대로 (스크롤 생성됨)
setColumnWidths(newWidths);
};
// 초기 마운트 시 균등 분배 적용
useEffect(() => {
if (initializedRef.current) return;
if (!containerRef.current || columns.length === 0) return;
const timer = setTimeout(() => {
applyEqualizeWidths();
initializedRef.current = true;
}, 100);
return () => clearTimeout(timer);
}, [columns]);
// 트리거 감지: 1=균등분배, 2=자동맞춤
useEffect(() => {
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return;
// 홀수면 자동맞춤, 짝수면 균등분배 (토글 방식)
if (equalizeWidthsTrigger % 2 === 1) {
applyAutoFitWidths();
} else {
applyEqualizeWidths();
}
}, [equalizeWidthsTrigger]);
useEffect(() => { useEffect(() => {
if (!resizing) return; if (!resizing) return;
@ -336,13 +396,8 @@ export function RepeaterTable({
const isAllSelected = data.length > 0 && selectedRows.size === data.length; const isAllSelected = data.length > 0 && selectedRows.size === data.length;
const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length; const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length;
const renderCell = ( const renderCell = (row: any, column: RepeaterColumnConfig, rowIndex: number) => {
row: any, const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
column: RepeaterColumnConfig,
rowIndex: number
) => {
const isEditing =
editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
const value = row[column.field]; const value = row[column.field];
// 계산 필드는 편집 불가 // 계산 필드는 편집 불가
@ -360,13 +415,7 @@ export function RepeaterTable({
} }
}; };
return ( return <div className="px-2 py-1">{column.type === "number" ? formatNumber(value) : value || "-"}</div>;
<div className="px-2 py-1">
{column.type === "number"
? formatNumber(value)
: value || "-"}
</div>
);
} }
// 편집 가능한 필드 // 편집 가능한 필드
@ -377,22 +426,22 @@ export function RepeaterTable({
if (value === undefined || value === null || value === "") return ""; if (value === undefined || value === null || value === "") return "";
const num = typeof value === "number" ? value : parseFloat(value); const num = typeof value === "number" ? value : parseFloat(value);
if (isNaN(num)) return ""; if (isNaN(num)) return "";
// 정수면 소수점 없이, 소수면 소수점 유지
if (Number.isInteger(num)) {
return num.toString(); return num.toString();
} else {
return num.toString();
}
})(); })();
return ( return (
<Input <Input
type="number" type="text"
inputMode="numeric"
value={displayValue} value={displayValue}
onChange={(e) => onChange={(e) => {
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0) const val = e.target.value;
// 숫자와 소수점만 허용
if (val === "" || /^-?\d*\.?\d*$/.test(val)) {
handleCellEdit(rowIndex, column.field, val === "" ? 0 : parseFloat(val) || 0);
} }
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full" }}
className="h-8 w-full min-w-0 rounded-none border-gray-200 text-right text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/> />
); );
@ -416,23 +465,19 @@ export function RepeaterTable({
}; };
return ( return (
<Input <input
type="date" type="date"
value={formatDateValue(value)} value={formatDateValue(value)}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)} onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full" onClick={(e) => (e.target as HTMLInputElement).showPicker?.()}
className="h-8 w-full min-w-0 cursor-pointer rounded-none border border-gray-200 bg-white px-2 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-inner-spin-button]:hidden"
/> />
); );
case "select": case "select":
return ( return (
<Select <Select value={value || ""} onValueChange={(newValue) => handleCellEdit(rowIndex, column.field, newValue)}>
value={value || ""} <SelectTrigger className="h-8 w-full min-w-0 rounded-none border-gray-200 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
onValueChange={(newValue) =>
handleCellEdit(rowIndex, column.field, newValue)
}
>
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -451,7 +496,7 @@ export function RepeaterTable({
type="text" type="text"
value={value || ""} value={value || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)} onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full" className="h-8 w-full min-w-0 rounded-none border-gray-200 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/> />
); );
} }
@ -461,58 +506,49 @@ export function RepeaterTable({
const sortableItems = data.map((_, idx) => `row-${idx}`); const sortableItems = data.map((_, idx) => `row-${idx}`);
return ( return (
<DndContext <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div ref={containerRef} className="border border-gray-200 bg-white"> <div ref={containerRef} className="border border-gray-200 bg-white">
<div className="overflow-x-auto max-h-[400px] overflow-y-auto"> <div className="max-h-[400px] overflow-x-auto overflow-y-auto">
<table <table
className={cn( className="border-collapse text-xs"
"text-xs border-collapse", style={{
isEqualizedMode && "w-full" width: `max(100%, ${Object.values(columnWidths).reduce((sum, w) => sum + w, 0) + 74}px)`,
)} }}
style={isEqualizedMode ? undefined : { minWidth: "max-content" }}
> >
<thead className="bg-gray-50 sticky top-0 z-10"> <thead className="sticky top-0 z-10 bg-gray-50">
<tr> <tr>
{/* 드래그 핸들 헤더 */} {/* 드래그 핸들 헤더 */}
<th className="px-1 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-8"> <th className="w-8 border-r border-b border-gray-200 px-1 py-2 text-center font-medium text-gray-700">
<span className="sr-only"></span> <span className="sr-only"></span>
</th> </th>
{/* 체크박스 헤더 */} {/* 체크박스 헤더 */}
<th className="px-3 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-10"> <th className="w-10 border-r border-b border-gray-200 px-3 py-2 text-center font-medium text-gray-700">
<Checkbox <Checkbox
checked={isAllSelected} checked={isAllSelected}
// @ts-ignore - indeterminate는 HTML 속성 // @ts-expect-error - indeterminate는 HTML 속성
data-indeterminate={isIndeterminate} data-indeterminate={isIndeterminate}
onCheckedChange={handleSelectAll} onCheckedChange={handleSelectAll}
className={cn( className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
"border-gray-400",
isIndeterminate && "data-[state=checked]:bg-primary"
)}
/> />
</th> </th>
{columns.map((col) => { {columns.map((col) => {
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId; const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
const activeOption = hasDynamicSource const activeOption = hasDynamicSource
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0] ? col.dynamicDataSource!.options.find((opt) => opt.id === activeOptionId) ||
col.dynamicDataSource!.options[0]
: null; : null;
const isExpanded = expandedColumns.has(col.field);
return ( return (
<th <th
key={col.field} key={col.field}
className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 relative group cursor-pointer select-none" className="group relative cursor-pointer border-r border-b border-gray-200 px-3 py-2 text-left font-medium whitespace-nowrap text-gray-700 select-none"
style={{ width: `${columnWidths[col.field]}px` }} style={{ width: `${columnWidths[col.field]}px` }}
onDoubleClick={() => handleDoubleClick(col.field)} onDoubleClick={() => handleDoubleClick(col.field)}
title={isExpanded ? "더블클릭하여 기본 너비로 복구" : "더블클릭하여 내용에 맞게 확장"} title="더블클릭하여 글자 너비에 맞춤"
> >
<div className="flex items-center justify-between pointer-events-none"> <div className="pointer-events-none flex items-center justify-between">
<div className="flex items-center gap-1 pointer-events-auto"> <div className="pointer-events-auto flex items-center gap-1">
{hasDynamicSource ? ( {hasDynamicSource ? (
<Popover <Popover
open={openPopover === col.field} open={openPopover === col.field}
@ -522,20 +558,19 @@ export function RepeaterTable({
<button <button
type="button" type="button"
className={cn( className={cn(
"inline-flex items-center gap-1 hover:text-blue-600 transition-colors", "inline-flex items-center gap-1 transition-colors hover:text-blue-600",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded px-1 -mx-1" "-mx-1 rounded px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
)} )}
> >
<span>{col.label}</span> {/* 컬럼명 - 선택된 옵션라벨 형식으로 표시 */}
<span>
{activeOption?.headerLabel || `${col.label} - ${activeOption?.label || ''}`}
</span>
<ChevronDown className="h-3 w-3 opacity-60" /> <ChevronDown className="h-3 w-3 opacity-60" />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent className="w-auto min-w-[160px] p-1" align="start" sideOffset={4}>
className="w-auto min-w-[160px] p-1" <div className="text-muted-foreground mb-1 border-b px-2 py-1 text-[10px]">
align="start"
sideOffset={4}
>
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
</div> </div>
{col.dynamicDataSource!.options.map((option) => ( {col.dynamicDataSource!.options.map((option) => (
@ -545,18 +580,26 @@ export function RepeaterTable({
onClick={() => { onClick={() => {
onDataSourceChange?.(col.field, option.id); onDataSourceChange?.(col.field, option.id);
setOpenPopover(null); setOpenPopover(null);
// 옵션 변경 시 해당 컬럼 너비 재계산
if (option.headerLabel) {
const newHeaderWidth = measureTextWidth(option.headerLabel) + 32;
setColumnWidths((prev) => ({
...prev,
[col.field]: Math.max(prev[col.field] || 60, newHeaderWidth),
}));
}
}} }}
className={cn( className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm", "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs",
"hover:bg-accent hover:text-accent-foreground transition-colors", "hover:bg-accent hover:text-accent-foreground transition-colors",
"focus:outline-none focus-visible:bg-accent", "focus-visible:bg-accent focus:outline-none",
activeOption?.id === option.id && "bg-accent/50" activeOption?.id === option.id && "bg-accent/50",
)} )}
> >
<Check <Check
className={cn( className={cn(
"h-3 w-3", "h-3 w-3",
activeOption?.id === option.id ? "opacity-100" : "opacity-0" activeOption?.id === option.id ? "opacity-100" : "opacity-0",
)} )}
/> />
<span>{option.label}</span> <span>{option.label}</span>
@ -567,13 +610,13 @@ export function RepeaterTable({
) : ( ) : (
<> <>
{col.label} {col.label}
{col.required && <span className="text-red-500 ml-1">*</span>} {col.required && <span className="ml-1 text-red-500">*</span>}
</> </>
)} )}
</div> </div>
{/* 리사이즈 핸들 */} {/* 리사이즈 핸들 */}
<div <div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto" className="pointer-events-auto absolute top-0 right-0 bottom-0 w-1 cursor-col-resize opacity-0 transition-opacity group-hover:opacity-100 hover:bg-blue-500"
onMouseDown={(e) => handleMouseDown(e, col.field)} onMouseDown={(e) => handleMouseDown(e, col.field)}
title="드래그하여 너비 조정" title="드래그하여 너비 조정"
/> />
@ -589,7 +632,7 @@ export function RepeaterTable({
<tr> <tr>
<td <td
colSpan={columns.length + 2} colSpan={columns.length + 2}
className="px-4 py-8 text-center text-gray-500 border-b border-gray-200" className="border-b border-gray-200 px-4 py-8 text-center text-gray-500"
> >
</td> </td>
@ -600,19 +643,19 @@ export function RepeaterTable({
key={`row-${rowIndex}`} key={`row-${rowIndex}`}
id={`row-${rowIndex}`} id={`row-${rowIndex}`}
className={cn( className={cn(
"hover:bg-blue-50/50 transition-colors", "transition-colors hover:bg-blue-50/50",
selectedRows.has(rowIndex) && "bg-blue-50" selectedRows.has(rowIndex) && "bg-blue-50",
)} )}
> >
{({ attributes, listeners, isDragging }) => ( {({ attributes, listeners, isDragging }) => (
<> <>
{/* 드래그 핸들 */} {/* 드래그 핸들 */}
<td className="px-1 py-1 text-center border-b border-r border-gray-200"> <td className="border-r border-b border-gray-200 px-1 py-1 text-center">
<button <button
type="button" type="button"
className={cn( className={cn(
"cursor-grab p-1 rounded hover:bg-gray-100 transition-colors", "cursor-grab rounded p-1 transition-colors hover:bg-gray-100",
isDragging && "cursor-grabbing" isDragging && "cursor-grabbing",
)} )}
{...attributes} {...attributes}
{...listeners} {...listeners}
@ -621,7 +664,7 @@ export function RepeaterTable({
</button> </button>
</td> </td>
{/* 체크박스 */} {/* 체크박스 */}
<td className="px-3 py-1 text-center border-b border-r border-gray-200"> <td className="border-r border-b border-gray-200 px-3 py-1 text-center">
<Checkbox <Checkbox
checked={selectedRows.has(rowIndex)} checked={selectedRows.has(rowIndex)}
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)} onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
@ -632,8 +675,11 @@ export function RepeaterTable({
{columns.map((col) => ( {columns.map((col) => (
<td <td
key={col.field} key={col.field}
className="px-1 py-1 border-b border-r border-gray-200 overflow-hidden" className="overflow-hidden border-r border-b border-gray-200 px-1 py-1"
style={{ width: `${columnWidths[col.field]}px`, maxWidth: `${columnWidths[col.field]}px` }} style={{
width: `${columnWidths[col.field]}px`,
maxWidth: `${columnWidths[col.field]}px`,
}}
> >
{renderCell(row, col, rowIndex)} {renderCell(row, col, rowIndex)}
</td> </td>
@ -651,4 +697,3 @@ export function RepeaterTable({
</DndContext> </DndContext>
); );
} }

View File

@ -17,6 +17,7 @@ export interface ModalRepeaterTableProps {
modalTitle: string; // 모달 제목 (예: "품목 검색 및 선택") modalTitle: string; // 모달 제목 (예: "품목 검색 및 선택")
modalButtonText?: string; // 모달 열기 버튼 텍스트 (기본: "품목 검색") modalButtonText?: string; // 모달 열기 버튼 텍스트 (기본: "품목 검색")
multiSelect?: boolean; // 다중 선택 허용 (기본: true) multiSelect?: boolean; // 다중 선택 허용 (기본: true)
modalFilters?: ModalFilterConfig[]; // 모달 내 필터 설정
// Repeater 테이블 설정 // Repeater 테이블 설정
columns: RepeaterColumnConfig[]; // 테이블 컬럼 설정 columns: RepeaterColumnConfig[]; // 테이블 컬럼 설정
@ -75,6 +76,7 @@ export interface DynamicDataSourceConfig {
export interface DynamicDataSourceOption { export interface DynamicDataSourceOption {
id: string; id: string;
label: string; // 표시 라벨 (예: "거래처별 단가") label: string; // 표시 라벨 (예: "거래처별 단가")
headerLabel?: string; // 헤더에 표시될 전체 라벨 (예: "단가 - 거래처별 단가")
// 조회 방식 // 조회 방식
sourceType: "table" | "multiTable" | "api"; sourceType: "table" | "multiTable" | "api";
@ -175,6 +177,14 @@ export interface CalculationRule {
dependencies: string[]; // 의존하는 필드들 dependencies: string[]; // 의존하는 필드들
} }
// 모달 필터 설정 (간소화된 버전)
export interface ModalFilterConfig {
column: string; // 필터 대상 컬럼 (소스 테이블의 컬럼명)
label: string; // 필터 라벨 (UI에 표시될 이름)
type: "select" | "text"; // select: 드롭다운 (distinct 값), text: 텍스트 입력
defaultValue?: string; // 기본값
}
export interface ItemSelectionModalProps { export interface ItemSelectionModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@ -188,4 +198,7 @@ export interface ItemSelectionModalProps {
uniqueField?: string; uniqueField?: string;
onSelect: (items: Record<string, unknown>[]) => void; onSelect: (items: Record<string, unknown>[]) => void;
columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑 columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑
// 모달 내부 필터 (사용자 선택 가능)
modalFilters?: ModalFilterConfig[];
} }

View File

@ -156,23 +156,49 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 🆕 연쇄 드롭다운 설정 확인 // 🆕 연쇄 드롭다운 설정 확인
const cascadingRelationCode = config?.cascadingRelationCode || componentConfig?.cascadingRelationCode; const cascadingRelationCode = config?.cascadingRelationCode || componentConfig?.cascadingRelationCode;
// 🆕 카테고리 값 연쇄관계 설정
const categoryRelationCode = config?.categoryRelationCode || componentConfig?.categoryRelationCode;
const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child"; const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child";
const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField; const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField;
// 자식 역할일 때만 부모 값 필요
const parentValue = cascadingRole === "child" && cascadingParentField && formData // 🆕 자식 역할일 때 부모 값 추출 (단일 또는 다중)
const rawParentValue = cascadingRole === "child" && cascadingParentField && formData
? formData[cascadingParentField] ? formData[cascadingParentField]
: undefined; : undefined;
// 🆕 연쇄 드롭다운 훅 사용 (역할에 따라 다른 옵션 로드) // 🆕 부모값이 콤마로 구분된 문자열이면 배열로 변환 (다중 선택 지원)
const parentValues: string[] | undefined = useMemo(() => {
if (!rawParentValue) return undefined;
// 이미 배열인 경우
if (Array.isArray(rawParentValue)) {
return rawParentValue.map(v => String(v)).filter(v => v);
}
// 콤마로 구분된 문자열인 경우
const strValue = String(rawParentValue);
if (strValue.includes(',')) {
return strValue.split(',').map(v => v.trim()).filter(v => v);
}
// 단일 값
return [strValue];
}, [rawParentValue]);
// 🆕 연쇄 드롭다운 훅 사용 (역할에 따라 다른 옵션 로드) - 다중 부모값 지원
const { const {
options: cascadingOptions, options: cascadingOptions,
loading: isLoadingCascading, loading: isLoadingCascading,
} = useCascadingDropdown({ } = useCascadingDropdown({
relationCode: cascadingRelationCode, relationCode: cascadingRelationCode,
categoryRelationCode: categoryRelationCode, // 🆕 카테고리 값 연쇄관계 지원
role: cascadingRole, // 부모/자식 역할 전달 role: cascadingRole, // 부모/자식 역할 전달
parentValue: parentValue, parentValues: parentValues, // 다중 부모값
}); });
// 🆕 카테고리 값 연쇄관계가 활성화되었는지 확인
const hasCategoryRelation = !!categoryRelationCode;
useEffect(() => { useEffect(() => {
if (webType === "category" && component.tableName && component.columnName) { if (webType === "category" && component.tableName && component.columnName) {
console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", { console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", {
@ -324,6 +350,10 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 선택된 값에 따른 라벨 업데이트 // 선택된 값에 따른 라벨 업데이트
useEffect(() => { useEffect(() => {
const getAllOptionsForLabel = () => { const getAllOptionsForLabel = () => {
// 🆕 카테고리 값 연쇄관계가 설정된 경우 연쇄 옵션 사용
if (categoryRelationCode) {
return cascadingOptions;
}
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용 // 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
if (cascadingRelationCode) { if (cascadingRelationCode) {
return cascadingOptions; return cascadingOptions;
@ -353,7 +383,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
if (newLabel !== selectedLabel) { if (newLabel !== selectedLabel) {
setSelectedLabel(newLabel); setSelectedLabel(newLabel);
} }
}, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode]); }, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode, categoryRelationCode]);
// 클릭 이벤트 핸들러 (React Query로 간소화) // 클릭 이벤트 핸들러 (React Query로 간소화)
const handleToggle = () => { const handleToggle = () => {
@ -404,6 +434,10 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 모든 옵션 가져오기 // 모든 옵션 가져오기
const getAllOptions = () => { const getAllOptions = () => {
// 🆕 카테고리 값 연쇄관계가 설정된 경우 연쇄 옵션 사용
if (categoryRelationCode) {
return cascadingOptions;
}
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용 // 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
if (cascadingRelationCode) { if (cascadingRelationCode) {
return cascadingOptions; return cascadingOptions;
@ -776,7 +810,30 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
{(isLoadingCodes || isLoadingCategories) ? ( {(isLoadingCodes || isLoadingCategories) ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div> <div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? ( ) : allOptions.length > 0 ? (
allOptions.map((option, index) => { (() => {
// 부모별 그룹핑 (카테고리 연쇄관계인 경우)
const hasParentInfo = allOptions.some((opt: any) => opt.parent_label);
if (hasParentInfo) {
// 부모별로 그룹핑
const groupedOptions: Record<string, { parentLabel: string; options: typeof allOptions }> = {};
allOptions.forEach((opt: any) => {
const parentKey = opt.parent_value || "기타";
const parentLabel = opt.parent_label || "기타";
if (!groupedOptions[parentKey]) {
groupedOptions[parentKey] = { parentLabel, options: [] };
}
groupedOptions[parentKey].options.push(opt);
});
return Object.entries(groupedOptions).map(([parentKey, group]) => (
<div key={parentKey}>
{/* 그룹 헤더 */}
<div className="sticky top-0 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-600 border-b">
{group.parentLabel}
</div>
{/* 그룹 옵션들 */}
{group.options.map((option, index) => {
const isOptionSelected = selectedValues.includes(option.value); const isOptionSelected = selectedValues.includes(option.value);
return ( return (
<div <div
@ -802,7 +859,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
checked={isOptionSelected} checked={isOptionSelected}
value={option.value} value={option.value}
onChange={(e) => { onChange={(e) => {
// 체크박스 직접 클릭 시에도 올바른 값으로 처리
e.stopPropagation(); e.stopPropagation();
const newVals = isOptionSelected const newVals = isOptionSelected
? selectedValues.filter((v) => v !== option.value) ? selectedValues.filter((v) => v !== option.value)
@ -819,7 +875,56 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
</div> </div>
</div> </div>
); );
}) })}
</div>
));
}
// 부모 정보가 없으면 기존 방식
return allOptions.map((option, index) => {
const isOptionSelected = selectedValues.includes(option.value);
return (
<div
key={`${option.value}-${index}`}
className={cn(
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
isOptionSelected && "bg-blue-50 font-medium"
)}
onClick={() => {
const newVals = isOptionSelected
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
setSelectedValues(newVals);
const newValue = newVals.join(",");
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newValue);
}
}}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isOptionSelected}
value={option.value}
onChange={(e) => {
e.stopPropagation();
const newVals = isOptionSelected
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
setSelectedValues(newVals);
const newValue = newVals.join(",");
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newValue);
}
}}
className="h-4 w-4 pointer-events-auto"
/>
<span>{option.label || option.value}</span>
</div>
</div>
);
});
})()
) : ( ) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div> <div className="bg-white px-3 py-2 text-gray-900"> </div>
)} )}

View File

@ -11,6 +11,7 @@ import { Link2, ExternalLink } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { SelectBasicConfig } from "./types"; import { SelectBasicConfig } from "./types";
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation"; import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
import { categoryValueCascadingApi, CategoryValueCascadingGroup } from "@/lib/api/categoryValueCascading";
export interface SelectBasicConfigPanelProps { export interface SelectBasicConfigPanelProps {
config: SelectBasicConfig; config: SelectBasicConfig;
@ -36,6 +37,11 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
const [relationList, setRelationList] = useState<CascadingRelation[]>([]); const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
const [loadingRelations, setLoadingRelations] = useState(false); const [loadingRelations, setLoadingRelations] = useState(false);
// 🆕 카테고리 값 연쇄관계 상태
const [categoryRelationEnabled, setCategoryRelationEnabled] = useState(!!(config as any).categoryRelationCode);
const [categoryRelationList, setCategoryRelationList] = useState<CategoryValueCascadingGroup[]>([]);
const [loadingCategoryRelations, setLoadingCategoryRelations] = useState(false);
// 연쇄 관계 목록 로드 // 연쇄 관계 목록 로드
useEffect(() => { useEffect(() => {
if (cascadingEnabled && relationList.length === 0) { if (cascadingEnabled && relationList.length === 0) {
@ -43,10 +49,18 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
} }
}, [cascadingEnabled]); }, [cascadingEnabled]);
// 🆕 카테고리 값 연쇄관계 목록 로드
useEffect(() => {
if (categoryRelationEnabled && categoryRelationList.length === 0) {
loadCategoryRelationList();
}
}, [categoryRelationEnabled]);
// config 변경 시 상태 동기화 // config 변경 시 상태 동기화
useEffect(() => { useEffect(() => {
setCascadingEnabled(!!config.cascadingRelationCode); setCascadingEnabled(!!config.cascadingRelationCode);
}, [config.cascadingRelationCode]); setCategoryRelationEnabled(!!(config as any).categoryRelationCode);
}, [config.cascadingRelationCode, (config as any).categoryRelationCode]);
const loadRelationList = async () => { const loadRelationList = async () => {
setLoadingRelations(true); setLoadingRelations(true);
@ -62,6 +76,21 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
} }
}; };
// 🆕 카테고리 값 연쇄관계 목록 로드
const loadCategoryRelationList = async () => {
setLoadingCategoryRelations(true);
try {
const response = await categoryValueCascadingApi.getGroups("Y");
if (response.success && response.data) {
setCategoryRelationList(response.data);
}
} catch (error) {
console.error("카테고리 값 연쇄관계 목록 로드 실패:", error);
} finally {
setLoadingCategoryRelations(false);
}
};
const handleChange = (key: keyof SelectBasicConfig, value: any) => { const handleChange = (key: keyof SelectBasicConfig, value: any) => {
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호) // 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
const newConfig = { ...config, [key]: value }; const newConfig = { ...config, [key]: value };
@ -82,6 +111,33 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
onChange(newConfig); onChange(newConfig);
} else { } else {
loadRelationList(); loadRelationList();
// 카테고리 값 연쇄관계 비활성화 (둘 중 하나만 사용)
if (categoryRelationEnabled) {
setCategoryRelationEnabled(false);
onChange({ ...config, categoryRelationCode: undefined } as any);
}
}
};
// 🆕 카테고리 값 연쇄관계 토글
const handleCategoryRelationToggle = (enabled: boolean) => {
setCategoryRelationEnabled(enabled);
if (!enabled) {
// 비활성화 시 관계 설정 제거
const newConfig = {
...config,
categoryRelationCode: undefined,
cascadingRole: undefined,
cascadingParentField: undefined,
} as any;
onChange(newConfig);
} else {
loadCategoryRelationList();
// 일반 연쇄관계 비활성화 (둘 중 하나만 사용)
if (cascadingEnabled) {
setCascadingEnabled(false);
onChange({ ...config, cascadingRelationCode: undefined });
}
} }
}; };
@ -280,52 +336,56 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
)} )}
{/* 부모 필드 설정 (자식 역할일 때만) */} {/* 부모 필드 설정 (자식 역할일 때만) */}
{config.cascadingRelationCode && config.cascadingRole === "child" && ( {config.cascadingRelationCode && config.cascadingRole === "child" && (() => {
<div className="space-y-2"> // 선택된 관계에서 부모 값 컬럼 가져오기
<Label className="text-xs"> </Label> const expectedParentColumn = selectedRelation?.parent_value_column;
{(() => {
const parentComp = findParentComponent(config.cascadingRelationCode); // 부모 역할에 맞는 컴포넌트만 필터링
const isAutoDetected = parentComp && config.cascadingParentField === parentComp.columnName; const parentFieldCandidates = allComponents.filter((comp) => {
// 현재 컴포넌트 제외
if (currentComponent && comp.id === currentComponent.id) return false;
// 관계에서 지정한 부모 컬럼명과 일치하는 컴포넌트만
if (expectedParentColumn && comp.columnName !== expectedParentColumn) return false;
// columnName이 있어야 함
return !!comp.columnName;
});
return ( return (
<> <div className="space-y-2">
<div className="flex gap-2 items-center"> <Label className="text-xs"> </Label>
<Input {expectedParentColumn && (
value={config.cascadingParentField || ""}
onChange={(e) => handleChange("cascadingParentField", e.target.value || undefined)}
placeholder="예: warehouse_code"
className="text-xs flex-1"
/>
{parentComp && !isAutoDetected && (
<Button
type="button"
size="sm"
variant="outline"
className="text-xs shrink-0"
onClick={() => handleChange("cascadingParentField", parentComp.columnName)}
>
</Button>
)}
</div>
{isAutoDetected ? (
<p className="text-xs text-green-600">
: {parentComp.label || parentComp.columnName}
</p>
) : parentComp ? (
<p className="text-xs text-amber-600">
: {parentComp.columnName} ({parentComp.label || "라벨 없음"})
</p>
) : (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
. . : <strong>{expectedParentColumn}</strong>
</p> </p>
)} )}
</> <Select
value={config.cascadingParentField || ""}
onValueChange={(value) => handleChange("cascadingParentField", value || undefined)}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder="부모 필드 선택" />
</SelectTrigger>
<SelectContent>
{parentFieldCandidates.map((comp) => (
<SelectItem key={comp.id} value={comp.columnName}>
{comp.label || comp.columnName} ({comp.columnName})
</SelectItem>
))}
{parentFieldCandidates.length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{expectedParentColumn
? `'${expectedParentColumn}' 컬럼을 가진 컴포넌트가 화면에 없습니다`
: "선택 가능한 부모 필드가 없습니다"}
</div>
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
); );
})()} })()}
</div>
)}
{/* 선택된 관계 정보 표시 */} {/* 선택된 관계 정보 표시 */}
{selectedRelation && config.cascadingRole && ( {selectedRelation && config.cascadingRole && (
@ -374,6 +434,152 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
</div> </div>
)} )}
</div> </div>
{/* 🆕 카테고리 값 연쇄관계 설정 */}
<div className="border-t pt-4 mt-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
<Label className="text-sm font-medium"> </Label>
</div>
<Switch
checked={categoryRelationEnabled}
onCheckedChange={handleCategoryRelationToggle}
/>
</div>
<p className="text-muted-foreground text-xs">
.
<br />: 검사유형
</p>
{categoryRelationEnabled && (
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
{/* 관계 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={(config as any).categoryRelationCode || ""}
onValueChange={(value) => handleChange("categoryRelationCode" as any, value || undefined)}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder={loadingCategoryRelations ? "로딩 중..." : "관계 선택"} />
</SelectTrigger>
<SelectContent>
{categoryRelationList.map((relation) => (
<SelectItem key={relation.relation_code} value={relation.relation_code}>
<div className="flex flex-col">
<span>{relation.relation_name}</span>
<span className="text-muted-foreground text-xs">
{relation.parent_table_name}.{relation.parent_column_name} {relation.child_table_name}.{relation.child_column_name}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 역할 선택 */}
{(config as any).categoryRelationCode && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={config.cascadingRole === "parent" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("parent")}
>
( )
</Button>
<Button
type="button"
size="sm"
variant={config.cascadingRole === "child" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("child")}
>
( )
</Button>
</div>
<p className="text-muted-foreground text-xs">
{config.cascadingRole === "parent"
? "이 필드가 상위 카테고리 선택 역할을 합니다. (예: 검사유형)"
: config.cascadingRole === "child"
? "이 필드는 상위 카테고리 값에 따라 옵션이 변경됩니다. (예: 적용대상)"
: "이 필드의 역할을 선택하세요."}
</p>
</div>
)}
{/* 부모 필드 설정 (자식 역할일 때만) */}
{(config as any).categoryRelationCode && config.cascadingRole === "child" && (() => {
// 선택된 관계 정보 가져오기
const selectedRelation = categoryRelationList.find(
(r) => r.relation_code === (config as any).categoryRelationCode
);
const expectedParentColumn = selectedRelation?.parent_column_name;
// 부모 역할에 맞는 컴포넌트만 필터링
const parentFieldCandidates = allComponents.filter((comp) => {
// 현재 컴포넌트 제외
if (currentComponent && comp.id === currentComponent.id) return false;
// 관계에서 지정한 부모 컬럼명과 일치하는 컴포넌트만
if (expectedParentColumn && comp.columnName !== expectedParentColumn) return false;
// columnName이 있어야 함
return !!comp.columnName;
});
return (
<div className="space-y-2">
<Label className="text-xs"> </Label>
{expectedParentColumn && (
<p className="text-muted-foreground text-xs">
: <strong>{expectedParentColumn}</strong>
</p>
)}
<Select
value={config.cascadingParentField || ""}
onValueChange={(value) => handleChange("cascadingParentField", value || undefined)}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder="부모 필드 선택" />
</SelectTrigger>
<SelectContent>
{parentFieldCandidates.map((comp) => (
<SelectItem key={comp.id} value={comp.columnName}>
{comp.label || comp.columnName} ({comp.columnName})
</SelectItem>
))}
{parentFieldCandidates.length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{expectedParentColumn
? `'${expectedParentColumn}' 컬럼을 가진 컴포넌트가 화면에 없습니다`
: "선택 가능한 부모 필드가 없습니다"}
</div>
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
);
})()}
{/* 관계 관리 페이지 링크 */}
<div className="flex justify-end">
<Link href="/admin/cascading-management?tab=category-value" target="_blank">
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
<ExternalLink className="mr-1 h-3 w-3" />
</Button>
</Link>
</div>
</div>
)}
</div>
</div> </div>
); );
}; };

View File

@ -687,7 +687,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false); const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
const [showGridLines, setShowGridLines] = useState(true); const [showGridLines, setShowGridLines] = useState(true);
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table"); const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
const [frozenColumns, setFrozenColumns] = useState<string[]>([]); // 체크박스 컬럼은 항상 기본 틀고정
const [frozenColumns, setFrozenColumns] = useState<string[]>(
(tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [],
);
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
// 🆕 Search Panel (통합 검색) 관련 상태 // 🆕 Search Panel (통합 검색) 관련 상태
const [globalSearchTerm, setGlobalSearchTerm] = useState(""); const [globalSearchTerm, setGlobalSearchTerm] = useState("");
@ -1022,6 +1026,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
getColumnUniqueValues, // 고유 값 조회 함수 등록 getColumnUniqueValues, // 고유 값 조회 함수 등록
onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정 onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
// 틀고정 컬럼 관련
frozenColumnCount, // 현재 틀고정 컬럼 수
onFrozenColumnCountChange: (count: number) => {
setFrozenColumnCount(count);
// 체크박스 컬럼은 항상 틀고정에 포함
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
// 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정
const visibleCols = columnsToRegister
.filter((col) => col.visible !== false)
.map((col) => col.columnName || col.field);
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)];
setFrozenColumns(newFrozenColumns);
},
// 탭 관련 정보 (탭 내부의 테이블인 경우) // 탭 관련 정보 (탭 내부의 테이블인 경우)
parentTabId, parentTabId,
parentTabsComponentId, parentTabsComponentId,
@ -1033,6 +1050,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return () => { return () => {
unregisterTable(tableId); unregisterTable(tableId);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
tableId, tableId,
tableConfig.selectedTable, tableConfig.selectedTable,
@ -1044,7 +1062,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용) data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
totalItems, // 전체 항목 수가 변경되면 재등록 totalItems, // 전체 항목 수가 변경되면 재등록
registerTable, registerTable,
unregisterTable, // unregisterTable은 의존성에서 제외 - 무한 루프 방지
// unregisterTable 함수는 의존성이 없어 안정적임
]); ]);
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 // 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기
@ -1292,17 +1311,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const parts = columnName.split("."); const parts = columnName.split(".");
targetTable = parts[0]; // 조인된 테이블명 (예: item_info) targetTable = parts[0]; // 조인된 테이블명 (예: item_info)
targetColumn = parts[1]; // 실제 컬럼명 (예: material) targetColumn = parts[1]; // 실제 컬럼명 (예: material)
console.log(`🔗 [TableList] 엔티티 조인 컬럼 감지:`, { console.log("🔗 [TableList] 엔티티 조인 컬럼 감지:", {
originalColumn: columnName, originalColumn: columnName,
targetTable, targetTable,
targetColumn, targetColumn,
}); });
} }
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
if (response.data.success && response.data.data && Array.isArray(response.data.data)) { if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
const mapping: Record<string, { label: string; color?: string }> = {}; const mapping: Record<string, { label: string; color?: string }> = {};
@ -1357,7 +1374,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
col.columnName, col.columnName,
})) || []; })) || [];
// 조인 테이블별로 그룹화 // 조인 테이블별로 그룹화
const joinedTableColumns: Record<string, { columnName: string; actualColumn: string }[]> = {}; const joinedTableColumns: Record<string, { columnName: string; actualColumn: string }[]> = {};
@ -1389,7 +1405,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}); });
} }
// 조인된 테이블별로 inputType 정보 가져오기 // 조인된 테이블별로 inputType 정보 가져오기
const newJoinedColumnMeta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {}; const newJoinedColumnMeta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
@ -1452,6 +1467,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta); console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta);
} }
// 🆕 카테고리 연쇄관계 매핑 로드 (category_value_cascading_mapping)
try {
const cascadingResponse = await apiClient.get(
`/category-value-cascading/table/${tableConfig.selectedTable}/mappings`,
);
if (cascadingResponse.data.success && cascadingResponse.data.data) {
const cascadingMappings = cascadingResponse.data.data;
// 각 자식 컬럼에 대한 매핑 추가
for (const [columnName, columnMappings] of Object.entries(
cascadingMappings as Record<string, Array<{ code: string; label: string }>>,
)) {
if (!mappings[columnName]) {
mappings[columnName] = {};
}
// 연쇄관계 매핑 추가
for (const item of columnMappings) {
mappings[columnName][item.code] = {
label: item.label,
color: undefined, // 연쇄관계는 색상 없음
};
}
}
console.log("✅ [TableList] 카테고리 연쇄관계 매핑 로드 완료:", {
tableName: tableConfig.selectedTable,
cascadingColumns: Object.keys(cascadingMappings),
});
}
} catch (cascadingError: any) {
// 연쇄관계 매핑이 없는 경우 무시 (404 등)
if (cascadingError?.response?.status !== 404) {
console.warn("⚠️ [TableList] 카테고리 연쇄관계 매핑 로드 실패:", cascadingError?.message);
}
}
if (Object.keys(mappings).length > 0) { if (Object.keys(mappings).length > 0) {
setCategoryMappings(mappings); setCategoryMappings(mappings);
setCategoryMappingsKey((prev) => prev + 1); setCategoryMappingsKey((prev) => prev + 1);
@ -1476,7 +1526,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// ======================================== // ========================================
const fetchTableDataInternal = useCallback(async () => { const fetchTableDataInternal = useCallback(async () => {
if (!tableConfig.selectedTable || isDesignMode) { if (!tableConfig.selectedTable || isDesignMode) {
setData([]); setData([]);
setTotalPages(0); setTotalPages(0);
@ -1495,11 +1544,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const search = searchTerm || undefined; const search = searchTerm || undefined;
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때) // 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
let linkedFilterValues: Record<string, any> = {}; const linkedFilterValues: Record<string, any> = {};
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부 let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부 let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
if (splitPanelContext) { if (splitPanelContext) {
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
const linkedFiltersConfig = splitPanelContext.linkedFilters || []; const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
@ -1590,7 +1638,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
// 🆕 RelatedDataButtons 필터 값 준비 // 🆕 RelatedDataButtons 필터 값 준비
let relatedButtonFilterValues: Record<string, any> = {}; const relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) { if (relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = { relatedButtonFilterValues[relatedButtonFilter.filterColumn] = {
value: relatedButtonFilter.filterValue, value: relatedButtonFilter.filterValue,
@ -1666,7 +1714,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}; };
}); });
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
let excludeFilterParam: any = undefined; let excludeFilterParam: any = undefined;
if (tableConfig.excludeFilter?.enabled) { if (tableConfig.excludeFilter?.enabled) {
@ -2408,7 +2455,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
try { try {
const { apiClient } = await import("@/lib/api/client"); const { apiClient } = await import("@/lib/api/client");
await apiClient.put(`/dynamic-form/update-field`, { await apiClient.put("/dynamic-form/update-field", {
tableName: tableConfig.selectedTable, tableName: tableConfig.selectedTable,
keyField: primaryKeyField, keyField: primaryKeyField,
keyValue: primaryKeyValue, keyValue: primaryKeyValue,
@ -2449,7 +2496,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 모든 변경사항 저장 // 모든 변경사항 저장
const savePromises = Array.from(pendingChanges.values()).map((change) => const savePromises = Array.from(pendingChanges.values()).map((change) =>
apiClient.put(`/dynamic-form/update-field`, { apiClient.put("/dynamic-form/update-field", {
tableName: tableConfig.selectedTable, tableName: tableConfig.selectedTable,
keyField: primaryKeyField, keyField: primaryKeyField,
keyValue: change.primaryKeyValue, keyValue: change.primaryKeyValue,
@ -2877,6 +2924,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
sortDirection, sortDirection,
groupByColumns, groupByColumns,
frozenColumns, frozenColumns,
frozenColumnCount, // 틀고정 컬럼 수 저장
showGridLines, showGridLines,
headerFilters: Object.fromEntries( headerFilters: Object.fromEntries(
Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set<string>)]), Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set<string>)]),
@ -2898,6 +2946,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
sortDirection, sortDirection,
groupByColumns, groupByColumns,
frozenColumns, frozenColumns,
frozenColumnCount,
showGridLines, showGridLines,
headerFilters, headerFilters,
localPageSize, localPageSize,
@ -2918,7 +2967,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (state.sortColumn !== undefined) setSortColumn(state.sortColumn); if (state.sortColumn !== undefined) setSortColumn(state.sortColumn);
if (state.sortDirection) setSortDirection(state.sortDirection); if (state.sortDirection) setSortDirection(state.sortDirection);
if (state.groupByColumns) setGroupByColumns(state.groupByColumns); if (state.groupByColumns) setGroupByColumns(state.groupByColumns);
if (state.frozenColumns) setFrozenColumns(state.frozenColumns); if (state.frozenColumns) {
// 체크박스 컬럼이 항상 포함되도록 보장
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null;
const restoredFrozenColumns =
checkboxColumn && !state.frozenColumns.includes(checkboxColumn)
? [checkboxColumn, ...state.frozenColumns]
: state.frozenColumns;
setFrozenColumns(restoredFrozenColumns);
}
if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원
if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines); if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines);
if (state.headerFilters) { if (state.headerFilters) {
const filters: Record<string, Set<string>> = {}; const filters: Record<string, Set<string>> = {};
@ -2927,7 +2985,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}); });
setHeaderFilters(filters); setHeaderFilters(filters);
} }
} catch (error) { } catch (error) {
console.error("❌ 테이블 상태 복원 실패:", error); console.error("❌ 테이블 상태 복원 실패:", error);
} }
@ -3547,7 +3604,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
})); }));
// 배치 업데이트 // 배치 업데이트
await Promise.all(updates.map((update) => apiClient.put(`/dynamic-form/update-field`, update))); await Promise.all(updates.map((update) => apiClient.put("/dynamic-form/update-field", update)));
toast.success("순서가 변경되었습니다."); toast.success("순서가 변경되었습니다.");
setRefreshTrigger((prev) => prev + 1); setRefreshTrigger((prev) => prev + 1);
@ -4898,7 +4955,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// relatedButtonFilter가 있으면 데이터 로드, null이면 빈 상태 (setRefreshTrigger로 트리거) // relatedButtonFilter가 있으면 데이터 로드, null이면 빈 상태 (setRefreshTrigger로 트리거)
console.log("🔄 [TableList] RelatedDataButtons 상태 변경:", { console.log("🔄 [TableList] RelatedDataButtons 상태 변경:", {
relatedButtonFilter, relatedButtonFilter,
isRelatedButtonTarget isRelatedButtonTarget,
}); });
setRefreshTrigger((prev) => prev + 1); setRefreshTrigger((prev) => prev + 1);
} }
@ -5588,7 +5645,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (isFrozen && frozenIndex > 0) { if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) { for (let i = 0; i < frozenIndex; i++) {
const frozenCol = frozenColumns[i]; const frozenCol = frozenColumns[i];
const frozenColWidth = columnWidths[frozenCol] || 150; // 체크박스 컬럼은 48px 고정
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
leftPosition += frozenColWidth; leftPosition += frozenColWidth;
} }
} }
@ -5607,7 +5665,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
column.sortable !== false && column.sortable !== false &&
column.columnName !== "__checkbox__" && column.columnName !== "__checkbox__" &&
"hover:bg-muted/70 cursor-pointer transition-colors", "hover:bg-muted/70 cursor-pointer transition-colors",
isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", isFrozen && "sticky z-40 shadow-[2px_0_4px_rgba(0,0,0,0.1)]",
// 🆕 Column Reordering 스타일 // 🆕 Column Reordering 스타일
isColumnDragEnabled && isColumnDragEnabled &&
column.columnName !== "__checkbox__" && column.columnName !== "__checkbox__" &&
@ -5899,7 +5957,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (isFrozen && frozenIndex > 0) { if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) { for (let i = 0; i < frozenIndex; i++) {
const frozenCol = frozenColumns[i]; const frozenCol = frozenColumns[i];
const frozenColWidth = columnWidths[frozenCol] || 150; // 체크박스 컬럼은 48px 고정
const frozenColWidth =
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
leftPosition += frozenColWidth; leftPosition += frozenColWidth;
} }
} }
@ -5912,7 +5972,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
column.columnName === "__checkbox__" column.columnName === "__checkbox__"
? "px-0 py-1" ? "px-0 py-1"
: "px-2 py-1 sm:px-4 sm:py-1.5", : "px-2 py-1 sm:px-4 sm:py-1.5",
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]", isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
)} )}
style={{ style={{
textAlign: textAlign:
@ -5927,7 +5987,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
: `${100 / visibleColumns.length}%`, : `${100 / visibleColumns.length}%`,
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
...(isFrozen && { left: `${leftPosition}px` }), ...(isFrozen && {
left: `${leftPosition}px`,
backgroundColor: "hsl(var(--background))",
}),
}} }}
> >
{column.columnName === "__checkbox__" {column.columnName === "__checkbox__"
@ -6059,7 +6122,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (isFrozen && frozenIndex > 0) { if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) { for (let i = 0; i < frozenIndex; i++) {
const frozenCol = frozenColumns[i]; const frozenCol = frozenColumns[i];
const frozenColWidth = columnWidths[frozenCol] || 150; // 체크박스 컬럼은 48px 고정
const frozenColWidth =
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
leftPosition += frozenColWidth; leftPosition += frozenColWidth;
} }
} }
@ -6072,7 +6137,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
className={cn( className={cn(
"text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap sm:text-sm", "text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5", column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]", isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
// 🆕 포커스된 셀 스타일 // 🆕 포커스된 셀 스타일
isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset", isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset",
// 🆕 편집 중인 셀 스타일 // 🆕 편집 중인 셀 스타일
@ -6099,7 +6164,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
column.columnName === "__checkbox__" ? "48px" : `${100 / visibleColumns.length}%`, column.columnName === "__checkbox__" ? "48px" : `${100 / visibleColumns.length}%`,
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
...(isFrozen && { left: `${leftPosition}px` }), ...(isFrozen && {
left: `${leftPosition}px`,
backgroundColor: "hsl(var(--background))",
}),
}} }}
onClick={(e) => handleCellClick(index, colIndex, e)} onClick={(e) => handleCellClick(index, colIndex, e)}
onDoubleClick={() => onDoubleClick={() =>
@ -6220,7 +6288,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (isFrozen && frozenIndex > 0) { if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) { for (let i = 0; i < frozenIndex; i++) {
const frozenCol = frozenColumns[i]; const frozenCol = frozenColumns[i];
const frozenColWidth = columnWidths[frozenCol] || 150; // 체크박스 컬럼은 48px 고정
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
leftPosition += frozenColWidth; leftPosition += frozenColWidth;
} }
} }
@ -6235,7 +6304,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
className={cn( className={cn(
"text-foreground text-xs font-semibold sm:text-sm", "text-foreground text-xs font-semibold sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-4", column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-4",
isFrozen && "bg-muted/80 sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]", isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
)} )}
style={{ style={{
textAlign: isNumeric ? "right" : column.align || "left", textAlign: isNumeric ? "right" : column.align || "left",
@ -6245,7 +6314,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
: columnWidth : columnWidth
? `${columnWidth}px` ? `${columnWidth}px`
: undefined, : undefined,
...(isFrozen && { left: `${leftPosition}px` }), ...(isFrozen && {
left: `${leftPosition}px`,
backgroundColor: "hsl(var(--muted) / 0.8)",
}),
}} }}
> >
{summary ? ( {summary ? (

View File

@ -138,33 +138,84 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// currentTable은 tableList(필터링된 목록)에서 가져와야 함 // currentTable은 tableList(필터링된 목록)에서 가져와야 함
const currentTable = useMemo(() => { const currentTable = useMemo(() => {
console.log("🔍 [TableSearchWidget] currentTable 계산:", {
selectedTableId,
tableListLength: tableList.length,
tableList: tableList.map(t => ({ id: t.tableId, name: t.tableName, parentTabId: t.parentTabId }))
});
if (!selectedTableId) return undefined; if (!selectedTableId) return undefined;
// 먼저 tableList(필터링된 목록)에서 찾기 // 먼저 tableList(필터링된 목록)에서 찾기
const tableFromList = tableList.find(t => t.tableId === selectedTableId); const tableFromList = tableList.find(t => t.tableId === selectedTableId);
if (tableFromList) { if (tableFromList) {
console.log("✅ [TableSearchWidget] 테이블 찾음 (tableList):", tableFromList.tableName);
return tableFromList; return tableFromList;
} }
// tableList에 없으면 전체에서 찾기 (폴백) // tableList에 없으면 전체에서 찾기 (폴백)
return getTable(selectedTableId); const tableFromAll = getTable(selectedTableId);
console.log("🔄 [TableSearchWidget] 테이블 찾음 (전체):", tableFromAll?.tableName);
return tableFromAll;
}, [selectedTableId, tableList, getTable]); }, [selectedTableId, tableList, getTable]);
// 🆕 활성 탭 ID 문자열 (변경 감지용)
const activeTabIdsStr = useMemo(() => activeTabIds.join(","), [activeTabIds]);
// 🆕 이전 활성 탭 ID 추적 (탭 전환 감지용)
const prevActiveTabIdsRef = useRef<string>(activeTabIdsStr);
// 대상 패널의 첫 번째 테이블 자동 선택 // 대상 패널의 첫 번째 테이블 자동 선택
useEffect(() => { useEffect(() => {
if (!autoSelectFirstTable || tableList.length === 0) { if (!autoSelectFirstTable || tableList.length === 0) {
return; return;
} }
// 🆕 탭 전환 감지: 활성 탭이 변경되었는지 확인
const tabChanged = prevActiveTabIdsRef.current !== activeTabIdsStr;
if (tabChanged) {
console.log("🔄 [TableSearchWidget] 탭 전환 감지:", {
이전탭: prevActiveTabIdsRef.current,
현재탭: activeTabIdsStr,
가용테이블: tableList.map(t => ({ id: t.tableId, tableName: t.tableName, parentTabId: t.parentTabId })),
현재선택테이블: selectedTableId
});
prevActiveTabIdsRef.current = activeTabIdsStr;
// 🆕 탭 전환 시: 해당 탭에 속한 테이블 중 첫 번째 강제 선택
const activeTabTable = tableList.find(t => t.parentTabId && activeTabIds.includes(t.parentTabId));
const targetTable = activeTabTable || tableList[0];
if (targetTable) {
console.log("✅ [TableSearchWidget] 탭 전환으로 테이블 강제 선택:", {
테이블ID: targetTable.tableId,
테이블명: targetTable.tableName,
탭ID: targetTable.parentTabId,
이전테이블: selectedTableId
});
setSelectedTableId(targetTable.tableId);
}
return; // 탭 전환 시에는 여기서 종료
}
// 현재 선택된 테이블이 대상 패널에 있는지 확인 // 현재 선택된 테이블이 대상 패널에 있는지 확인
const isCurrentTableInTarget = selectedTableId && tableList.some(t => t.tableId === selectedTableId); const isCurrentTableInTarget = selectedTableId && tableList.some(t => t.tableId === selectedTableId);
// 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택 // 현재 선택된 테이블이 대상 패널에 없으면 첫 번째 테이블 선택
if (!selectedTableId || !isCurrentTableInTarget) { if (!selectedTableId || !isCurrentTableInTarget) {
const targetTable = tableList[0]; const activeTabTable = tableList.find(t => t.parentTabId && activeTabIds.includes(t.parentTabId));
const targetTable = activeTabTable || tableList[0];
if (targetTable && targetTable.tableId !== selectedTableId) {
console.log("✅ [TableSearchWidget] 테이블 자동 선택 (초기):", {
테이블ID: targetTable.tableId,
테이블명: targetTable.tableName,
탭ID: targetTable.parentTabId
});
setSelectedTableId(targetTable.tableId); setSelectedTableId(targetTable.tableId);
} }
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]); }
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition, activeTabIdsStr, activeTabIds]);
// 현재 선택된 테이블의 탭 ID (탭별 필터 저장용) // 현재 선택된 테이블의 탭 ID (탭별 필터 저장용)
const currentTableTabId = currentTable?.parentTabId; const currentTableTabId = currentTable?.parentTabId;
@ -196,6 +247,13 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드) // 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
useEffect(() => { useEffect(() => {
console.log("📋 [TableSearchWidget] 필터 설정 useEffect 실행:", {
currentTable: currentTable?.tableName,
currentTableTabId,
filterMode,
selectedTableId,
컬럼수: currentTable?.columns?.length
});
if (!currentTable?.tableName) return; if (!currentTable?.tableName) return;
// 고정 모드: presetFilters를 activeFilters로 설정 // 고정 모드: presetFilters를 activeFilters로 설정
@ -229,12 +287,20 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
return; return;
} }
// 동적 모드: 화면별 + 탭별로 독립적인 필터 설정 불러오기 // 동적 모드: 화면별로 독립적인 필터 설정 불러오기
// 참고: FilterPanel.tsx에서도 screenId만 사용하여 저장하므로 키가 일치해야 함
const filterConfigKey = screenId const filterConfigKey = screenId
? `table_filters_${currentTable.tableName}_screen_${screenId}${currentTableTabId ? `_tab_${currentTableTabId}` : ''}` ? `table_filters_${currentTable.tableName}_screen_${screenId}`
: `table_filters_${currentTable.tableName}`; : `table_filters_${currentTable.tableName}`;
const savedFilters = localStorage.getItem(filterConfigKey); const savedFilters = localStorage.getItem(filterConfigKey);
console.log("🔑 [TableSearchWidget] 필터 설정 키 확인:", {
filterConfigKey,
savedFilters: savedFilters ? `${savedFilters.substring(0, 100)}...` : null,
screenId,
tableName: currentTable.tableName
});
if (savedFilters) { if (savedFilters) {
try { try {
const parsed = JSON.parse(savedFilters) as Array<{ const parsed = JSON.parse(savedFilters) as Array<{
@ -257,6 +323,13 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
width: f.width || 200, width: f.width || 200,
})); }));
console.log("📌 [TableSearchWidget] 필터 설정 로드:", {
filterConfigKey,
총필터수: parsed.length,
활성화필터수: activeFiltersList.length,
활성화필터: activeFiltersList.map(f => f.columnName)
});
setActiveFilters(activeFiltersList); setActiveFilters(activeFiltersList);
// 탭별 저장된 필터 값 복원 // 탭별 저장된 필터 값 복원
@ -280,10 +353,19 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
} }
} catch (error) { } catch (error) {
console.error("저장된 필터 불러오기 실패:", error); console.error("저장된 필터 불러오기 실패:", error);
// 파싱 에러 시 필터 초기화
setActiveFilters([]);
setFilterValues({});
} }
} else { } else {
// 필터 설정이 없으면 초기화 // 필터 설정이 없으면 activeFilters와 filterValues 모두 초기화
console.log("⚠️ [TableSearchWidget] 저장된 필터 설정 없음 - 필터 초기화:", {
tableName: currentTable.tableName,
filterConfigKey
});
setActiveFilters([]);
setFilterValues({}); setFilterValues({});
setSelectOptions({});
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]); }, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);

View File

@ -0,0 +1,884 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Plus, Columns, AlignJustify } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
// 기존 ModalRepeaterTable 컴포넌트 재사용
import { RepeaterTable } from "../modal-repeater-table/RepeaterTable";
import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal";
import { RepeaterColumnConfig, CalculationRule, DynamicDataSourceOption } from "../modal-repeater-table/types";
// 타입 정의
import {
TableSectionConfig,
TableColumnConfig,
ValueMappingConfig,
TableJoinCondition,
FormDataState,
} from "./types";
interface TableSectionRendererProps {
sectionId: string;
tableConfig: TableSectionConfig;
formData: FormDataState;
onFormDataChange: (field: string, value: any) => void;
onTableDataChange: (data: any[]) => void;
className?: string;
}
/**
* TableColumnConfig를 RepeaterColumnConfig로
* columnModes lookup이 dynamicDataSource로
*/
function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
const baseColumn: RepeaterColumnConfig = {
field: col.field,
label: col.label,
type: col.type,
editable: col.editable ?? true,
calculated: col.calculated ?? false,
width: col.width || "150px",
required: col.required,
defaultValue: col.defaultValue,
selectOptions: col.selectOptions,
// valueMapping은 별도로 처리
};
// lookup 설정을 dynamicDataSource로 변환 (새로운 조회 기능)
if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) {
baseColumn.dynamicDataSource = {
enabled: true,
options: col.lookup.options.map((option) => ({
id: option.id,
// "컬럼명 - 옵션라벨" 형식으로 헤더에 표시
label: option.displayLabel || option.label,
// 헤더에 표시될 전체 라벨 (컬럼명 - 옵션라벨)
headerLabel: `${col.label} - ${option.displayLabel || option.label}`,
sourceType: "table" as const,
tableConfig: {
tableName: option.tableName,
valueColumn: option.valueColumn,
joinConditions: option.conditions.map((cond) => ({
sourceField: cond.sourceField,
targetField: cond.targetColumn,
// sourceType에 따른 데이터 출처 설정
sourceType: cond.sourceType, // "currentRow" | "sectionField" | "externalTable"
fromFormData: cond.sourceType === "sectionField",
sectionId: cond.sectionId,
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환)
transform: cond.transform?.enabled ? {
tableName: cond.transform.tableName,
matchColumn: cond.transform.matchColumn,
resultColumn: cond.transform.resultColumn,
} : undefined,
})),
},
// 조회 유형 정보 추가
lookupType: option.type,
})),
defaultOptionId: col.lookup.options.find((o) => o.isDefault)?.id || col.lookup.options[0]?.id,
};
}
// columnModes를 dynamicDataSource로 변환 (기존 로직 유지)
else if (col.columnModes && col.columnModes.length > 0) {
baseColumn.dynamicDataSource = {
enabled: true,
options: col.columnModes.map((mode) => ({
id: mode.id,
label: mode.label,
sourceType: "table" as const,
// 실제 조회 로직은 TableSectionRenderer에서 처리
tableConfig: {
tableName: mode.valueMapping?.externalRef?.tableName || "",
valueColumn: mode.valueMapping?.externalRef?.valueColumn || "",
joinConditions: (mode.valueMapping?.externalRef?.joinConditions || []).map((jc) => ({
sourceField: jc.sourceField,
targetField: jc.targetColumn,
})),
},
})),
defaultOptionId: col.columnModes.find((m) => m.isDefault)?.id || col.columnModes[0]?.id,
};
}
return baseColumn;
}
/**
* TableCalculationRule을 CalculationRule로
*/
function convertToCalculationRule(calc: { resultField: string; formula: string; dependencies: string[] }): CalculationRule {
return {
result: calc.resultField,
formula: calc.formula,
dependencies: calc.dependencies,
};
}
/**
* 함수: 중간
* : 거래처 "(무)테스트업체" "CUST-0002"
*/
async function transformValue(
value: any,
transform: { tableName: string; matchColumn: string; resultColumn: string }
): Promise<any> {
if (!value || !transform.tableName || !transform.matchColumn || !transform.resultColumn) {
return value;
}
try {
// 정확히 일치하는 검색
const response = await apiClient.post(
`/table-management/tables/${transform.tableName}/data`,
{
search: {
[transform.matchColumn]: {
value: value,
operator: "equals"
}
},
size: 1,
page: 1
}
);
if (response.data.success && response.data.data?.data?.length > 0) {
const transformedValue = response.data.data.data[0][transform.resultColumn];
return transformedValue;
}
console.warn(`변환 실패: ${transform.tableName}.${transform.matchColumn} = "${value}" 인 행을 찾을 수 없습니다.`);
return undefined;
} catch (error) {
console.error("값 변환 오류:", error);
return undefined;
}
}
/**
*
* LookupCondition.sourceType이 "externalTable"
*/
async function fetchExternalLookupValue(
externalLookup: {
tableName: string;
matchColumn: string;
matchSourceType: "currentRow" | "sourceTable" | "sectionField";
matchSourceField: string;
matchSectionId?: string;
resultColumn: string;
},
rowData: any,
sourceData: any,
formData: FormDataState
): Promise<any> {
// 1. 비교 값 가져오기
let matchValue: any;
if (externalLookup.matchSourceType === "currentRow") {
matchValue = rowData[externalLookup.matchSourceField];
} else if (externalLookup.matchSourceType === "sourceTable") {
matchValue = sourceData?.[externalLookup.matchSourceField];
} else {
matchValue = formData[externalLookup.matchSourceField];
}
if (matchValue === undefined || matchValue === null || matchValue === "") {
console.warn(`외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`);
return undefined;
}
// 2. 외부 테이블에서 값 조회 (정확히 일치하는 검색)
try {
const response = await apiClient.post(
`/table-management/tables/${externalLookup.tableName}/data`,
{
search: {
[externalLookup.matchColumn]: {
value: matchValue,
operator: "equals"
}
},
size: 1,
page: 1
}
);
if (response.data.success && response.data.data?.data?.length > 0) {
return response.data.data.data[0][externalLookup.resultColumn];
}
console.warn(`외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`);
return undefined;
} catch (error) {
console.error("외부 테이블 조회 오류:", error);
return undefined;
}
}
/**
*
*
* @param tableName -
* @param valueColumn -
* @param joinConditions -
* @param rowData - ( )
* @param sourceData - (_sourceData)
* @param formData - ( )
*/
async function fetchExternalValue(
tableName: string,
valueColumn: string,
joinConditions: TableJoinCondition[],
rowData: any,
sourceData: any,
formData: FormDataState
): Promise<any> {
if (joinConditions.length === 0) {
return undefined;
}
try {
const whereConditions: Record<string, any> = {};
for (const condition of joinConditions) {
let value: any;
// 값 출처에 따라 가져오기 (4가지 소스 타입 지원)
if (condition.sourceType === "row") {
// 현재 행 데이터 (설정된 컬럼 필드)
value = rowData[condition.sourceField];
} else if (condition.sourceType === "sourceData") {
// 원본 소스 테이블 데이터 (_sourceData)
value = sourceData?.[condition.sourceField];
} else if (condition.sourceType === "formData") {
// formData에서 가져오기 (다른 섹션)
value = formData[condition.sourceField];
} else if (condition.sourceType === "externalTable" && condition.externalLookup) {
// 외부 테이블에서 조회하여 가져오기
value = await fetchExternalLookupValue(condition.externalLookup, rowData, sourceData, formData);
}
if (value === undefined || value === null || value === "") {
return undefined;
}
// 값 변환이 필요한 경우 (예: 이름 → 코드) - 레거시 호환
if (condition.transform) {
value = await transformValue(value, condition.transform);
if (value === undefined) {
return undefined;
}
}
// 숫자형 ID 변환
let convertedValue = value;
if (condition.targetColumn.endsWith("_id") || condition.targetColumn === "id") {
const numValue = Number(value);
if (!isNaN(numValue)) {
convertedValue = numValue;
}
}
// 정확히 일치하는 검색을 위해 operator: "equals" 사용
whereConditions[condition.targetColumn] = {
value: convertedValue,
operator: "equals"
};
}
// API 호출
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{ search: whereConditions, size: 1, page: 1 }
);
if (response.data.success && response.data.data?.data?.length > 0) {
return response.data.data.data[0][valueColumn];
}
return undefined;
} catch (error) {
console.error("외부 테이블 조회 오류:", error);
return undefined;
}
}
/**
*
* UniversalFormModal
*/
export function TableSectionRenderer({
sectionId,
tableConfig,
formData,
onFormDataChange,
onTableDataChange,
className,
}: TableSectionRendererProps) {
// 테이블 데이터 상태
const [tableData, setTableData] = useState<any[]>([]);
// 모달 상태
const [modalOpen, setModalOpen] = useState(false);
// 체크박스 선택 상태
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
// 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배)
const [widthTrigger, setWidthTrigger] = useState(0);
// 동적 데이터 소스 활성화 상태
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
// 날짜 일괄 적용 완료 플래그 (컬럼별로 한 번만 적용)
const [batchAppliedFields, setBatchAppliedFields] = useState<Set<string>>(new Set());
// 초기 데이터 로드 완료 플래그 (무한 루프 방지)
const initialDataLoadedRef = React.useRef(false);
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
useEffect(() => {
// 이미 초기화되었으면 스킵
if (initialDataLoadedRef.current) return;
const tableSectionKey = `_tableSection_${sectionId}`;
const initialData = formData[tableSectionKey];
if (Array.isArray(initialData) && initialData.length > 0) {
console.log("[TableSectionRenderer] 초기 데이터 로드:", {
sectionId,
itemCount: initialData.length,
});
setTableData(initialData);
initialDataLoadedRef.current = true;
}
}, [sectionId, formData]);
// RepeaterColumnConfig로 변환
const columns: RepeaterColumnConfig[] = (tableConfig.columns || []).map(convertToRepeaterColumn);
// 계산 규칙 변환
const calculationRules: CalculationRule[] = (tableConfig.calculations || []).map(convertToCalculationRule);
// 계산 로직
const calculateRow = useCallback(
(row: any): any => {
if (calculationRules.length === 0) return row;
const updatedRow = { ...row };
for (const rule of calculationRules) {
try {
let formula = rule.formula;
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
const dependencies = rule.dependencies.length > 0 ? rule.dependencies : fieldMatches;
for (const dep of dependencies) {
if (dep === rule.result) continue;
const value = parseFloat(row[dep]) || 0;
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
}
const result = new Function(`return ${formula}`)();
updatedRow[rule.result] = result;
} catch (error) {
console.error(`계산 오류 (${rule.formula}):`, error);
updatedRow[rule.result] = 0;
}
}
return updatedRow;
},
[calculationRules]
);
const calculateAll = useCallback(
(data: any[]): any[] => {
return data.map((row) => calculateRow(row));
},
[calculateRow]
);
// 데이터 변경 핸들러 (날짜 일괄 적용 로직 포함)
const handleDataChange = useCallback(
(newData: any[]) => {
let processedData = newData;
// 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리
const batchApplyColumns = tableConfig.columns.filter(
(col) => col.type === "date" && col.batchApply === true
);
for (const dateCol of batchApplyColumns) {
// 이미 일괄 적용된 컬럼은 건너뜀
if (batchAppliedFields.has(dateCol.field)) continue;
// 해당 컬럼에 값이 있는 행과 없는 행 분류
const itemsWithDate = processedData.filter((item) => item[dateCol.field]);
const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]);
// 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
const selectedDate = itemsWithDate[0][dateCol.field];
// 모든 행에 동일한 날짜 적용
processedData = processedData.map((item) => ({
...item,
[dateCol.field]: selectedDate,
}));
// 플래그 활성화 (이후 개별 수정 가능)
setBatchAppliedFields((prev) => new Set([...prev, dateCol.field]));
}
}
setTableData(processedData);
onTableDataChange(processedData);
},
[onTableDataChange, tableConfig.columns, batchAppliedFields]
);
// 행 변경 핸들러
const handleRowChange = useCallback(
(index: number, newRow: any) => {
const calculatedRow = calculateRow(newRow);
const newData = [...tableData];
newData[index] = calculatedRow;
handleDataChange(newData);
},
[tableData, calculateRow, handleDataChange]
);
// 행 삭제 핸들러
const handleRowDelete = useCallback(
(index: number) => {
const newData = tableData.filter((_, i) => i !== index);
handleDataChange(newData);
},
[tableData, handleDataChange]
);
// 선택된 항목 일괄 삭제
const handleBulkDelete = useCallback(() => {
if (selectedRows.size === 0) return;
const newData = tableData.filter((_, index) => !selectedRows.has(index));
handleDataChange(newData);
setSelectedRows(new Set());
// 데이터가 모두 삭제되면 일괄 적용 플래그도 리셋
if (newData.length === 0) {
setBatchAppliedFields(new Set());
}
}, [tableData, selectedRows, handleDataChange]);
// 아이템 추가 핸들러 (모달에서 선택)
const handleAddItems = useCallback(
async (items: any[]) => {
// 각 아이템에 대해 valueMapping 적용
const mappedItems = await Promise.all(
items.map(async (sourceItem) => {
const newItem: any = {};
for (const col of tableConfig.columns) {
const mapping = col.valueMapping;
// 0. lookup 설정이 있는 경우 (동적 조회)
if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) {
// 현재 활성화된 옵션 또는 기본 옵션 사용
const activeOptionId = activeDataSources[col.field];
const defaultOption = col.lookup.options.find((o) => o.isDefault) || col.lookup.options[0];
const selectedOption = activeOptionId
? col.lookup.options.find((o) => o.id === activeOptionId) || defaultOption
: defaultOption;
if (selectedOption) {
// sameTable 타입: 소스 데이터에서 직접 값 복사
if (selectedOption.type === "sameTable") {
const value = sourceItem[selectedOption.valueColumn];
if (value !== undefined) {
newItem[col.field] = value;
}
// _sourceData에 원본 저장 (나중에 다른 옵션으로 전환 시 사용)
newItem._sourceData = sourceItem;
continue;
}
// relatedTable, combinedLookup: 외부 테이블 조회
// 조인 조건 구성 (4가지 소스 타입 지원)
const joinConditions: TableJoinCondition[] = selectedOption.conditions.map((cond) => {
// sourceType 매핑
let sourceType: "row" | "sourceData" | "formData" | "externalTable";
if (cond.sourceType === "currentRow") {
sourceType = "row";
} else if (cond.sourceType === "sourceTable") {
sourceType = "sourceData";
} else if (cond.sourceType === "externalTable") {
sourceType = "externalTable";
} else {
sourceType = "formData";
}
return {
sourceType,
sourceField: cond.sourceField,
targetColumn: cond.targetColumn,
// 외부 테이블 조회 설정
externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환)
transform: cond.transform?.enabled ? {
tableName: cond.transform.tableName,
matchColumn: cond.transform.matchColumn,
resultColumn: cond.transform.resultColumn,
} : undefined,
};
});
// 외부 테이블에서 값 조회 (sourceItem이 _sourceData 역할)
const value = await fetchExternalValue(
selectedOption.tableName,
selectedOption.valueColumn,
joinConditions,
{ ...sourceItem, ...newItem }, // rowData (현재 행)
sourceItem, // sourceData (소스 테이블 원본)
formData
);
if (value !== undefined) {
newItem[col.field] = value;
}
// _sourceData에 원본 저장
newItem._sourceData = sourceItem;
}
continue;
}
// 1. 먼저 col.sourceField 확인 (간단 매핑)
if (!mapping && col.sourceField) {
// sourceField가 명시적으로 설정된 경우
if (sourceItem[col.sourceField] !== undefined) {
newItem[col.field] = sourceItem[col.sourceField];
}
continue;
}
if (!mapping) {
// 매핑 없으면 소스에서 동일 필드명으로 복사
if (sourceItem[col.field] !== undefined) {
newItem[col.field] = sourceItem[col.field];
}
continue;
}
// 2. valueMapping이 있는 경우 (고급 매핑)
switch (mapping.type) {
case "source":
// 소스 테이블에서 복사
const srcField = mapping.sourceField || col.sourceField || col.field;
if (sourceItem[srcField] !== undefined) {
newItem[col.field] = sourceItem[srcField];
}
break;
case "manual":
// 사용자 입력 (빈 값 또는 기본값)
newItem[col.field] = col.defaultValue ?? undefined;
break;
case "internal":
// formData에서 값 가져오기
if (mapping.internalField) {
newItem[col.field] = formData[mapping.internalField];
}
break;
case "external":
// 외부 테이블에서 조회
if (mapping.externalRef) {
const { tableName, valueColumn, joinConditions } = mapping.externalRef;
const value = await fetchExternalValue(
tableName,
valueColumn,
joinConditions,
{ ...sourceItem, ...newItem }, // rowData
sourceItem, // sourceData
formData
);
if (value !== undefined) {
newItem[col.field] = value;
}
}
break;
}
// 기본값 적용
if (col.defaultValue !== undefined && newItem[col.field] === undefined) {
newItem[col.field] = col.defaultValue;
}
}
return newItem;
})
);
// 계산 필드 업데이트
const calculatedItems = calculateAll(mappedItems);
// 기존 데이터에 추가
const newData = [...tableData, ...calculatedItems];
handleDataChange(newData);
},
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources]
);
// 컬럼 모드/조회 옵션 변경 핸들러
const handleDataSourceChange = useCallback(
async (columnField: string, optionId: string) => {
setActiveDataSources((prev) => ({
...prev,
[columnField]: optionId,
}));
// 해당 컬럼의 모든 행 데이터 재조회
const column = tableConfig.columns.find((col) => col.field === columnField);
// lookup 설정이 있는 경우 (새로운 조회 기능)
if (column?.lookup?.enabled && column.lookup.options) {
const selectedOption = column.lookup.options.find((opt) => opt.id === optionId);
if (!selectedOption) return;
// sameTable 타입: 현재 행의 소스 데이터에서 값 복사 (외부 조회 필요 없음)
if (selectedOption.type === "sameTable") {
const updatedData = tableData.map((row) => {
// sourceField에서 값을 가져와 해당 컬럼에 복사
// row에 _sourceData가 있으면 거기서, 없으면 row 자체에서 가져옴
const sourceData = row._sourceData || row;
const newValue = sourceData[selectedOption.valueColumn] ?? row[columnField];
return { ...row, [columnField]: newValue };
});
const calculatedData = calculateAll(updatedData);
handleDataChange(calculatedData);
return;
}
// 모든 행에 대해 새 값 조회
const updatedData = await Promise.all(
tableData.map(async (row) => {
let newValue: any = row[columnField];
// 조인 조건 구성 (4가지 소스 타입 지원)
const joinConditions: TableJoinCondition[] = selectedOption.conditions.map((cond) => {
// sourceType 매핑
let sourceType: "row" | "sourceData" | "formData" | "externalTable";
if (cond.sourceType === "currentRow") {
sourceType = "row";
} else if (cond.sourceType === "sourceTable") {
sourceType = "sourceData";
} else if (cond.sourceType === "externalTable") {
sourceType = "externalTable";
} else {
sourceType = "formData";
}
return {
sourceType,
sourceField: cond.sourceField,
targetColumn: cond.targetColumn,
// 외부 테이블 조회 설정
externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환)
transform: cond.transform?.enabled ? {
tableName: cond.transform.tableName,
matchColumn: cond.transform.matchColumn,
resultColumn: cond.transform.resultColumn,
} : undefined,
};
});
// 외부 테이블에서 값 조회 (_sourceData 전달)
const sourceData = row._sourceData || row;
const value = await fetchExternalValue(
selectedOption.tableName,
selectedOption.valueColumn,
joinConditions,
row,
sourceData,
formData
);
if (value !== undefined) {
newValue = value;
}
return { ...row, [columnField]: newValue };
})
);
// 계산 필드 업데이트
const calculatedData = calculateAll(updatedData);
handleDataChange(calculatedData);
return;
}
// 기존 columnModes 처리 (레거시 호환)
if (!column?.columnModes) return;
const selectedMode = column.columnModes.find((mode) => mode.id === optionId);
if (!selectedMode) return;
// 모든 행에 대해 새 값 조회
const updatedData = await Promise.all(
tableData.map(async (row) => {
const mapping = selectedMode.valueMapping;
let newValue: any = row[columnField];
const sourceData = row._sourceData || row;
if (mapping.type === "external" && mapping.externalRef) {
const { tableName, valueColumn, joinConditions } = mapping.externalRef;
const value = await fetchExternalValue(tableName, valueColumn, joinConditions, row, sourceData, formData);
if (value !== undefined) {
newValue = value;
}
} else if (mapping.type === "source" && mapping.sourceField) {
newValue = row[mapping.sourceField];
} else if (mapping.type === "internal" && mapping.internalField) {
newValue = formData[mapping.internalField];
}
return { ...row, [columnField]: newValue };
})
);
// 계산 필드 업데이트
const calculatedData = calculateAll(updatedData);
handleDataChange(calculatedData);
},
[tableConfig.columns, tableData, formData, calculateAll, handleDataChange]
);
// 소스 테이블 정보
const { source, filters, uiConfig } = tableConfig;
const sourceTable = source.tableName;
const sourceColumns = source.displayColumns;
const sourceSearchFields = source.searchColumns;
const columnLabels = source.columnLabels || {};
const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택";
const addButtonText = uiConfig?.addButtonText || "항목 검색";
const multiSelect = uiConfig?.multiSelect ?? true;
// 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리)
const baseFilterCondition: Record<string, any> = {};
if (filters?.preFilters) {
for (const filter of filters.preFilters) {
// 간단한 "=" 연산자만 처리 (확장 가능)
if (filter.operator === "=") {
baseFilterCondition[filter.column] = filter.value;
}
}
}
// 모달 필터 설정을 ItemSelectionModal에 전달할 형식으로 변환
const modalFiltersForModal = useMemo(() => {
if (!filters?.modalFilters) return [];
return filters.modalFilters.map((filter) => ({
column: filter.column,
label: filter.label || filter.column,
// category 타입을 select로 변환 (ModalFilterConfig 호환)
type: filter.type === "category" ? "select" as const : filter.type as "text" | "select",
options: filter.options,
categoryRef: filter.categoryRef,
defaultValue: filter.defaultValue,
}));
}, [filters?.modalFilters]);
return (
<div className={cn("space-y-4", className)}>
{/* 추가 버튼 영역 */}
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{tableData.length > 0 && `${tableData.length}개 항목`}
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
</span>
{columns.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setWidthTrigger((prev) => prev + 1)}
className="h-7 text-xs px-2"
title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"}
>
{widthTrigger % 2 === 0 ? (
<>
<AlignJustify className="h-3.5 w-3.5 mr-1" />
</>
) : (
<>
<Columns className="h-3.5 w-3.5 mr-1" />
</>
)}
</Button>
)}
</div>
<div className="flex gap-2">
{selectedRows.size > 0 && (
<Button
variant="destructive"
onClick={handleBulkDelete}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
({selectedRows.size})
</Button>
)}
<Button
onClick={() => setModalOpen(true)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="h-4 w-4 mr-2" />
{addButtonText}
</Button>
</div>
</div>
{/* Repeater 테이블 */}
<RepeaterTable
columns={columns}
data={tableData}
onDataChange={handleDataChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
activeDataSources={activeDataSources}
onDataSourceChange={handleDataSourceChange}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={widthTrigger}
/>
{/* 항목 선택 모달 */}
<ItemSelectionModal
open={modalOpen}
onOpenChange={setModalOpen}
sourceTable={sourceTable}
sourceColumns={sourceColumns}
sourceSearchFields={sourceSearchFields}
multiSelect={multiSelect}
filterCondition={baseFilterCondition}
modalTitle={modalTitle}
alreadySelected={tableData}
uniqueField={tableConfig.saveConfig?.uniqueField}
onSelect={handleAddItems}
columnLabels={columnLabels}
modalFilters={modalFiltersForModal}
/>
</div>
);
}

View File

@ -38,6 +38,7 @@ import {
OptionalFieldGroupConfig, OptionalFieldGroupConfig,
} from "./types"; } from "./types";
import { defaultConfig, generateUniqueId } from "./config"; import { defaultConfig, generateUniqueId } from "./config";
import { TableSectionRenderer } from "./TableSectionRenderer";
/** /**
* 🔗 Select * 🔗 Select
@ -194,6 +195,10 @@ export function UniversalFormModalComponent({
// 로딩 상태 // 로딩 상태
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// 🆕 수정 모드: 원본 그룹 데이터 (INSERT/UPDATE/DELETE 추적용)
const [originalGroupedData, setOriginalGroupedData] = useState<any[]>([]);
const groupedDataInitializedRef = useRef(false);
// 삭제 확인 다이얼로그 // 삭제 확인 다이얼로그
const [deleteDialog, setDeleteDialog] = useState<{ const [deleteDialog, setDeleteDialog] = useState<{
open: boolean; open: boolean;
@ -269,7 +274,7 @@ export function UniversalFormModalComponent({
// 설정에 정의된 필드 columnName 목록 수집 // 설정에 정의된 필드 columnName 목록 수집
const configuredFields = new Set<string>(); const configuredFields = new Set<string>();
config.sections.forEach((section) => { config.sections.forEach((section) => {
section.fields.forEach((field) => { (section.fields || []).forEach((field) => {
if (field.columnName) { if (field.columnName) {
configuredFields.add(field.columnName); configuredFields.add(field.columnName);
} }
@ -303,6 +308,12 @@ export function UniversalFormModalComponent({
console.log(`[UniversalFormModal] 반복 섹션 병합: ${sectionKey}`, items); console.log(`[UniversalFormModal] 반복 섹션 병합: ${sectionKey}`, items);
} }
} }
// 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용)
if (originalGroupedData.length > 0) {
event.detail.formData._originalGroupedData = originalGroupedData;
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}`);
}
}; };
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
@ -310,7 +321,37 @@ export function UniversalFormModalComponent({
return () => { return () => {
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener); window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
}; };
}, [formData, repeatSections, config.sections]); }, [formData, repeatSections, config.sections, originalGroupedData]);
// 🆕 수정 모드: _groupedData가 있으면 테이블 섹션 초기화
useEffect(() => {
if (!_groupedData || _groupedData.length === 0) return;
if (groupedDataInitializedRef.current) return; // 이미 초기화됨
// 테이블 타입 섹션 찾기
const tableSection = config.sections.find((s) => s.type === "table");
if (!tableSection) {
console.log("[UniversalFormModal] 테이블 섹션 없음 - _groupedData 무시");
return;
}
console.log("[UniversalFormModal] 수정 모드 - 테이블 섹션 초기화:", {
sectionId: tableSection.id,
itemCount: _groupedData.length,
});
// 원본 데이터 저장 (수정/삭제 추적용)
setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData)));
// 테이블 섹션 데이터 설정
const tableSectionKey = `_tableSection_${tableSection.id}`;
setFormData((prev) => ({
...prev,
[tableSectionKey]: _groupedData,
}));
groupedDataInitializedRef.current = true;
}, [_groupedData, config.sections]);
// 필드 레벨 linkedFieldGroup 데이터 로드 // 필드 레벨 linkedFieldGroup 데이터 로드
useEffect(() => { useEffect(() => {
@ -319,7 +360,7 @@ export function UniversalFormModalComponent({
// 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집 // 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집
config.sections.forEach((section) => { config.sections.forEach((section) => {
section.fields.forEach((field) => { (section.fields || []).forEach((field) => {
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) { if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) {
tablesToLoad.add(field.linkedFieldGroup.sourceTable); tablesToLoad.add(field.linkedFieldGroup.sourceTable);
} }
@ -372,9 +413,12 @@ export function UniversalFormModalComponent({
items.push(createRepeatItem(section, i)); items.push(createRepeatItem(section, i));
} }
newRepeatSections[section.id] = items; newRepeatSections[section.id] = items;
} else if (section.type === "table") {
// 테이블 섹션은 필드 초기화 스킵 (TableSectionRenderer에서 처리)
continue;
} else { } else {
// 일반 섹션 필드 초기화 // 일반 섹션 필드 초기화
for (const field of section.fields) { for (const field of (section.fields || [])) {
// 기본값 설정 // 기본값 설정
let value = field.defaultValue ?? ""; let value = field.defaultValue ?? "";
@ -405,7 +449,7 @@ export function UniversalFormModalComponent({
console.log(`[initializeForm] 옵셔널 그룹 자동 활성화: ${key}, triggerField=${group.triggerField}, value=${triggerValue}`); console.log(`[initializeForm] 옵셔널 그룹 자동 활성화: ${key}, triggerField=${group.triggerField}, value=${triggerValue}`);
// 활성화된 그룹의 필드값도 초기화 // 활성화된 그룹의 필드값도 초기화
for (const field of group.fields) { for (const field of group.fields || []) {
let value = field.defaultValue ?? ""; let value = field.defaultValue ?? "";
const parentField = field.parentFieldName || field.columnName; const parentField = field.parentFieldName || field.columnName;
if (effectiveInitialData[parentField] !== undefined) { if (effectiveInitialData[parentField] !== undefined) {
@ -448,7 +492,7 @@ export function UniversalFormModalComponent({
_index: index, _index: index,
}; };
for (const field of section.fields) { for (const field of (section.fields || [])) {
item[field.columnName] = field.defaultValue ?? ""; item[field.columnName] = field.defaultValue ?? "";
} }
@ -479,9 +523,9 @@ export function UniversalFormModalComponent({
let hasChanges = false; let hasChanges = false;
for (const section of config.sections) { for (const section of config.sections) {
if (section.repeatable) continue; if (section.repeatable || section.type === "table") continue;
for (const field of section.fields) { for (const field of (section.fields || [])) {
if ( if (
field.numberingRule?.enabled && field.numberingRule?.enabled &&
field.numberingRule?.generateOnOpen && field.numberingRule?.generateOnOpen &&
@ -653,7 +697,7 @@ export function UniversalFormModalComponent({
} }
// 옵셔널 필드 그룹 필드 값 초기화 // 옵셔널 필드 그룹 필드 값 초기화
group.fields.forEach((field) => { (group.fields || []).forEach((field) => {
handleFieldChange(field.columnName, field.defaultValue || ""); handleFieldChange(field.columnName, field.defaultValue || "");
}); });
}, [config, handleFieldChange]); }, [config, handleFieldChange]);
@ -781,9 +825,9 @@ export function UniversalFormModalComponent({
const missingFields: string[] = []; const missingFields: string[] = [];
for (const section of config.sections) { for (const section of config.sections) {
if (section.repeatable) continue; // 반복 섹션은 별도 검증 if (section.repeatable || section.type === "table") continue; // 반복 섹션 및 테이블 섹션은 별도 검증
for (const field of section.fields) { for (const field of (section.fields || [])) {
if (field.required && !field.hidden && !field.numberingRule?.hidden) { if (field.required && !field.hidden && !field.numberingRule?.hidden) {
const value = formData[field.columnName]; const value = formData[field.columnName];
if (value === undefined || value === null || value === "") { if (value === undefined || value === null || value === "") {
@ -800,16 +844,27 @@ export function UniversalFormModalComponent({
const saveSingleRow = useCallback(async () => { const saveSingleRow = useCallback(async () => {
const dataToSave = { ...formData }; const dataToSave = { ...formData };
// 테이블 섹션 데이터 추출 (별도 저장용)
const tableSectionData: Record<string, any[]> = {};
// 메타데이터 필드 제거 (채번 규칙 ID는 유지 - buttonActions.ts에서 사용) // 메타데이터 필드 제거 (채번 규칙 ID는 유지 - buttonActions.ts에서 사용)
Object.keys(dataToSave).forEach((key) => { Object.keys(dataToSave).forEach((key) => {
if (key.startsWith("_") && !key.includes("_numberingRuleId")) { if (key.startsWith("_tableSection_")) {
// 테이블 섹션 데이터는 별도로 저장
const sectionId = key.replace("_tableSection_", "");
tableSectionData[sectionId] = dataToSave[key] || [];
delete dataToSave[key];
} else if (key.startsWith("_") && !key.includes("_numberingRuleId")) {
delete dataToSave[key]; delete dataToSave[key];
} }
}); });
// 저장 시점 채번규칙 처리 (generateOnSave만 처리) // 저장 시점 채번규칙 처리 (generateOnSave만 처리)
for (const section of config.sections) { for (const section of config.sections) {
for (const field of section.fields) { // 테이블 타입 섹션은 건너뛰기
if (section.type === "table") continue;
for (const field of (section.fields || [])) {
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) { if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
const response = await allocateNumberingCode(field.numberingRule.ruleId); const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) { if (response.success && response.data?.generatedCode) {
@ -822,12 +877,140 @@ export function UniversalFormModalComponent({
} }
} }
// 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장)
// targetTable이 없거나 메인 테이블과 같은 경우
const tableSectionsForMainTable = config.sections.filter(
(s) => s.type === "table" &&
(!s.tableConfig?.saveConfig?.targetTable ||
s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName)
);
if (tableSectionsForMainTable.length > 0) {
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
const commonFieldsData: Record<string, any> = {};
const { sectionSaveModes } = config.saveConfig;
// 필드 타입 섹션에서 공통 저장 필드 수집
for (const section of config.sections) {
if (section.type === "table") continue;
const sectionMode = sectionSaveModes?.find((s) => s.sectionId === section.id);
const defaultMode = "common"; // 필드 타입 섹션의 기본값은 공통 저장
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
if (section.fields) {
for (const field of section.fields) {
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
if (fieldSaveMode === "common" && dataToSave[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = dataToSave[field.columnName];
}
}
}
}
// 각 테이블 섹션의 품목 데이터에 공통 필드 병합하여 저장
for (const tableSection of tableSectionsForMainTable) {
const sectionData = tableSectionData[tableSection.id] || [];
if (sectionData.length > 0) {
// 품목별로 행 저장
for (const item of sectionData) {
const rowToSave = { ...commonFieldsData, ...item };
// _sourceData 등 내부 메타데이터 제거
Object.keys(rowToSave).forEach((key) => {
if (key.startsWith("_")) {
delete rowToSave[key];
}
});
const response = await apiClient.post(
`/table-management/tables/${config.saveConfig.tableName}/add`,
rowToSave
);
if (!response.data?.success) {
throw new Error(response.data?.message || "품목 저장 실패");
}
}
// 이미 저장했으므로 아래 로직에서 다시 저장하지 않도록 제거
delete tableSectionData[tableSection.id];
}
}
// 품목이 없으면 공통 데이터만 저장하지 않음 (품목이 필요한 화면이므로)
// 다른 테이블 섹션이 있는 경우에만 메인 데이터 저장
const hasOtherTableSections = Object.keys(tableSectionData).length > 0;
if (!hasOtherTableSections) {
return; // 메인 테이블에 저장할 품목이 없으면 종료
}
}
// 메인 데이터 저장 (테이블 섹션이 없거나 별도 테이블에 저장하는 경우)
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave); const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave);
if (!response.data?.success) { if (!response.data?.success) {
throw new Error(response.data?.message || "저장 실패"); throw new Error(response.data?.message || "저장 실패");
} }
}, [config.sections, config.saveConfig.tableName, formData]);
// 테이블 섹션 데이터 저장 (별도 테이블에)
for (const section of config.sections) {
if (section.type === "table" && section.tableConfig?.saveConfig?.targetTable) {
const sectionData = tableSectionData[section.id];
if (sectionData && sectionData.length > 0) {
// 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기)
const mainRecordId = response.data?.data?.id;
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
const commonFieldsData: Record<string, any> = {};
const { sectionSaveModes } = config.saveConfig;
if (sectionSaveModes && sectionSaveModes.length > 0) {
// 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
for (const otherSection of config.sections) {
if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
const sectionMode = sectionSaveModes.find((s) => s.sectionId === otherSection.id);
const defaultMode = otherSection.type === "table" ? "individual" : "common";
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
// 필드 타입 섹션의 필드들 처리
if (otherSection.type !== "table" && otherSection.fields) {
for (const field of otherSection.fields) {
// 필드별 오버라이드 확인
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
// 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = formData[field.columnName];
}
}
}
}
}
for (const item of sectionData) {
// 공통 필드 병합 + 개별 품목 데이터
const itemToSave = { ...commonFieldsData, ...item };
// 메인 레코드와 연결이 필요한 경우
if (mainRecordId && config.saveConfig.primaryKeyColumn) {
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
}
await apiClient.post(
`/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`,
itemToSave
);
}
}
}
}
}, [config.sections, config.saveConfig.tableName, config.saveConfig.primaryKeyColumn, config.saveConfig.sectionSaveModes, formData]);
// 다중 행 저장 (겸직 등) // 다중 행 저장 (겸직 등)
const saveMultipleRows = useCallback(async () => { const saveMultipleRows = useCallback(async () => {
@ -840,7 +1023,7 @@ export function UniversalFormModalComponent({
// 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용 // 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용
if (commonFields.length === 0) { if (commonFields.length === 0) {
const nonRepeatableSections = config.sections.filter((s) => !s.repeatable); const nonRepeatableSections = config.sections.filter((s) => !s.repeatable);
commonFields = nonRepeatableSections.flatMap((s) => s.fields.map((f) => f.columnName)); commonFields = nonRepeatableSections.flatMap((s) => (s.fields || []).map((f) => f.columnName));
} }
// 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용 // 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
@ -886,7 +1069,7 @@ export function UniversalFormModalComponent({
// 반복 섹션의 필드 값 추가 // 반복 섹션의 필드 값 추가
const repeatSection = config.sections.find((s) => s.id === repeatSectionId); const repeatSection = config.sections.find((s) => s.id === repeatSectionId);
repeatSection?.fields.forEach((field) => { (repeatSection?.fields || []).forEach((field) => {
if (item[field.columnName] !== undefined) { if (item[field.columnName] !== undefined) {
subRow[field.columnName] = item[field.columnName]; subRow[field.columnName] = item[field.columnName];
} }
@ -901,9 +1084,9 @@ export function UniversalFormModalComponent({
// 저장 시점 채번규칙 처리 (메인 행만) // 저장 시점 채번규칙 처리 (메인 행만)
for (const section of config.sections) { for (const section of config.sections) {
if (section.repeatable) continue; if (section.repeatable || section.type === "table") continue;
for (const field of section.fields) { for (const field of (section.fields || [])) {
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) { if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
// generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당 // generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당
const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen; const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen;
@ -951,8 +1134,8 @@ export function UniversalFormModalComponent({
// 1. 메인 테이블 데이터 구성 // 1. 메인 테이블 데이터 구성
const mainData: Record<string, any> = {}; const mainData: Record<string, any> = {};
config.sections.forEach((section) => { config.sections.forEach((section) => {
if (section.repeatable) return; // 반복 섹션 제외 if (section.repeatable || section.type === "table") return; // 반복 섹션 및 테이블 타입 제외
section.fields.forEach((field) => { (section.fields || []).forEach((field) => {
const value = formData[field.columnName]; const value = formData[field.columnName];
if (value !== undefined && value !== null && value !== "") { if (value !== undefined && value !== null && value !== "") {
mainData[field.columnName] = value; mainData[field.columnName] = value;
@ -962,9 +1145,9 @@ export function UniversalFormModalComponent({
// 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당) // 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당)
for (const section of config.sections) { for (const section of config.sections) {
if (section.repeatable) continue; if (section.repeatable || section.type === "table") continue;
for (const field of section.fields) { for (const field of (section.fields || [])) {
// 채번규칙이 활성화된 필드 처리 // 채번규칙이 활성화된 필드 처리
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) { if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
// 신규 생성이거나 값이 없는 경우에만 채번 // 신규 생성이거나 값이 없는 경우에만 채번
@ -1054,8 +1237,8 @@ export function UniversalFormModalComponent({
// 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑 // 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑
else { else {
config.sections.forEach((section) => { config.sections.forEach((section) => {
if (section.repeatable) return; if (section.repeatable || section.type === "table") return;
const matchingField = section.fields.find((f) => f.columnName === mapping.targetColumn); const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn);
if (matchingField && mainData[matchingField.columnName] !== undefined) { if (matchingField && mainData[matchingField.columnName] !== undefined) {
mainFieldMappings!.push({ mainFieldMappings!.push({
formField: matchingField.columnName, formField: matchingField.columnName,
@ -1535,10 +1718,36 @@ export function UniversalFormModalComponent({
const isCollapsed = collapsedSections.has(section.id); const isCollapsed = collapsedSections.has(section.id);
const sectionColumns = section.columns || 2; const sectionColumns = section.columns || 2;
// 반복 섹션
if (section.repeatable) { if (section.repeatable) {
return renderRepeatableSection(section, isCollapsed); return renderRepeatableSection(section, isCollapsed);
} }
// 테이블 타입 섹션
if (section.type === "table" && section.tableConfig) {
return (
<Card key={section.id} className="mb-4">
<CardHeader className="pb-3">
<CardTitle className="text-base">{section.title}</CardTitle>
{section.description && <CardDescription className="text-xs">{section.description}</CardDescription>}
</CardHeader>
<CardContent>
<TableSectionRenderer
sectionId={section.id}
tableConfig={section.tableConfig}
formData={formData}
onFormDataChange={handleFieldChange}
onTableDataChange={(data) => {
// 테이블 섹션 데이터를 formData에 저장
handleFieldChange(`_tableSection_${section.id}`, data);
}}
/>
</CardContent>
</Card>
);
}
// 기본 필드 타입 섹션
return ( return (
<Card key={section.id} className="mb-4"> <Card key={section.id} className="mb-4">
{section.collapsible ? ( {section.collapsible ? (
@ -1560,7 +1769,7 @@ export function UniversalFormModalComponent({
<CardContent> <CardContent>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}> <div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{/* 일반 필드 렌더링 */} {/* 일반 필드 렌더링 */}
{section.fields.map((field) => {(section.fields || []).map((field) =>
renderFieldWithColumns( renderFieldWithColumns(
field, field,
formData[field.columnName], formData[field.columnName],
@ -1582,7 +1791,7 @@ export function UniversalFormModalComponent({
<CardContent> <CardContent>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}> <div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{/* 일반 필드 렌더링 */} {/* 일반 필드 렌더링 */}
{section.fields.map((field) => {(section.fields || []).map((field) =>
renderFieldWithColumns( renderFieldWithColumns(
field, field,
formData[field.columnName], formData[field.columnName],
@ -1719,7 +1928,7 @@ export function UniversalFormModalComponent({
</div> </div>
<CollapsibleContent> <CollapsibleContent>
<div className="grid gap-3 px-3 pb-3" style={{ gridTemplateColumns: "repeat(12, 1fr)" }}> <div className="grid gap-3 px-3 pb-3" style={{ gridTemplateColumns: "repeat(12, 1fr)" }}>
{group.fields.map((field) => {(group.fields || []).map((field) =>
renderFieldWithColumns( renderFieldWithColumns(
field, field,
formData[field.columnName], formData[field.columnName],
@ -1763,7 +1972,7 @@ export function UniversalFormModalComponent({
</Button> </Button>
</div> </div>
<div className="grid gap-3" style={{ gridTemplateColumns: "repeat(12, 1fr)" }}> <div className="grid gap-3" style={{ gridTemplateColumns: "repeat(12, 1fr)" }}>
{group.fields.map((field) => {(group.fields || []).map((field) =>
renderFieldWithColumns( renderFieldWithColumns(
field, field,
formData[field.columnName], formData[field.columnName],
@ -1819,7 +2028,7 @@ export function UniversalFormModalComponent({
</div> </div>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}> <div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{/* 일반 필드 렌더링 */} {/* 일반 필드 렌더링 */}
{section.fields.map((field) => {(section.fields || []).map((field) =>
renderFieldWithColumns( renderFieldWithColumns(
field, field,
item[field.columnName], item[field.columnName],
@ -1898,7 +2107,7 @@ export function UniversalFormModalComponent({
<div className="text-muted-foreground text-center"> <div className="text-muted-foreground text-center">
<p className="font-medium">{config.modal.title || "범용 폼 모달"}</p> <p className="font-medium">{config.modal.title || "범용 폼 모달"}</p>
<p className="mt-1 text-xs"> <p className="mt-1 text-xs">
{config.sections.length} |{config.sections.reduce((acc, s) => acc + s.fields.length, 0)} {config.sections.length} |{config.sections.reduce((acc, s) => acc + (s.fields?.length || 0), 0)}
</p> </p>
<p className="mt-1 text-xs"> : {config.saveConfig.tableName || "(미설정)"}</p> <p className="mt-1 text-xs"> : {config.saveConfig.tableName || "(미설정)"}</p>
</div> </div>

View File

@ -17,6 +17,7 @@ import {
Settings, Settings,
Database, Database,
Layout, Layout,
Table,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
@ -27,9 +28,11 @@ import {
FormSectionConfig, FormSectionConfig,
FormFieldConfig, FormFieldConfig,
MODAL_SIZE_OPTIONS, MODAL_SIZE_OPTIONS,
SECTION_TYPE_OPTIONS,
} from "./types"; } from "./types";
import { import {
defaultSectionConfig, defaultSectionConfig,
defaultTableSectionConfig,
generateSectionId, generateSectionId,
} from "./config"; } from "./config";
@ -37,6 +40,7 @@ import {
import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal"; import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal";
import { SaveSettingsModal } from "./modals/SaveSettingsModal"; import { SaveSettingsModal } from "./modals/SaveSettingsModal";
import { SectionLayoutModal } from "./modals/SectionLayoutModal"; import { SectionLayoutModal } from "./modals/SectionLayoutModal";
import { TableSectionSettingsModal } from "./modals/TableSectionSettingsModal";
// 도움말 텍스트 컴포넌트 // 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => ( const HelpText = ({ children }: { children: React.ReactNode }) => (
@ -57,6 +61,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
const [saveSettingsModalOpen, setSaveSettingsModalOpen] = useState(false); const [saveSettingsModalOpen, setSaveSettingsModalOpen] = useState(false);
const [sectionLayoutModalOpen, setSectionLayoutModalOpen] = useState(false); const [sectionLayoutModalOpen, setSectionLayoutModalOpen] = useState(false);
const [fieldDetailModalOpen, setFieldDetailModalOpen] = useState(false); const [fieldDetailModalOpen, setFieldDetailModalOpen] = useState(false);
const [tableSectionSettingsModalOpen, setTableSectionSettingsModalOpen] = useState(false);
const [selectedSection, setSelectedSection] = useState<FormSectionConfig | null>(null); const [selectedSection, setSelectedSection] = useState<FormSectionConfig | null>(null);
const [selectedField, setSelectedField] = useState<FormFieldConfig | null>(null); const [selectedField, setSelectedField] = useState<FormFieldConfig | null>(null);
@ -95,23 +100,25 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
try { try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const data = response.data?.data; // API 응답 구조: { success, data: { columns: [...], total, page, ... } }
const columns = response.data?.data?.columns;
if (response.data?.success && Array.isArray(data)) { if (response.data?.success && Array.isArray(columns)) {
setTableColumns((prev) => ({ setTableColumns((prev) => ({
...prev, ...prev,
[tableName]: data.map( [tableName]: columns.map(
(c: { (c: {
columnName?: string; columnName?: string;
column_name?: string; column_name?: string;
dataType?: string; dataType?: string;
data_type?: string; data_type?: string;
displayName?: string;
columnComment?: string; columnComment?: string;
column_comment?: string; column_comment?: string;
}) => ({ }) => ({
name: c.columnName || c.column_name || "", name: c.columnName || c.column_name || "",
type: c.dataType || c.data_type || "text", type: c.dataType || c.data_type || "text",
label: c.columnComment || c.column_comment || c.columnName || c.column_name || "", label: c.displayName || c.columnComment || c.column_comment || c.columnName || c.column_name || "",
}), }),
), ),
})); }));
@ -159,11 +166,14 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
); );
// 섹션 관리 // 섹션 관리
const addSection = useCallback(() => { const addSection = useCallback((type: "fields" | "table" = "fields") => {
const newSection: FormSectionConfig = { const newSection: FormSectionConfig = {
...defaultSectionConfig, ...defaultSectionConfig,
id: generateSectionId(), id: generateSectionId(),
title: `섹션 ${config.sections.length + 1}`, title: type === "table" ? `테이블 섹션 ${config.sections.length + 1}` : `섹션 ${config.sections.length + 1}`,
type,
fields: type === "fields" ? [] : undefined,
tableConfig: type === "table" ? { ...defaultTableSectionConfig } : undefined,
}; };
onChange({ onChange({
...config, ...config,
@ -171,6 +181,41 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
}); });
}, [config, onChange]); }, [config, onChange]);
// 섹션 타입 변경
const changeSectionType = useCallback(
(sectionId: string, newType: "fields" | "table") => {
onChange({
...config,
sections: config.sections.map((s) => {
if (s.id !== sectionId) return s;
if (newType === "table") {
return {
...s,
type: "table",
fields: undefined,
tableConfig: { ...defaultTableSectionConfig },
};
} else {
return {
...s,
type: "fields",
fields: [],
tableConfig: undefined,
};
}
}),
});
},
[config, onChange]
);
// 테이블 섹션 설정 모달 열기
const handleOpenTableSectionSettings = (section: FormSectionConfig) => {
setSelectedSection(section);
setTableSectionSettingsModalOpen(true);
};
const updateSection = useCallback( const updateSection = useCallback(
(sectionId: string, updates: Partial<FormSectionConfig>) => { (sectionId: string, updates: Partial<FormSectionConfig>) => {
onChange({ onChange({
@ -365,39 +410,56 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
</div> </div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="px-4 pb-4 space-y-4 w-full min-w-0"> <AccordionContent className="px-4 pb-4 space-y-4 w-full min-w-0">
<Button size="sm" variant="outline" onClick={addSection} className="h-9 text-xs w-full max-w-full"> {/* 섹션 추가 버튼들 */}
<Plus className="h-4 w-4 mr-2" /> <div className="flex gap-2 w-full min-w-0">
<Button size="sm" variant="outline" onClick={() => addSection("fields")} className="h-9 text-xs flex-1 min-w-0">
<Plus className="h-4 w-4 mr-1 shrink-0" />
<span className="truncate"> </span>
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => addSection("table")} className="h-9 text-xs flex-1 min-w-0">
<Table className="h-4 w-4 mr-1 shrink-0" />
<span className="truncate"> </span>
</Button>
</div>
<HelpText> <HelpText>
. 섹션: 일반 .
<br /> <br />
: 기본 , , 섹션: 품목 .
</HelpText> </HelpText>
{config.sections.length === 0 ? ( {config.sections.length === 0 ? (
<div className="text-center py-12 border border-dashed rounded-lg w-full bg-muted/20"> <div className="text-center py-12 border border-dashed rounded-lg w-full bg-muted/20">
<p className="text-sm text-muted-foreground mb-2 font-medium"> </p> <p className="text-sm text-muted-foreground mb-2 font-medium"> </p>
<p className="text-xs text-muted-foreground">"섹션 추가" </p> <p className="text-xs text-muted-foreground"> </p>
</div> </div>
) : ( ) : (
<div className="space-y-3 w-full min-w-0"> <div className="space-y-3 w-full min-w-0">
{config.sections.map((section, index) => ( {config.sections.map((section, index) => (
<div key={section.id} className="border rounded-lg p-3 bg-card w-full min-w-0 overflow-hidden space-y-3"> <div key={section.id} className="border rounded-lg p-3 bg-card w-full min-w-0 overflow-hidden space-y-3">
{/* 헤더: 제목 + 삭제 */} {/* 헤더: 제목 + 타입 배지 + 삭제 */}
<div className="flex items-start justify-between gap-3 w-full min-w-0"> <div className="flex items-start justify-between gap-3 w-full min-w-0">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<span className="text-sm font-medium truncate">{section.title}</span> <span className="text-sm font-medium truncate">{section.title}</span>
{section.repeatable && ( {section.type === "table" ? (
<Badge variant="outline" className="text-xs px-1.5 py-0.5 text-purple-600 bg-purple-50 border-purple-200">
</Badge>
) : section.repeatable ? (
<Badge variant="outline" className="text-xs px-1.5 py-0.5"> <Badge variant="outline" className="text-xs px-1.5 py-0.5">
</Badge> </Badge>
)} ) : null}
</div> </div>
{section.type === "table" ? (
<Badge variant="secondary" className="text-xs px-2 py-0.5"> <Badge variant="secondary" className="text-xs px-2 py-0.5">
{section.fields.length} {section.tableConfig?.source?.tableName || "(소스 미설정)"}
</Badge> </Badge>
) : (
<Badge variant="secondary" className="text-xs px-2 py-0.5">
{(section.fields || []).length}
</Badge>
)}
</div> </div>
<Button <Button
@ -435,10 +497,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
</div> </div>
</div> </div>
{/* 필드 목록 */} {/* 필드 목록 (필드 타입만) */}
{section.fields.length > 0 && ( {section.type !== "table" && (section.fields || []).length > 0 && (
<div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1"> <div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1">
{section.fields.slice(0, 4).map((field) => ( {(section.fields || []).slice(0, 4).map((field) => (
<Badge <Badge
key={field.id} key={field.id}
variant="outline" variant="outline"
@ -447,15 +509,46 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
{field.label} {field.label}
</Badge> </Badge>
))} ))}
{section.fields.length > 4 && ( {(section.fields || []).length > 4 && (
<Badge variant="outline" className="text-xs px-2 py-0.5 shrink-0"> <Badge variant="outline" className="text-xs px-2 py-0.5 shrink-0">
+{section.fields.length - 4} +{(section.fields || []).length - 4}
</Badge> </Badge>
)} )}
</div> </div>
)} )}
{/* 레이아웃 설정 버튼 */} {/* 테이블 컬럼 목록 (테이블 타입만) */}
{section.type === "table" && section.tableConfig?.columns && section.tableConfig.columns.length > 0 && (
<div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1">
{section.tableConfig.columns.slice(0, 4).map((col) => (
<Badge
key={col.field}
variant="outline"
className="text-xs px-2 py-0.5 shrink-0 text-purple-600 bg-purple-50 border-purple-200"
>
{col.label}
</Badge>
))}
{section.tableConfig.columns.length > 4 && (
<Badge variant="outline" className="text-xs px-2 py-0.5 shrink-0">
+{section.tableConfig.columns.length - 4}
</Badge>
)}
</div>
)}
{/* 설정 버튼 (타입에 따라 다름) */}
{section.type === "table" ? (
<Button
size="sm"
variant="outline"
onClick={() => handleOpenTableSectionSettings(section)}
className="h-9 text-xs w-full"
>
<Table className="h-4 w-4 mr-2" />
</Button>
) : (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@ -465,6 +558,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
<Layout className="h-4 w-4 mr-2" /> <Layout className="h-4 w-4 mr-2" />
</Button> </Button>
)}
</div> </div>
))} ))}
</div> </div>
@ -530,7 +624,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
const updatedSection = { const updatedSection = {
...selectedSection, ...selectedSection,
// 기본 필드 목록에서 업데이트 // 기본 필드 목록에서 업데이트
fields: selectedSection.fields.map((f) => (f.id === updatedField.id ? updatedField : f)), fields: (selectedSection.fields || []).map((f) => (f.id === updatedField.id ? updatedField : f)),
// 옵셔널 필드 그룹 내 필드도 업데이트 // 옵셔널 필드 그룹 내 필드도 업데이트
optionalFieldGroups: selectedSection.optionalFieldGroups?.map((group) => ({ optionalFieldGroups: selectedSection.optionalFieldGroups?.map((group) => ({
...group, ...group,
@ -558,6 +652,46 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
onLoadTableColumns={loadTableColumns} onLoadTableColumns={loadTableColumns}
/> />
)} )}
{/* 테이블 섹션 설정 모달 */}
{selectedSection && selectedSection.type === "table" && (
<TableSectionSettingsModal
open={tableSectionSettingsModalOpen}
onOpenChange={setTableSectionSettingsModalOpen}
section={selectedSection}
onSave={(updates) => {
const updatedSection = {
...selectedSection,
...updates,
};
// config 업데이트
onChange({
...config,
sections: config.sections.map((s) =>
s.id === selectedSection.id ? updatedSection : s
),
});
setSelectedSection(updatedSection);
setTableSectionSettingsModalOpen(false);
}}
tables={tables.map(t => ({ table_name: t.name, comment: t.label }))}
tableColumns={Object.fromEntries(
Object.entries(tableColumns).map(([tableName, cols]) => [
tableName,
cols.map(c => ({
column_name: c.name,
data_type: c.type,
is_nullable: "YES",
comment: c.label,
})),
])
)}
onLoadTableColumns={loadTableColumns}
allSections={config.sections as FormSectionConfig[]}
/>
)}
</div> </div>
); );
} }

View File

@ -2,7 +2,16 @@
* *
*/ */
import { UniversalFormModalConfig } from "./types"; import {
UniversalFormModalConfig,
TableSectionConfig,
TableColumnConfig,
ValueMappingConfig,
ColumnModeConfig,
TablePreFilter,
TableModalFilter,
TableCalculationRule,
} from "./types";
// 기본 설정값 // 기본 설정값
export const defaultConfig: UniversalFormModalConfig = { export const defaultConfig: UniversalFormModalConfig = {
@ -77,6 +86,7 @@ export const defaultSectionConfig = {
id: "", id: "",
title: "새 섹션", title: "새 섹션",
description: "", description: "",
type: "fields" as const,
collapsible: false, collapsible: false,
defaultCollapsed: false, defaultCollapsed: false,
columns: 2, columns: 2,
@ -95,6 +105,97 @@ export const defaultSectionConfig = {
linkedFieldGroups: [], linkedFieldGroups: [],
}; };
// ============================================
// 테이블 섹션 관련 기본값
// ============================================
// 기본 테이블 섹션 설정
export const defaultTableSectionConfig: TableSectionConfig = {
source: {
tableName: "",
displayColumns: [],
searchColumns: [],
columnLabels: {},
},
filters: {
preFilters: [],
modalFilters: [],
},
columns: [],
calculations: [],
saveConfig: {
targetTable: undefined,
uniqueField: undefined,
},
uiConfig: {
addButtonText: "항목 검색",
modalTitle: "항목 검색 및 선택",
multiSelect: true,
maxHeight: "400px",
},
};
// 기본 테이블 컬럼 설정
export const defaultTableColumnConfig: TableColumnConfig = {
field: "",
label: "",
type: "text",
editable: true,
calculated: false,
required: false,
width: "150px",
minWidth: "60px",
maxWidth: "400px",
defaultValue: undefined,
selectOptions: [],
valueMapping: undefined,
columnModes: [],
};
// 기본 값 매핑 설정
export const defaultValueMappingConfig: ValueMappingConfig = {
type: "source",
sourceField: "",
externalRef: undefined,
internalField: undefined,
};
// 기본 컬럼 모드 설정
export const defaultColumnModeConfig: ColumnModeConfig = {
id: "",
label: "",
isDefault: false,
valueMapping: {
type: "source",
sourceField: "",
},
};
// 기본 사전 필터 설정
export const defaultPreFilterConfig: TablePreFilter = {
column: "",
operator: "=",
value: "",
};
// 기본 모달 필터 설정
export const defaultModalFilterConfig: TableModalFilter = {
column: "",
label: "",
type: "category",
categoryRef: undefined,
options: [],
optionsFromTable: undefined,
defaultValue: undefined,
};
// 기본 계산 규칙 설정
export const defaultCalculationRuleConfig: TableCalculationRule = {
resultField: "",
formula: "",
dependencies: [],
};
// 기본 옵셔널 필드 그룹 설정 // 기본 옵셔널 필드 그룹 설정
export const defaultOptionalFieldGroupConfig = { export const defaultOptionalFieldGroupConfig = {
id: "", id: "",
@ -184,3 +285,18 @@ export const generateFieldId = (): string => {
export const generateLinkedFieldGroupId = (): string => { export const generateLinkedFieldGroupId = (): string => {
return generateUniqueId("linked"); return generateUniqueId("linked");
}; };
// 유틸리티: 테이블 컬럼 ID 생성
export const generateTableColumnId = (): string => {
return generateUniqueId("tcol");
};
// 유틸리티: 컬럼 모드 ID 생성
export const generateColumnModeId = (): string => {
return generateUniqueId("mode");
};
// 유틸리티: 필터 ID 생성
export const generateFilterId = (): string => {
return generateUniqueId("filter");
};

View File

@ -11,9 +11,10 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Plus, Trash2, Database, Layers } from "lucide-react"; import { Plus, Trash2, Database, Layers, Info } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig } from "../types"; import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig, SectionSaveMode } from "../types";
// 도움말 텍스트 컴포넌트 // 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => ( const HelpText = ({ children }: { children: React.ReactNode }) => (
@ -219,6 +220,8 @@ export function SaveSettingsModal({
const getAllFields = (): { columnName: string; label: string; sectionTitle: string }[] => { const getAllFields = (): { columnName: string; label: string; sectionTitle: string }[] => {
const fields: { columnName: string; label: string; sectionTitle: string }[] = []; const fields: { columnName: string; label: string; sectionTitle: string }[] = [];
sections.forEach((section) => { sections.forEach((section) => {
// 필드 타입 섹션만 처리 (테이블 타입은 fields가 undefined)
if (section.fields && Array.isArray(section.fields)) {
section.fields.forEach((field) => { section.fields.forEach((field) => {
fields.push({ fields.push({
columnName: field.columnName, columnName: field.columnName,
@ -226,12 +229,103 @@ export function SaveSettingsModal({
sectionTitle: section.title, sectionTitle: section.title,
}); });
}); });
}
}); });
return fields; return fields;
}; };
const allFields = getAllFields(); const allFields = getAllFields();
// 섹션별 저장 방식 조회 (없으면 기본값 반환)
const getSectionSaveMode = (sectionId: string, sectionType: "fields" | "table"): "common" | "individual" => {
const sectionMode = localSaveConfig.sectionSaveModes?.find((s) => s.sectionId === sectionId);
if (sectionMode) {
return sectionMode.saveMode;
}
// 기본값: fields 타입은 공통 저장, table 타입은 개별 저장
return sectionType === "fields" ? "common" : "individual";
};
// 필드별 저장 방식 조회 (오버라이드 확인)
const getFieldSaveMode = (sectionId: string, fieldName: string, sectionType: "fields" | "table"): "common" | "individual" => {
const sectionMode = localSaveConfig.sectionSaveModes?.find((s) => s.sectionId === sectionId);
if (sectionMode) {
// 필드별 오버라이드 확인
const fieldOverride = sectionMode.fieldOverrides?.find((f) => f.fieldName === fieldName);
if (fieldOverride) {
return fieldOverride.saveMode;
}
return sectionMode.saveMode;
}
// 기본값
return sectionType === "fields" ? "common" : "individual";
};
// 섹션별 저장 방식 업데이트
const updateSectionSaveMode = (sectionId: string, mode: "common" | "individual") => {
const currentModes = localSaveConfig.sectionSaveModes || [];
const existingIndex = currentModes.findIndex((s) => s.sectionId === sectionId);
let newModes: SectionSaveMode[];
if (existingIndex >= 0) {
newModes = [...currentModes];
newModes[existingIndex] = { ...newModes[existingIndex], saveMode: mode };
} else {
newModes = [...currentModes, { sectionId, saveMode: mode }];
}
updateSaveConfig({ sectionSaveModes: newModes });
};
// 필드별 오버라이드 토글
const toggleFieldOverride = (sectionId: string, fieldName: string, sectionType: "fields" | "table") => {
const currentModes = localSaveConfig.sectionSaveModes || [];
const sectionIndex = currentModes.findIndex((s) => s.sectionId === sectionId);
// 섹션 설정이 없으면 먼저 생성
let newModes = [...currentModes];
if (sectionIndex < 0) {
const defaultMode = sectionType === "fields" ? "common" : "individual";
newModes.push({ sectionId, saveMode: defaultMode, fieldOverrides: [] });
}
const targetIndex = newModes.findIndex((s) => s.sectionId === sectionId);
const sectionMode = newModes[targetIndex];
const currentFieldOverrides = sectionMode.fieldOverrides || [];
const fieldOverrideIndex = currentFieldOverrides.findIndex((f) => f.fieldName === fieldName);
let newFieldOverrides;
if (fieldOverrideIndex >= 0) {
// 이미 오버라이드가 있으면 제거 (섹션 기본값으로 돌아감)
newFieldOverrides = currentFieldOverrides.filter((f) => f.fieldName !== fieldName);
} else {
// 오버라이드 추가 (섹션 기본값의 반대)
const oppositeMode = sectionMode.saveMode === "common" ? "individual" : "common";
newFieldOverrides = [...currentFieldOverrides, { fieldName, saveMode: oppositeMode }];
}
newModes[targetIndex] = { ...sectionMode, fieldOverrides: newFieldOverrides };
updateSaveConfig({ sectionSaveModes: newModes });
};
// 섹션의 필드 목록 가져오기
const getSectionFields = (section: FormSectionConfig): { fieldName: string; label: string }[] => {
if (section.type === "table" && section.tableConfig) {
// 테이블 타입: tableConfig.columns에서 필드 목록 가져오기
return (section.tableConfig.columns || []).map((col) => ({
fieldName: col.field,
label: col.label,
}));
} else if (section.fields) {
// 필드 타입: fields에서 목록 가져오기
return section.fields.map((field) => ({
fieldName: field.columnName,
label: field.label,
}));
}
return [];
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0"> <DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
@ -721,6 +815,150 @@ export function SaveSettingsModal({
</div> </div>
)} )}
{/* 섹션별 저장 방식 */}
<div className="space-y-3 border rounded-lg p-3 bg-card">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-green-600" />
<h3 className="text-xs font-semibold"> </h3>
</div>
{/* 설명 */}
<div className="bg-muted/50 rounded-lg p-2.5 space-y-1.5">
<div className="flex items-start gap-2">
<Info className="h-3.5 w-3.5 text-blue-500 mt-0.5 shrink-0" />
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground">
<span className="font-medium text-foreground"> :</span> <span className="font-medium"></span>
<br />
<span className="text-[9px] text-muted-foreground/80">: 수주번호, , - 3 3 </span>
</p>
<p className="text-[10px] text-muted-foreground">
<span className="font-medium text-foreground"> :</span> <span className="font-medium"></span>
<br />
<span className="text-[9px] text-muted-foreground/80">: 품목코드, , - </span>
</p>
</div>
</div>
</div>
{/* 섹션 목록 */}
{sections.length === 0 ? (
<div className="text-center py-4 border border-dashed rounded-lg">
<p className="text-[10px] text-muted-foreground"> </p>
</div>
) : (
<Accordion type="multiple" className="space-y-2">
{sections.map((section) => {
const sectionType = section.type || "fields";
const currentMode = getSectionSaveMode(section.id, sectionType);
const sectionFields = getSectionFields(section);
return (
<AccordionItem
key={section.id}
value={section.id}
className={cn(
"border rounded-lg",
currentMode === "common" ? "bg-blue-50/30" : "bg-orange-50/30"
)}
>
<AccordionTrigger className="px-3 py-2 text-xs hover:no-underline">
<div className="flex items-center justify-between flex-1 mr-2">
<div className="flex items-center gap-2">
<span className="font-medium">{section.title}</span>
<Badge
variant="outline"
className={cn(
"text-[8px] h-4",
sectionType === "table" ? "border-orange-300 text-orange-600" : "border-blue-300 text-blue-600"
)}
>
{sectionType === "table" ? "테이블" : "필드"}
</Badge>
</div>
<Badge
variant={currentMode === "common" ? "default" : "secondary"}
className="text-[8px] h-4"
>
{currentMode === "common" ? "공통 저장" : "개별 저장"}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 space-y-3">
{/* 저장 방식 선택 */}
<div className="space-y-2">
<Label className="text-[10px] font-medium"> </Label>
<RadioGroup
value={currentMode}
onValueChange={(value) => updateSectionSaveMode(section.id, value as "common" | "individual")}
className="flex gap-4"
>
<div className="flex items-center space-x-1.5">
<RadioGroupItem value="common" id={`${section.id}-common`} className="h-3 w-3" />
<Label htmlFor={`${section.id}-common`} className="text-[10px] cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-1.5">
<RadioGroupItem value="individual" id={`${section.id}-individual`} className="h-3 w-3" />
<Label htmlFor={`${section.id}-individual`} className="text-[10px] cursor-pointer">
</Label>
</div>
</RadioGroup>
</div>
{/* 필드 목록 */}
{sectionFields.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<Label className="text-[10px] font-medium"> ({sectionFields.length})</Label>
<HelpText> </HelpText>
<div className="grid grid-cols-2 gap-1.5">
{sectionFields.map((field) => {
const fieldMode = getFieldSaveMode(section.id, field.fieldName, sectionType);
const isOverridden = fieldMode !== currentMode;
return (
<button
key={field.fieldName}
onClick={() => toggleFieldOverride(section.id, field.fieldName, sectionType)}
className={cn(
"flex items-center justify-between px-2 py-1.5 rounded border text-left transition-colors",
isOverridden
? "border-amber-300 bg-amber-50"
: "border-gray-200 bg-white hover:bg-gray-50"
)}
>
<span className="text-[9px] truncate flex-1">
{field.label}
<span className="text-muted-foreground ml-1">({field.fieldName})</span>
</span>
<Badge
variant={fieldMode === "common" ? "default" : "secondary"}
className={cn(
"text-[7px] h-3.5 ml-1 shrink-0",
isOverridden && "ring-1 ring-amber-400"
)}
>
{fieldMode === "common" ? "공통" : "개별"}
</Badge>
</button>
);
})}
</div>
</div>
</>
)}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
)}
</div>
{/* 저장 후 동작 */} {/* 저장 후 동작 */}
<div className="space-y-2 border rounded-lg p-3 bg-card"> <div className="space-y-2 border rounded-lg p-3 bg-card">
<h3 className="text-xs font-semibold"> </h3> <h3 className="text-xs font-semibold"> </h3>

View File

@ -37,13 +37,19 @@ export function SectionLayoutModal({
onOpenFieldDetail, onOpenFieldDetail,
}: SectionLayoutModalProps) { }: SectionLayoutModalProps) {
// 로컬 상태로 섹션 관리 // 로컬 상태로 섹션 관리 (fields가 없으면 빈 배열로 초기화)
const [localSection, setLocalSection] = useState<FormSectionConfig>(section); const [localSection, setLocalSection] = useState<FormSectionConfig>(() => ({
...section,
fields: section.fields || [],
}));
// open이 변경될 때마다 데이터 동기화 // open이 변경될 때마다 데이터 동기화
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setLocalSection(section); setLocalSection({
...section,
fields: section.fields || [],
});
} }
}, [open, section]); }, [open, section]);
@ -59,42 +65,45 @@ export function SectionLayoutModal({
onOpenChange(false); onOpenChange(false);
}; };
// fields 배열 (안전한 접근)
const fields = localSection.fields || [];
// 필드 추가 // 필드 추가
const addField = () => { const addField = () => {
const newField: FormFieldConfig = { const newField: FormFieldConfig = {
...defaultFieldConfig, ...defaultFieldConfig,
id: generateFieldId(), id: generateFieldId(),
label: `새 필드 ${localSection.fields.length + 1}`, label: `새 필드 ${fields.length + 1}`,
columnName: `field_${localSection.fields.length + 1}`, columnName: `field_${fields.length + 1}`,
}; };
updateSection({ updateSection({
fields: [...localSection.fields, newField], fields: [...fields, newField],
}); });
}; };
// 필드 삭제 // 필드 삭제
const removeField = (fieldId: string) => { const removeField = (fieldId: string) => {
updateSection({ updateSection({
fields: localSection.fields.filter((f) => f.id !== fieldId), fields: fields.filter((f) => f.id !== fieldId),
}); });
}; };
// 필드 업데이트 // 필드 업데이트
const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => { const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => {
updateSection({ updateSection({
fields: localSection.fields.map((f) => (f.id === fieldId ? { ...f, ...updates } : f)), fields: fields.map((f) => (f.id === fieldId ? { ...f, ...updates } : f)),
}); });
}; };
// 필드 이동 // 필드 이동
const moveField = (fieldId: string, direction: "up" | "down") => { const moveField = (fieldId: string, direction: "up" | "down") => {
const index = localSection.fields.findIndex((f) => f.id === fieldId); const index = fields.findIndex((f) => f.id === fieldId);
if (index === -1) return; if (index === -1) return;
if (direction === "up" && index === 0) return; if (direction === "up" && index === 0) return;
if (direction === "down" && index === localSection.fields.length - 1) return; if (direction === "down" && index === fields.length - 1) return;
const newFields = [...localSection.fields]; const newFields = [...fields];
const targetIndex = direction === "up" ? index - 1 : index + 1; const targetIndex = direction === "up" ? index - 1 : index + 1;
[newFields[index], newFields[targetIndex]] = [newFields[targetIndex], newFields[index]]; [newFields[index], newFields[targetIndex]] = [newFields[targetIndex], newFields[index]];
@ -317,7 +326,7 @@ export function SectionLayoutModal({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-xs font-semibold"> </h3> <h3 className="text-xs font-semibold"> </h3>
<Badge variant="secondary" className="text-[9px] px-1.5 py-0"> <Badge variant="secondary" className="text-[9px] px-1.5 py-0">
{localSection.fields.length} {fields.length}
</Badge> </Badge>
</div> </div>
<Button size="sm" variant="outline" onClick={addField} className="h-7 text-[10px] px-2"> <Button size="sm" variant="outline" onClick={addField} className="h-7 text-[10px] px-2">
@ -330,14 +339,14 @@ export function SectionLayoutModal({
. "상세 설정" . . "상세 설정" .
</HelpText> </HelpText>
{localSection.fields.length === 0 ? ( {fields.length === 0 ? (
<div className="text-center py-8 border border-dashed rounded-lg"> <div className="text-center py-8 border border-dashed rounded-lg">
<p className="text-sm text-muted-foreground mb-2"> </p> <p className="text-sm text-muted-foreground mb-2"> </p>
<p className="text-xs text-muted-foreground"> "필드 추가" </p> <p className="text-xs text-muted-foreground"> "필드 추가" </p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{localSection.fields.map((field, index) => ( {fields.map((field, index) => (
<div <div
key={field.id} key={field.id}
className={cn( className={cn(
@ -363,7 +372,7 @@ export function SectionLayoutModal({
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => moveField(field.id, "down")} onClick={() => moveField(field.id, "down")}
disabled={index === localSection.fields.length - 1} disabled={index === fields.length - 1}
className="h-3 w-5 p-0" className="h-3 w-5 p-0"
> >
<ChevronDown className="h-2.5 w-2.5" /> <ChevronDown className="h-2.5 w-2.5" />
@ -929,7 +938,7 @@ export function SectionLayoutModal({
</Button> </Button>
<Button onClick={handleSave} className="h-9 text-sm"> <Button onClick={handleSave} className="h-9 text-sm">
({localSection.fields.length} ) ({fields.length} )
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -184,7 +184,12 @@ export interface FormSectionConfig {
description?: string; description?: string;
collapsible?: boolean; // 접을 수 있는지 (기본: false) collapsible?: boolean; // 접을 수 있는지 (기본: false)
defaultCollapsed?: boolean; // 기본 접힘 상태 (기본: false) defaultCollapsed?: boolean; // 기본 접힘 상태 (기본: false)
fields: FormFieldConfig[];
// 섹션 타입: fields (기본) 또는 table (테이블 형식)
type?: "fields" | "table";
// type: "fields" 일 때 사용
fields?: FormFieldConfig[];
// 반복 섹션 (겸직 등) // 반복 섹션 (겸직 등)
repeatable?: boolean; repeatable?: boolean;
@ -199,6 +204,294 @@ export interface FormSectionConfig {
// 섹션 레이아웃 // 섹션 레이아웃
columns?: number; // 필드 배치 컬럼 수 (기본: 2) columns?: number; // 필드 배치 컬럼 수 (기본: 2)
gap?: string; // 필드 간 간격 gap?: string; // 필드 간 간격
// type: "table" 일 때 사용
tableConfig?: TableSectionConfig;
}
// ============================================
// 테이블 섹션 관련 타입 정의
// ============================================
/**
*
*
*/
export interface TableSectionConfig {
// 1. 소스 설정 (검색 모달에서 데이터를 가져올 테이블)
source: {
tableName: string; // 소스 테이블명 (예: item_info)
displayColumns: string[]; // 모달에 표시할 컬럼
searchColumns: string[]; // 검색 가능한 컬럼
columnLabels?: Record<string, string>; // 컬럼 라벨 (컬럼명 -> 표시 라벨)
};
// 2. 필터 설정
filters?: {
// 사전 필터 (항상 적용, 사용자에게 노출되지 않음)
preFilters?: TablePreFilter[];
// 모달 내 필터 UI (사용자가 선택 가능)
modalFilters?: TableModalFilter[];
};
// 3. 테이블 컬럼 설정
columns: TableColumnConfig[];
// 4. 계산 규칙
calculations?: TableCalculationRule[];
// 5. 저장 설정
saveConfig?: {
targetTable?: string; // 다른 테이블에 저장 시 (미지정 시 메인 테이블)
uniqueField?: string; // 중복 체크 필드
};
// 6. UI 설정
uiConfig?: {
addButtonText?: string; // 추가 버튼 텍스트 (기본: "품목 검색")
modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택")
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
maxHeight?: string; // 테이블 최대 높이 (기본: "400px")
};
}
/**
*
*
*/
export interface TablePreFilter {
column: string; // 필터할 컬럼
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like";
value: any; // 필터 값
}
/**
*
* UI
*/
export interface TableModalFilter {
column: string; // 필터할 컬럼
label: string; // 필터 라벨
type: "category" | "text"; // 필터 타입 (category: 드롭다운, text: 텍스트 입력)
// 카테고리 참조 (type: "category"일 때) - 테이블에서 컬럼의 distinct 값 조회
categoryRef?: {
tableName: string; // 테이블명 (예: "item_info")
columnName: string; // 컬럼명 (예: "division")
};
// 정적 옵션 (직접 입력한 경우)
options?: { value: string; label: string }[];
// 테이블에서 동적 로드 (테이블 컬럼 조회)
optionsFromTable?: {
tableName: string;
valueColumn: string;
labelColumn: string;
distinct?: boolean; // 중복 제거 (기본: true)
};
// 기본값
defaultValue?: any;
}
/**
*
*/
export interface TableColumnConfig {
field: string; // 필드명 (저장할 컬럼명)
label: string; // 컬럼 헤더 라벨
type: "text" | "number" | "date" | "select"; // 입력 타입
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)
// 편집 설정
editable?: boolean; // 편집 가능 여부 (기본: true)
calculated?: boolean; // 계산 필드 여부 (자동 읽기전용)
required?: boolean; // 필수 입력 여부
// 너비 설정
width?: string; // 기본 너비 (예: "150px")
minWidth?: string; // 최소 너비
maxWidth?: string; // 최대 너비
// 기본값
defaultValue?: any;
// Select 옵션 (type이 "select"일 때)
selectOptions?: { value: string; label: string }[];
// 값 매핑 (핵심 기능) - 고급 설정용
valueMapping?: ValueMappingConfig;
// 컬럼 모드 전환 (동적 데이터 소스)
columnModes?: ColumnModeConfig[];
// 조회 설정 (동적 값 조회)
lookup?: LookupConfig;
// 날짜 일괄 적용 (type이 "date"일 때만 사용)
// 활성화 시 첫 번째 날짜 입력 시 모든 행에 동일한 날짜가 자동 적용됨
batchApply?: boolean;
}
// ============================================
// 조회(Lookup) 설정 관련 타입 정의
// ============================================
/**
*
* - sameTable: 동일 ( )
* - relatedTable: 연관 ( )
* - combinedLookup: 복합 ( + )
*/
export type LookupType = "sameTable" | "relatedTable" | "combinedLookup";
/**
*
* : 거래처
*/
export interface LookupTransform {
enabled: boolean; // 변환 사용 여부
tableName: string; // 변환 테이블 (예: customer_mng)
matchColumn: string; // 찾을 컬럼 (예: customer_name)
resultColumn: string; // 가져올 컬럼 (예: customer_code)
}
/**
*
* ( )
*/
export interface ExternalTableLookup {
tableName: string; // 조회할 테이블
matchColumn: string; // 조회 조건 컬럼 (WHERE 절에서 비교할 컬럼)
matchSourceType: "currentRow" | "sourceTable" | "sectionField"; // 비교값 출처
matchSourceField: string; // 비교값 필드명
matchSectionId?: string; // sectionField인 경우 섹션 ID
resultColumn: string; // 가져올 컬럼 (SELECT 절)
}
/**
*
*
* sourceType :
* - "currentRow": (rowData에서 , : part_code, quantity)
* - "sourceTable": (_sourceData에서 , : item_number, company_code)
* - "sectionField": (formData에서 , : partner_id)
* - "externalTable": ( )
*/
export interface LookupCondition {
sourceType: "currentRow" | "sourceTable" | "sectionField" | "externalTable"; // 값 출처
sourceField: string; // 출처의 필드명 (참조할 필드)
sectionId?: string; // sectionField인 경우 섹션 ID
targetColumn: string; // 조회 테이블의 컬럼
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
externalLookup?: ExternalTableLookup;
// 값 변환 설정 (선택) - 이름→코드 등 변환이 필요할 때 (레거시 호환)
transform?: LookupTransform;
}
/**
*
*
*/
export interface LookupOption {
id: string; // 옵션 고유 ID
label: string; // 옵션 라벨 (예: "기준단가", "거래처별 단가")
displayLabel?: string; // 헤더 드롭다운에 표시될 텍스트 (예: "기준단가" → "단가 (기준단가)")
type: LookupType; // 조회 유형
// 조회 테이블 설정
tableName: string; // 조회할 테이블
valueColumn: string; // 가져올 컬럼
// 조회 조건 (여러 조건 AND로 결합)
conditions: LookupCondition[];
// 기본 옵션 여부
isDefault?: boolean;
}
/**
*
*/
export interface LookupConfig {
enabled: boolean; // 조회 사용 여부
options: LookupOption[]; // 조회 옵션 목록
defaultOptionId?: string; // 기본 선택 옵션 ID
}
/**
*
*
*/
export interface ValueMappingConfig {
type: "source" | "manual" | "external" | "internal";
// type: "source" - 소스 테이블에서 복사
sourceField?: string; // 소스 테이블의 컬럼명
// type: "external" - 외부 테이블 조회
externalRef?: {
tableName: string; // 조회할 테이블
valueColumn: string; // 가져올 컬럼
joinConditions: TableJoinCondition[];
};
// type: "internal" - formData의 다른 필드 값 직접 사용
internalField?: string; // formData의 필드명
}
/**
*
*
*
* sourceType :
* - "row": (rowData)
* - "sourceData": (_sourceData)
* - "formData": (formData)
* - "externalTable":
*/
export interface TableJoinCondition {
sourceType: "row" | "sourceData" | "formData" | "externalTable"; // 값 출처
sourceField: string; // 출처의 필드명
targetColumn: string; // 조회 테이블의 컬럼
operator?: "=" | "!=" | ">" | "<" | ">=" | "<="; // 연산자 (기본: "=")
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
externalLookup?: ExternalTableLookup;
// 값 변환 설정 (선택) - 이름→코드 등 중간 변환이 필요할 때 (레거시 호환)
transform?: {
tableName: string; // 변환 테이블 (예: customer_mng)
matchColumn: string; // 찾을 컬럼 (예: customer_name)
resultColumn: string; // 가져올 컬럼 (예: customer_code)
};
}
/**
*
*
*/
export interface ColumnModeConfig {
id: string; // 모드 고유 ID
label: string; // 모드 라벨 (예: "기준 단가", "거래처별 단가")
isDefault?: boolean; // 기본 모드 여부
valueMapping: ValueMappingConfig; // 이 모드의 값 매핑
}
/**
*
*
*/
export interface TableCalculationRule {
resultField: string; // 결과를 저장할 필드
formula: string; // 계산 공식 (예: "quantity * unit_price")
dependencies: string[]; // 의존하는 필드들
} }
// 다중 행 저장 설정 // 다중 행 저장 설정
@ -214,6 +507,21 @@ export interface MultiRowSaveConfig {
mainSectionFields?: string[]; // 메인 행에만 저장할 필드 mainSectionFields?: string[]; // 메인 행에만 저장할 필드
} }
/**
*
* 저장: 해당 (: 수주번호, )
* 저장: 해당 (: 품목코드, , )
*/
export interface SectionSaveMode {
sectionId: string;
saveMode: "common" | "individual"; // 공통 저장 / 개별 저장
// 필드별 세부 설정 (선택사항 - 섹션 기본값과 다르게 설정할 필드)
fieldOverrides?: {
fieldName: string;
saveMode: "common" | "individual";
}[];
}
// 저장 설정 // 저장 설정
export interface SaveConfig { export interface SaveConfig {
tableName: string; tableName: string;
@ -225,6 +533,9 @@ export interface SaveConfig {
// 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용) // 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용)
customApiSave?: CustomApiSaveConfig; customApiSave?: CustomApiSaveConfig;
// 섹션별 저장 방식 설정
sectionSaveModes?: SectionSaveMode[];
// 저장 후 동작 (간편 설정) // 저장 후 동작 (간편 설정)
showToast?: boolean; // 토스트 메시지 (기본: true) showToast?: boolean; // 토스트 메시지 (기본: true)
refreshParent?: boolean; // 부모 새로고침 (기본: true) refreshParent?: boolean; // 부모 새로고침 (기본: true)
@ -432,3 +743,69 @@ export const LINKED_FIELD_DISPLAY_FORMAT_OPTIONS = [
{ value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" }, { value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" },
{ value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" }, { value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" },
] as const; ] as const;
// ============================================
// 테이블 섹션 관련 상수
// ============================================
// 섹션 타입 옵션
export const SECTION_TYPE_OPTIONS = [
{ value: "fields", label: "필드 타입" },
{ value: "table", label: "테이블 타입" },
] as const;
// 테이블 컬럼 타입 옵션
export const TABLE_COLUMN_TYPE_OPTIONS = [
{ value: "text", label: "텍스트" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "select", label: "선택(드롭다운)" },
] as const;
// 값 매핑 타입 옵션
export const VALUE_MAPPING_TYPE_OPTIONS = [
{ value: "source", label: "소스 테이블에서 복사" },
{ value: "manual", label: "사용자 직접 입력" },
{ value: "external", label: "외부 테이블 조회" },
{ value: "internal", label: "폼 데이터 참조" },
] as const;
// 조인 조건 소스 타입 옵션
export const JOIN_SOURCE_TYPE_OPTIONS = [
{ value: "row", label: "현재 행 데이터" },
{ value: "formData", label: "폼 필드 값" },
] as const;
// 필터 연산자 옵션
export const FILTER_OPERATOR_OPTIONS = [
{ value: "=", label: "같음 (=)" },
{ value: "!=", label: "다름 (!=)" },
{ value: ">", label: "큼 (>)" },
{ value: "<", label: "작음 (<)" },
{ value: ">=", label: "크거나 같음 (>=)" },
{ value: "<=", label: "작거나 같음 (<=)" },
{ value: "in", label: "포함 (IN)" },
{ value: "notIn", label: "미포함 (NOT IN)" },
{ value: "like", label: "유사 (LIKE)" },
] as const;
// 모달 필터 타입 옵션
export const MODAL_FILTER_TYPE_OPTIONS = [
{ value: "category", label: "테이블 조회" },
{ value: "text", label: "텍스트 입력" },
] as const;
// 조회 유형 옵션
export const LOOKUP_TYPE_OPTIONS = [
{ value: "sameTable", label: "동일 테이블 조회" },
{ value: "relatedTable", label: "연관 테이블 조회" },
{ value: "combinedLookup", label: "복합 조건 조회" },
] as const;
// 조회 조건 소스 타입 옵션
export const LOOKUP_CONDITION_SOURCE_OPTIONS = [
{ value: "currentRow", label: "현재 행" },
{ value: "sourceTable", label: "소스 테이블" },
{ value: "sectionField", label: "다른 섹션" },
{ value: "externalTable", label: "외부 테이블" },
] as const;

View File

@ -675,6 +675,14 @@ export class ButtonActionExecutor {
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData); console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
} }
// 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리
// 범용_폼_모달 내부에 _tableSection_ 데이터가 있는 경우 공통 필드 + 개별 품목 병합 저장
const universalFormModalResult = await this.handleUniversalFormModalTableSectionSave(config, context, formData);
if (universalFormModalResult.handled) {
console.log("✅ [handleSave] Universal Form Modal 테이블 섹션 저장 완료");
return universalFormModalResult.success;
}
// 폼 유효성 검사 // 폼 유효성 검사
if (config.validateForm) { if (config.validateForm) {
const validation = this.validateFormData(formData); const validation = this.validateFormData(formData);
@ -1479,6 +1487,244 @@ export class ButtonActionExecutor {
} }
} }
/**
* 🆕 Universal Form Modal
* _폼_모달 + _tableSection_
* 모드: INSERT/UPDATE/DELETE
*/
private static async handleUniversalFormModalTableSectionSave(
config: ButtonActionConfig,
context: ButtonActionContext,
formData: Record<string, any>,
): Promise<{ handled: boolean; success: boolean }> {
const { tableName, screenId } = context;
// 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음)
const universalFormModalKey = Object.keys(formData).find((key) => {
const value = formData[key];
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
// _tableSection_ 키가 있는지 확인
return Object.keys(value).some((k) => k.startsWith("_tableSection_"));
});
if (!universalFormModalKey) {
return { handled: false, success: false };
}
console.log("🎯 [handleUniversalFormModalTableSectionSave] Universal Form Modal 감지:", universalFormModalKey);
const modalData = formData[universalFormModalKey];
// _tableSection_ 데이터 추출
const tableSectionData: Record<string, any[]> = {};
const commonFieldsData: Record<string, any> = {};
// 🆕 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용)
// modalData 내부 또는 최상위 formData에서 찾음
const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || [];
for (const [key, value] of Object.entries(modalData)) {
if (key.startsWith("_tableSection_")) {
const sectionId = key.replace("_tableSection_", "");
tableSectionData[sectionId] = value as any[];
} else if (!key.startsWith("_")) {
// _로 시작하지 않는 필드는 공통 필드로 처리
commonFieldsData[key] = value;
}
}
console.log("🎯 [handleUniversalFormModalTableSectionSave] 데이터 분리:", {
commonFields: Object.keys(commonFieldsData),
tableSections: Object.keys(tableSectionData),
tableSectionCounts: Object.entries(tableSectionData).map(([k, v]) => ({ [k]: v.length })),
originalGroupedDataCount: originalGroupedData.length,
isEditMode: originalGroupedData.length > 0,
});
// 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음
const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0);
if (!hasTableSectionData && originalGroupedData.length === 0) {
console.log("⚠️ [handleUniversalFormModalTableSectionSave] 테이블 섹션 데이터 없음 - 일반 저장으로 전환");
return { handled: false, success: false };
}
try {
// 사용자 정보 추가
if (!context.userId) {
throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요.");
}
const userInfo = {
writer: context.userId,
created_by: context.userId,
updated_by: context.userId,
company_code: context.companyCode || "",
};
let insertedCount = 0;
let updatedCount = 0;
let deletedCount = 0;
// 각 테이블 섹션 처리
for (const [sectionId, currentItems] of Object.entries(tableSectionData)) {
console.log(`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`);
// 1⃣ 신규 품목 INSERT (id가 없는 항목)
const newItems = currentItems.filter((item) => !item.id);
for (const item of newItems) {
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
// 내부 메타데이터 제거
Object.keys(rowToSave).forEach((key) => {
if (key.startsWith("_")) {
delete rowToSave[key];
}
});
console.log(" [INSERT] 신규 품목:", rowToSave);
const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: tableName!,
data: rowToSave,
});
if (!saveResult.success) {
throw new Error(saveResult.message || "신규 품목 저장 실패");
}
insertedCount++;
}
// 2⃣ 기존 품목 UPDATE (id가 있는 항목, 변경된 경우만)
const existingItems = currentItems.filter((item) => item.id);
for (const item of existingItems) {
const originalItem = originalGroupedData.find((orig) => orig.id === item.id);
if (!originalItem) {
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - INSERT로 처리: id=${item.id}`);
// 원본이 없으면 신규로 처리
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
Object.keys(rowToSave).forEach((key) => {
if (key.startsWith("_")) {
delete rowToSave[key];
}
});
delete rowToSave.id; // id 제거하여 INSERT
const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: tableName!,
data: rowToSave,
});
if (!saveResult.success) {
throw new Error(saveResult.message || "품목 저장 실패");
}
insertedCount++;
continue;
}
// 변경 사항 확인 (공통 필드 포함)
const currentDataWithCommon = { ...commonFieldsData, ...item };
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
if (hasChanges) {
console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}`);
// 변경된 필드만 추출하여 부분 업데이트
const updateResult = await DynamicFormApi.updateFormDataPartial(
item.id,
originalItem,
currentDataWithCommon,
tableName!,
);
if (!updateResult.success) {
throw new Error(updateResult.message || "품목 수정 실패");
}
updatedCount++;
} else {
console.log(`⏭️ [SKIP] 변경 없음: id=${item.id}`);
}
}
// 3⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목)
const currentIds = new Set(currentItems.map((item) => item.id).filter(Boolean));
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id));
for (const deletedItem of deletedItems) {
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}`);
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(tableName!, deletedItem.id);
if (!deleteResult.success) {
throw new Error(deleteResult.message || "품목 삭제 실패");
}
deletedCount++;
}
}
// 결과 메시지 생성
const resultParts: string[] = [];
if (insertedCount > 0) resultParts.push(`${insertedCount}개 추가`);
if (updatedCount > 0) resultParts.push(`${updatedCount}개 수정`);
if (deletedCount > 0) resultParts.push(`${deletedCount}개 삭제`);
const resultMessage = resultParts.length > 0 ? resultParts.join(", ") : "변경 사항 없음";
console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`);
toast.success(`저장 완료: ${resultMessage}`);
// 저장 성공 이벤트 발생
window.dispatchEvent(new CustomEvent("saveSuccess"));
window.dispatchEvent(new CustomEvent("refreshTable"));
// EditModal 닫기 이벤트 발생
window.dispatchEvent(new CustomEvent("closeEditModal"));
return { handled: true, success: true };
} catch (error: any) {
console.error("❌ [handleUniversalFormModalTableSectionSave] 저장 오류:", error);
toast.error(error.message || "저장 중 오류가 발생했습니다.");
return { handled: true, success: false };
}
}
/**
*
*/
private static checkForChanges(original: Record<string, any>, current: Record<string, any>): boolean {
// 비교할 필드 목록 (메타데이터 제외)
const fieldsToCompare = new Set([
...Object.keys(original).filter((k) => !k.startsWith("_")),
...Object.keys(current).filter((k) => !k.startsWith("_")),
]);
for (const field of fieldsToCompare) {
// 시스템 필드는 비교에서 제외
if (["created_date", "updated_date", "created_by", "updated_by", "writer"].includes(field)) {
continue;
}
const originalValue = original[field];
const currentValue = current[field];
// null/undefined 통일 처리
const normalizedOriginal = originalValue === null || originalValue === undefined ? "" : String(originalValue);
const normalizedCurrent = currentValue === null || currentValue === undefined ? "" : String(currentValue);
if (normalizedOriginal !== normalizedCurrent) {
console.log(` 📝 변경 감지: ${field} = "${normalizedOriginal}" → "${normalizedCurrent}"`);
return true;
}
}
return false;
}
/** /**
* 🆕 (SelectedItemsDetailInput용 - ) * 🆕 (SelectedItemsDetailInput용 - )
* ItemData[] details * ItemData[] details

View File

@ -158,6 +158,37 @@ export interface ComponentConfig {
headerTextColor?: string; // 헤더 텍스트 색상 headerTextColor?: string; // 헤더 텍스트 색상
showBorder?: boolean; // 테두리 표시 showBorder?: boolean; // 테두리 표시
rowHeight?: number; // 행 높이 (px) rowHeight?: number; // 행 높이 (px)
// 페이지 번호 전용
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber"; // 페이지 번호 포맷
// 카드 컴포넌트 전용
cardTitle?: string; // 카드 제목
cardItems?: Array<{
label: string; // 항목 라벨 (예: "회사명")
value: string; // 항목 값 (예: "당사 주식회사") 또는 기본값
fieldName?: string; // 쿼리 필드명 (바인딩용)
}>;
labelWidth?: number; // 라벨 컬럼 너비 (px)
showCardBorder?: boolean; // 카드 테두리 표시 여부
showCardTitle?: boolean; // 카드 제목 표시 여부
titleFontSize?: number; // 제목 폰트 크기
labelFontSize?: number; // 라벨 폰트 크기
valueFontSize?: number; // 값 폰트 크기
titleColor?: string; // 제목 색상
labelColor?: string; // 라벨 색상
valueColor?: string; // 값 색상
// 계산 컴포넌트 전용
calcItems?: Array<{
label: string; // 항목 라벨 (예: "공급가액")
value: number | string; // 항목 값 또는 기본값
operator: "+" | "-" | "x" | "÷"; // 연산자
fieldName?: string; // 쿼리 필드명 (바인딩용)
}>;
resultLabel?: string; // 결과 라벨 (예: "합계 금액")
resultColor?: string; // 결과 색상
resultFontSize?: number; // 결과 폰트 크기
showCalcBorder?: boolean; // 테두리 표시 여부
numberFormat?: "none" | "comma" | "currency"; // 숫자 포맷 (없음, 천단위, 원화)
currencySuffix?: string; // 통화 접미사 (예: "원")
} }
// 리포트 상세 // 리포트 상세

View File

@ -66,6 +66,10 @@ export interface TableRegistration {
onGroupChange: (groups: string[]) => void; onGroupChange: (groups: string[]) => void;
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void; onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경 onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경
onFrozenColumnCountChange?: (count: number) => void; // 틀고정 컬럼 수 변경
// 현재 설정 값 (읽기 전용)
frozenColumnCount?: number; // 현재 틀고정 컬럼 수
// 데이터 조회 함수 (선택 타입 필터용) // 데이터 조회 함수 (선택 타입 필터용)
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>; getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;

View File

@ -1687,3 +1687,4 @@ const 출고등록_설정: ScreenSplitPanel = {

View File

@ -534,3 +534,4 @@ const { data: config } = await getScreenSplitPanel(screenId);

View File

@ -521,3 +521,4 @@ function ScreenViewPage() {