Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
b992f13b08
|
|
@ -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건)를
|
||||
> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다.
|
||||
|
|
@ -12,12 +12,15 @@
|
|||
"@types/mssql": "^9.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
|
|
@ -2256,6 +2259,93 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/dom": {
|
||||
"version": "1.15.6",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.6.tgz",
|
||||
"integrity": "sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "1.0.5",
|
||||
"@oozcitak/url": "1.0.0",
|
||||
"@oozcitak/util": "8.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/infra": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.5.tgz",
|
||||
"integrity": "sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/util": "8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
|
||||
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.0.tgz",
|
||||
"integrity": "sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "1.0.3",
|
||||
"@oozcitak/util": "1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.3.tgz",
|
||||
"integrity": "sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/util": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.1.tgz",
|
||||
"integrity": "sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url/node_modules/@oozcitak/util": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.2.tgz",
|
||||
"integrity": "sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/util": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.4.tgz",
|
||||
"integrity": "sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
|
||||
|
|
@ -4326,6 +4416,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/browser-split": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz",
|
||||
"integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.26.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
|
||||
|
|
@ -4445,6 +4541,15 @@
|
|||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bwip-js": {
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.8.0.tgz",
|
||||
"integrity": "sha512-gUDkDHSTv8/DJhomSIbO0fX/Dx0MO/sgllLxJyJfu4WixCQe9nfGJzmHm64ZCbxo+gUYQEsQcRmqcwcwPRwUkg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"bwip-js": "bin/bwip-js.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
|
|
@ -4521,6 +4626,15 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelize": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001745",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz",
|
||||
|
|
@ -5202,6 +5316,56 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/docx": {
|
||||
"version": "9.5.1",
|
||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz",
|
||||
"integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^24.0.1",
|
||||
"hash.js": "^1.1.7",
|
||||
"jszip": "^3.10.1",
|
||||
"nanoid": "^5.1.3",
|
||||
"xml": "^1.0.1",
|
||||
"xml-js": "^1.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/@types/node": {
|
||||
"version": "24.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
|
||||
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/nanoid": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
|
||||
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
|
|
@ -5216,6 +5380,11 @@
|
|||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-walk": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
|
|
@ -5349,6 +5518,27 @@
|
|||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ent": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
|
||||
"integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.3",
|
||||
"es-errors": "^1.3.0",
|
||||
"punycode": "^1.4.1",
|
||||
"safe-regex-test": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ent/node_modules/punycode": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
|
||||
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
|
|
@ -5361,6 +5551,16 @@
|
|||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/error": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz",
|
||||
"integrity": "sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==",
|
||||
"dependencies": {
|
||||
"camelize": "^1.0.0",
|
||||
"string-template": "~0.2.0",
|
||||
"xtend": "~4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
|
|
@ -5643,6 +5843,14 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ev-store": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ev-store/-/ev-store-7.0.0.tgz",
|
||||
"integrity": "sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==",
|
||||
"dependencies": {
|
||||
"individual": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
|
|
@ -6279,6 +6487,16 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/global": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"min-document": "^2.19.0",
|
||||
"process": "^0.11.10"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "13.24.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
||||
|
|
@ -6413,6 +6631,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hash.js": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
|
|
@ -6443,6 +6671,22 @@
|
|||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mdevils"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
|
|
@ -6450,6 +6694,27 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-to-docx": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/html-to-docx/-/html-to-docx-1.8.0.tgz",
|
||||
"integrity": "sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/dom": "1.15.6",
|
||||
"@oozcitak/util": "8.3.4",
|
||||
"color-name": "^1.1.4",
|
||||
"html-entities": "^2.3.3",
|
||||
"html-to-vdom": "^0.7.0",
|
||||
"image-size": "^1.0.0",
|
||||
"image-to-base64": "^2.2.0",
|
||||
"jszip": "^3.7.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "^3.1.25",
|
||||
"virtual-dom": "^2.1.1",
|
||||
"xmlbuilder2": "2.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
|
|
@ -6466,6 +6731,106 @@
|
|||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/html-to-vdom/-/html-to-vdom-0.7.0.tgz",
|
||||
"integrity": "sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"ent": "^2.0.0",
|
||||
"htmlparser2": "^3.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/dom-serializer": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
|
||||
"integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.0.1",
|
||||
"entities": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/entities": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
||||
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/domelementtype": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
|
||||
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/domhandler": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
|
||||
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/domutils": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
|
||||
"integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "0",
|
||||
"domelementtype": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/entities": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
|
||||
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/htmlparser2": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
|
||||
"integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^1.3.1",
|
||||
"domhandler": "^2.3.0",
|
||||
"domutils": "^1.5.1",
|
||||
"entities": "^1.1.1",
|
||||
"inherits": "^2.0.1",
|
||||
"readable-stream": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
|
|
@ -6590,6 +6955,30 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
|
||||
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue": "6.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/image-to-base64": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/image-to-base64/-/image-to-base64-2.2.0.tgz",
|
||||
"integrity": "sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imap": {
|
||||
"version": "0.8.19",
|
||||
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
|
||||
|
|
@ -6626,6 +7015,12 @@
|
|||
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -6673,6 +7068,11 @@
|
|||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/individual": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/individual/-/individual-3.0.0.tgz",
|
||||
"integrity": "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g=="
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
|
|
@ -6854,6 +7254,15 @@
|
|||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-object": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
|
||||
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-path-inside": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
||||
|
|
@ -7696,6 +8105,18 @@
|
|||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
|
|
@ -7812,6 +8233,15 @@
|
|||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
|
|
@ -8177,6 +8607,21 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/min-document": {
|
||||
"version": "2.19.2",
|
||||
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
|
||||
"integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dom-walk": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
||||
|
|
@ -8300,6 +8745,24 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/native-duplexpair": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
|
||||
|
|
@ -8329,6 +8792,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next-tick": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.2.2.tgz",
|
||||
"integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
|
|
@ -8670,6 +9139,12 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parchment": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||
|
|
@ -9179,6 +9654,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
|
@ -9595,6 +10079,23 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex-test": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
||||
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"is-regex": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
|
|
@ -9610,6 +10111,12 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
|
||||
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
|
|
@ -9744,6 +10251,12 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
|
|
@ -10020,6 +10533,11 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/string-template": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz",
|
||||
"integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
|
|
@ -10685,6 +11203,22 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/virtual-dom": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/virtual-dom/-/virtual-dom-2.1.1.tgz",
|
||||
"integrity": "sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browser-split": "0.0.1",
|
||||
"error": "^4.3.0",
|
||||
"ev-store": "^7.0.0",
|
||||
"global": "^4.3.0",
|
||||
"is-object": "^1.0.1",
|
||||
"next-tick": "^0.2.2",
|
||||
"x-is-array": "0.1.0",
|
||||
"x-is-string": "0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/walker": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
||||
|
|
@ -10862,6 +11396,80 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/x-is-array": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/x-is-array/-/x-is-array-0.1.0.tgz",
|
||||
"integrity": "sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA=="
|
||||
},
|
||||
"node_modules/x-is-string": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz",
|
||||
"integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w=="
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xml-js": {
|
||||
"version": "1.6.11",
|
||||
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"xml-js": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-2.1.2.tgz",
|
||||
"integrity": "sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/dom": "1.15.5",
|
||||
"@oozcitak/infra": "1.0.5",
|
||||
"@oozcitak/util": "8.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.5.tgz",
|
||||
"integrity": "sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "1.0.5",
|
||||
"@oozcitak/url": "1.0.0",
|
||||
"@oozcitak/util": "8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom/node_modules/@oozcitak/util": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
|
||||
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2/node_modules/@oozcitak/util": {
|
||||
"version": "8.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.3.tgz",
|
||||
"integrity": "sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -26,12 +26,15 @@
|
|||
"@types/mssql": "^9.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카
|
|||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
|
|
@ -81,6 +80,7 @@ import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자
|
|||
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -249,7 +249,6 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값
|
|||
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||
|
|
@ -257,6 +256,7 @@ app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력
|
|||
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
|
|
|
|||
|
|
@ -3245,6 +3245,7 @@ export const resetUserPassword = async (
|
|||
|
||||
/**
|
||||
* 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용)
|
||||
* column_labels 테이블에서 라벨 정보도 함께 가져옴
|
||||
*/
|
||||
export async function getTableSchema(
|
||||
req: AuthenticatedRequest,
|
||||
|
|
@ -3264,20 +3265,25 @@ export async function getTableSchema(
|
|||
|
||||
logger.info("테이블 스키마 조회", { tableName, companyCode });
|
||||
|
||||
// information_schema에서 컬럼 정보 가져오기
|
||||
// information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
|
||||
const schemaQuery = `
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default,
|
||||
character_maximum_length,
|
||||
numeric_precision,
|
||||
numeric_scale
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
ORDER BY ordinal_position
|
||||
ic.column_name,
|
||||
ic.data_type,
|
||||
ic.is_nullable,
|
||||
ic.column_default,
|
||||
ic.character_maximum_length,
|
||||
ic.numeric_precision,
|
||||
ic.numeric_scale,
|
||||
cl.column_label,
|
||||
cl.display_order
|
||||
FROM information_schema.columns ic
|
||||
LEFT JOIN column_labels cl
|
||||
ON cl.table_name = ic.table_name
|
||||
AND cl.column_name = ic.column_name
|
||||
WHERE ic.table_schema = 'public'
|
||||
AND ic.table_name = $1
|
||||
ORDER BY COALESCE(cl.display_order, ic.ordinal_position), ic.ordinal_position
|
||||
`;
|
||||
|
||||
const columns = await query<any>(schemaQuery, [tableName]);
|
||||
|
|
@ -3290,9 +3296,10 @@ export async function getTableSchema(
|
|||
return;
|
||||
}
|
||||
|
||||
// 컬럼 정보를 간단한 형태로 변환
|
||||
// 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
|
||||
const columnList = columns.map((col: any) => ({
|
||||
name: col.column_name,
|
||||
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
|
||||
type: col.data_type,
|
||||
nullable: col.is_nullable === "YES",
|
||||
default: col.column_default,
|
||||
|
|
@ -3387,13 +3394,23 @@ export async function copyMenu(
|
|||
}
|
||||
: undefined;
|
||||
|
||||
// 추가 복사 옵션 (카테고리, 코드, 채번규칙 등)
|
||||
const additionalCopyOptions = req.body.additionalCopyOptions
|
||||
? {
|
||||
copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true,
|
||||
copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true,
|
||||
copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// 메뉴 복사 실행
|
||||
const menuCopyService = new MenuCopyService();
|
||||
const result = await menuCopyService.copyMenu(
|
||||
parseInt(menuObjid, 10),
|
||||
targetCompanyCode,
|
||||
userId,
|
||||
screenNameConfig
|
||||
screenNameConfig,
|
||||
additionalCopyOptions
|
||||
);
|
||||
|
||||
logger.info("✅ 메뉴 복사 API 성공");
|
||||
|
|
|
|||
|
|
@ -662,6 +662,10 @@ export const getParentOptions = async (
|
|||
/**
|
||||
* 연쇄 관계로 자식 옵션 조회
|
||||
* 실제 연쇄 드롭다운에서 사용하는 API
|
||||
*
|
||||
* 다중 부모값 지원:
|
||||
* - parentValue: 단일 값 (예: "공정검사")
|
||||
* - parentValues: 다중 값 (예: "공정검사,출하검사" 또는 배열)
|
||||
*/
|
||||
export const getCascadingOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
|
|
@ -669,10 +673,26 @@ export const getCascadingOptions = async (
|
|||
) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const { parentValue } = req.query;
|
||||
const { parentValue, parentValues } = req.query;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!parentValue) {
|
||||
// 다중 부모값 파싱
|
||||
let parentValueArray: string[] = [];
|
||||
|
||||
if (parentValues) {
|
||||
// parentValues가 있으면 우선 사용 (다중 선택)
|
||||
if (Array.isArray(parentValues)) {
|
||||
parentValueArray = parentValues.map(v => String(v));
|
||||
} else {
|
||||
// 콤마로 구분된 문자열
|
||||
parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v);
|
||||
}
|
||||
} else if (parentValue) {
|
||||
// 기존 단일 값 호환
|
||||
parentValueArray = [String(parentValue)];
|
||||
}
|
||||
|
||||
if (parentValueArray.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: [],
|
||||
|
|
@ -714,13 +734,17 @@ export const getCascadingOptions = async (
|
|||
|
||||
const relation = relationResult.rows[0];
|
||||
|
||||
// 자식 옵션 조회
|
||||
// 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용
|
||||
// SQL Injection 방지를 위해 파라미터화된 쿼리 사용
|
||||
const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', ');
|
||||
|
||||
let optionsQuery = `
|
||||
SELECT
|
||||
SELECT DISTINCT
|
||||
${relation.child_value_column} as value,
|
||||
${relation.child_label_column} as label
|
||||
${relation.child_label_column} as label,
|
||||
${relation.child_filter_column} as parent_value
|
||||
FROM ${relation.child_table}
|
||||
WHERE ${relation.child_filter_column} = $1
|
||||
WHERE ${relation.child_filter_column} IN (${placeholders})
|
||||
`;
|
||||
|
||||
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
||||
|
|
@ -730,7 +754,8 @@ export const getCascadingOptions = async (
|
|||
[relation.child_table]
|
||||
);
|
||||
|
||||
const optionsParams: any[] = [parentValue];
|
||||
const optionsParams: any[] = [...parentValueArray];
|
||||
let paramIndex = parentValueArray.length + 1;
|
||||
|
||||
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
||||
if (
|
||||
|
|
@ -738,8 +763,9 @@ export const getCascadingOptions = async (
|
|||
tableInfoResult.rowCount > 0 &&
|
||||
companyCode !== "*"
|
||||
) {
|
||||
optionsQuery += ` AND company_code = $2`;
|
||||
optionsQuery += ` AND company_code = $${paramIndex}`;
|
||||
optionsParams.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 정렬
|
||||
|
|
@ -751,9 +777,9 @@ export const getCascadingOptions = async (
|
|||
|
||||
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
||||
|
||||
logger.info("연쇄 옵션 조회", {
|
||||
logger.info("연쇄 옵션 조회 (다중 부모값 지원)", {
|
||||
relationCode: code,
|
||||
parentValue,
|
||||
parentValues: parentValueArray,
|
||||
optionsCount: optionsResult.rowCount,
|
||||
});
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -424,18 +424,16 @@ export class EntityJoinController {
|
|||
config.referenceTable
|
||||
);
|
||||
|
||||
// 현재 display_column으로 사용 중인 컬럼 제외
|
||||
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
|
||||
const currentDisplayColumn =
|
||||
config.displayColumn || config.displayColumns[0];
|
||||
const availableColumns = columns.filter(
|
||||
(col) => col.columnName !== currentDisplayColumn
|
||||
);
|
||||
|
||||
|
||||
// 모든 컬럼 표시 (기본 표시 컬럼도 포함)
|
||||
return {
|
||||
joinConfig: config,
|
||||
tableName: config.referenceTable,
|
||||
currentDisplayColumn: currentDisplayColumn,
|
||||
availableColumns: availableColumns.map((col) => ({
|
||||
availableColumns: columns.map((col) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnName,
|
||||
dataType: col.dataType,
|
||||
|
|
|
|||
|
|
@ -1,276 +0,0 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 수주 번호 생성 함수
|
||||
* 형식: ORD + YYMMDD + 4자리 시퀀스
|
||||
* 예: ORD250114001
|
||||
*/
|
||||
async function generateOrderNumber(companyCode: string): Promise<string> {
|
||||
const pool = getPool();
|
||||
const today = new Date();
|
||||
const year = today.getFullYear().toString().slice(2); // 25
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0"); // 01
|
||||
const day = String(today.getDate()).padStart(2, "0"); // 14
|
||||
const dateStr = `${year}${month}${day}`; // 250114
|
||||
|
||||
// 당일 수주 카운트 조회
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM order_mng_master
|
||||
WHERE objid LIKE $1
|
||||
AND writer LIKE $2
|
||||
`;
|
||||
|
||||
const pattern = `ORD${dateStr}%`;
|
||||
const result = await pool.query(countQuery, [pattern, `%${companyCode}%`]);
|
||||
const count = parseInt(result.rows[0]?.count || "0");
|
||||
const seq = count + 1;
|
||||
|
||||
return `ORD${dateStr}${String(seq).padStart(4, "0")}`; // ORD250114001
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 등록 API
|
||||
* POST /api/orders
|
||||
*/
|
||||
export async function createOrder(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const {
|
||||
inputMode, // 입력 방식
|
||||
customerCode, // 거래처 코드
|
||||
deliveryDate, // 납품일
|
||||
items, // 품목 목록
|
||||
memo, // 메모
|
||||
} = req.body;
|
||||
|
||||
// 멀티테넌시
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
// 유효성 검사
|
||||
if (!customerCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "거래처 코드는 필수입니다",
|
||||
});
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "품목은 최소 1개 이상 필요합니다",
|
||||
});
|
||||
}
|
||||
|
||||
// 수주 번호 생성
|
||||
const orderNo = await generateOrderNumber(companyCode);
|
||||
|
||||
// 전체 금액 계산
|
||||
const totalAmount = items.reduce(
|
||||
(sum: number, item: any) => sum + (item.amount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// 수주 마스터 생성
|
||||
const masterQuery = `
|
||||
INSERT INTO order_mng_master (
|
||||
objid,
|
||||
partner_objid,
|
||||
final_delivery_date,
|
||||
reason,
|
||||
status,
|
||||
reg_date,
|
||||
writer
|
||||
) VALUES ($1, $2, $3, $4, $5, NOW(), $6)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const masterResult = await pool.query(masterQuery, [
|
||||
orderNo,
|
||||
customerCode,
|
||||
deliveryDate || null,
|
||||
memo || null,
|
||||
"진행중",
|
||||
`${userId}|${companyCode}`,
|
||||
]);
|
||||
|
||||
const masterObjid = masterResult.rows[0].objid;
|
||||
|
||||
// 수주 상세 (품목) 생성
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const subObjid = `${orderNo}_${i + 1}`;
|
||||
|
||||
const subQuery = `
|
||||
INSERT INTO order_mng_sub (
|
||||
objid,
|
||||
order_mng_master_objid,
|
||||
part_objid,
|
||||
partner_objid,
|
||||
partner_price,
|
||||
partner_qty,
|
||||
delivery_date,
|
||||
status,
|
||||
regdate,
|
||||
writer
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
|
||||
`;
|
||||
|
||||
await pool.query(subQuery, [
|
||||
subObjid,
|
||||
masterObjid,
|
||||
item.item_code || item.id, // 품목 코드
|
||||
customerCode,
|
||||
item.unit_price || 0,
|
||||
item.quantity || 0,
|
||||
item.delivery_date || deliveryDate || null,
|
||||
"진행중",
|
||||
`${userId}|${companyCode}`,
|
||||
]);
|
||||
}
|
||||
|
||||
logger.info("수주 등록 성공", {
|
||||
companyCode,
|
||||
orderNo,
|
||||
masterObjid,
|
||||
itemCount: items.length,
|
||||
totalAmount,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
orderNo,
|
||||
masterObjid,
|
||||
itemCount: items.length,
|
||||
totalAmount,
|
||||
},
|
||||
message: "수주가 등록되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("수주 등록 오류", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "수주 등록 중 오류가 발생했습니다",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 목록 조회 API (마스터 + 품목 JOIN)
|
||||
* GET /api/orders
|
||||
*/
|
||||
export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const { page = "1", limit = "20", searchText = "" } = req.query;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||
|
||||
// WHERE 조건
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 멀티테넌시 (writer 필드에 company_code 포함)
|
||||
if (companyCode !== "*") {
|
||||
whereConditions.push(`m.writer LIKE $${paramIndex}`);
|
||||
params.push(`%${companyCode}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 검색
|
||||
if (searchText) {
|
||||
whereConditions.push(`m.objid LIKE $${paramIndex}`);
|
||||
params.push(`%${searchText}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 카운트 쿼리 (고유한 수주 개수)
|
||||
const countQuery = `
|
||||
SELECT COUNT(DISTINCT m.objid) as count
|
||||
FROM order_mng_master m
|
||||
${whereClause}
|
||||
`;
|
||||
const countResult = await pool.query(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0]?.count || "0");
|
||||
|
||||
// 데이터 쿼리 (마스터 + 품목 JOIN)
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
m.objid as order_no,
|
||||
m.partner_objid,
|
||||
m.final_delivery_date,
|
||||
m.reason,
|
||||
m.status,
|
||||
m.reg_date,
|
||||
m.writer,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
CASE WHEN s.objid IS NOT NULL THEN
|
||||
json_build_object(
|
||||
'sub_objid', s.objid,
|
||||
'part_objid', s.part_objid,
|
||||
'partner_price', s.partner_price,
|
||||
'partner_qty', s.partner_qty,
|
||||
'delivery_date', s.delivery_date,
|
||||
'status', s.status,
|
||||
'regdate', s.regdate
|
||||
)
|
||||
END
|
||||
ORDER BY s.regdate
|
||||
) FILTER (WHERE s.objid IS NOT NULL),
|
||||
'[]'::json
|
||||
) as items
|
||||
FROM order_mng_master m
|
||||
LEFT JOIN order_mng_sub s ON m.objid = s.order_mng_master_objid
|
||||
${whereClause}
|
||||
GROUP BY m.objid, m.partner_objid, m.final_delivery_date, m.reason, m.status, m.reg_date, m.writer
|
||||
ORDER BY m.reg_date DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
params.push(parseInt(limit as string));
|
||||
params.push(offset);
|
||||
|
||||
const dataResult = await pool.query(dataQuery, params);
|
||||
|
||||
logger.info("수주 목록 조회 성공", {
|
||||
companyCode,
|
||||
total,
|
||||
page: parseInt(page as string),
|
||||
itemCount: dataResult.rows.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dataResult.rows,
|
||||
pagination: {
|
||||
total,
|
||||
page: parseInt(page as string),
|
||||
limit: parseInt(limit as string),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("수주 목록 조회 오류", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -30,6 +30,29 @@ export const getCategoryColumns = async (req: AuthenticatedRequest, res: Respons
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
|
||||
*/
|
||||
export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const columns = await tableCategoryValueService.getAllCategoryColumns(companyCode);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: columns,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "전체 카테고리 컬럼 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -767,11 +767,12 @@ export async function getTableData(
|
|||
|
||||
const tableManagementService = new TableManagementService();
|
||||
|
||||
// 🆕 현재 사용자 필터 적용
|
||||
// 🆕 현재 사용자 필터 적용 (autoFilter가 없거나 enabled가 명시적으로 false가 아니면 기본 적용)
|
||||
let enhancedSearch = { ...search };
|
||||
if (autoFilter?.enabled && req.user) {
|
||||
const filterColumn = autoFilter.filterColumn || "company_code";
|
||||
const userField = autoFilter.userField || "companyCode";
|
||||
const shouldApplyAutoFilter = autoFilter?.enabled !== false; // 기본값: true
|
||||
if (shouldApplyAutoFilter && req.user) {
|
||||
const filterColumn = autoFilter?.filterColumn || "company_code";
|
||||
const userField = autoFilter?.userField || "companyCode";
|
||||
const userValue = (req.user as any)[userField];
|
||||
|
||||
if (userValue) {
|
||||
|
|
@ -877,7 +878,17 @@ export async function addTableData(
|
|||
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
|
||||
if (hasCompanyCodeColumn) {
|
||||
data.company_code = companyCode;
|
||||
logger.info(`🔒 멀티테넌시: company_code 자동 추가 - ${companyCode}`);
|
||||
logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우)
|
||||
const userId = req.user?.userId;
|
||||
if (userId && !data.writer) {
|
||||
const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer");
|
||||
if (hasWriterColumn) {
|
||||
data.writer = userId;
|
||||
logger.info(`writer 자동 추가 - ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,3 +50,7 @@ router.get("/data/:groupCode", getAutoFillData);
|
|||
|
||||
export default router;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -46,3 +46,7 @@ router.get("/filtered-options/:relationCode", getFilteredOptions);
|
|||
|
||||
export default router;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -62,3 +62,7 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions);
|
|||
|
||||
export default router;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -50,3 +50,7 @@ router.get("/options/:exclusionCode", getExcludedOptions);
|
|||
|
||||
export default router;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { createOrder, getOrders } from "../controllers/orderController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* 수주 등록
|
||||
* POST /api/orders
|
||||
*/
|
||||
router.post("/", authenticateToken, createOrder);
|
||||
|
||||
/**
|
||||
* 수주 목록 조회
|
||||
* GET /api/orders
|
||||
*/
|
||||
router.get("/", authenticateToken, getOrders);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -56,6 +56,11 @@ router.post("/upload-image", upload.single("image"), (req, res, next) =>
|
|||
reportController.uploadImage(req, res, next)
|
||||
);
|
||||
|
||||
// WORD(DOCX) 내보내기
|
||||
router.post("/export-word", (req, res, next) =>
|
||||
reportController.exportToWord(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 목록
|
||||
router.get("/", (req, res, next) =>
|
||||
reportController.getReports(req, res, next)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,15 @@ const router = Router();
|
|||
// 모든 role 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* 사용자 권한 그룹 조회 (/:id 보다 먼저 정의해야 함)
|
||||
*/
|
||||
// 현재 사용자가 속한 권한 그룹 조회
|
||||
router.get("/user/my-groups", getUserRoleGroups);
|
||||
|
||||
// 특정 사용자가 속한 권한 그룹 조회
|
||||
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
|
||||
|
||||
/**
|
||||
* 권한 그룹 CRUD
|
||||
*/
|
||||
|
|
@ -67,13 +76,4 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions);
|
|||
// 메뉴 권한 설정
|
||||
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
|
||||
|
||||
/**
|
||||
* 사용자 권한 그룹 조회
|
||||
*/
|
||||
// 현재 사용자가 속한 권한 그룹 조회
|
||||
router.get("/user/my-groups", getUserRoleGroups);
|
||||
|
||||
// 특정 사용자가 속한 권한 그룹 조회
|
||||
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Router } from "express";
|
||||
import {
|
||||
getCategoryColumns,
|
||||
getAllCategoryColumns,
|
||||
getCategoryValues,
|
||||
addCategoryValue,
|
||||
updateCategoryValue,
|
||||
|
|
@ -22,6 +23,10 @@ const router = Router();
|
|||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
|
||||
// 주의: 더 구체적인 라우트보다 먼저 와야 함
|
||||
router.get("/all-columns", getAllCategoryColumns);
|
||||
|
||||
// 테이블의 카테고리 컬럼 목록 조회
|
||||
router.get("/:tableName/columns", getCategoryColumns);
|
||||
|
||||
|
|
|
|||
|
|
@ -86,11 +86,12 @@ export class CommonCodeService {
|
|||
}
|
||||
|
||||
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
||||
// company_code = '*'인 공통 데이터도 함께 조회
|
||||
if (userCompanyCode && userCompanyCode !== "*") {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
|
||||
values.push(userCompanyCode);
|
||||
paramIndex++;
|
||||
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode}`);
|
||||
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode} (공통 데이터 포함)`);
|
||||
} else if (userCompanyCode === "*") {
|
||||
// 최고 관리자는 모든 데이터 조회 가능
|
||||
logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
|
||||
|
|
@ -116,7 +117,7 @@ export class CommonCodeService {
|
|||
|
||||
const offset = (page - 1) * size;
|
||||
|
||||
// 카테고리 조회
|
||||
// code_category 테이블에서만 조회 (comm_code 제거)
|
||||
const categories = await query<CodeCategory>(
|
||||
`SELECT * FROM code_category
|
||||
${whereClause}
|
||||
|
|
@ -134,7 +135,7 @@ export class CommonCodeService {
|
|||
const total = parseInt(countResult?.count || "0");
|
||||
|
||||
logger.info(
|
||||
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
||||
`카테고리 조회 완료: code_category ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -224,7 +225,7 @@ export class CommonCodeService {
|
|||
paramIndex,
|
||||
});
|
||||
|
||||
// 코드 조회
|
||||
// code_info 테이블에서만 코드 조회 (comm_code fallback 제거)
|
||||
const codes = await query<CodeInfo>(
|
||||
`SELECT * FROM code_info
|
||||
${whereClause}
|
||||
|
|
@ -242,20 +243,9 @@ export class CommonCodeService {
|
|||
const total = parseInt(countResult?.count || "0");
|
||||
|
||||
logger.info(
|
||||
`✅ [getCodes] 코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})`
|
||||
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})`
|
||||
);
|
||||
|
||||
logger.info(`📊 [getCodes] 조회된 코드 상세:`, {
|
||||
categoryCode,
|
||||
menuObjid,
|
||||
codes: codes.map((c) => ({
|
||||
code_value: c.code_value,
|
||||
code_name: c.code_name,
|
||||
menu_objid: c.menu_objid,
|
||||
company_code: c.company_code,
|
||||
})),
|
||||
});
|
||||
|
||||
return { data: codes, total };
|
||||
} catch (error) {
|
||||
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
||||
|
|
|
|||
|
|
@ -854,6 +854,11 @@ export class DynamicFormService {
|
|||
if (tableColumns.includes("updated_at")) {
|
||||
changedFields.updated_at = new Date();
|
||||
}
|
||||
// updated_date 컬럼도 지원 (sales_order_mng 등)
|
||||
if (tableColumns.includes("updated_date")) {
|
||||
changedFields.updated_date = new Date();
|
||||
console.log("📅 updated_date 자동 추가:", changedFields.updated_date);
|
||||
}
|
||||
|
||||
console.log("🎯 실제 업데이트할 필드들:", changedFields);
|
||||
|
||||
|
|
@ -903,7 +908,7 @@ export class DynamicFormService {
|
|||
return `${key} = $${index + 1}::numeric`;
|
||||
} else if (dataType === "boolean") {
|
||||
return `${key} = $${index + 1}::boolean`;
|
||||
} else if (dataType === 'jsonb' || dataType === 'json') {
|
||||
} else if (dataType === "jsonb" || dataType === "json") {
|
||||
// 🆕 JSONB/JSON 타입은 명시적 캐스팅
|
||||
return `${key} = $${index + 1}::jsonb`;
|
||||
} else {
|
||||
|
|
@ -917,9 +922,13 @@ export class DynamicFormService {
|
|||
const values: any[] = Object.keys(changedFields).map((key) => {
|
||||
const value = changedFields[key];
|
||||
const dataType = columnTypes[key];
|
||||
|
||||
|
||||
// JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환
|
||||
if ((dataType === 'jsonb' || dataType === 'json') && (Array.isArray(value) || (typeof value === 'object' && value !== null))) {
|
||||
if (
|
||||
(dataType === "jsonb" || dataType === "json") &&
|
||||
(Array.isArray(value) ||
|
||||
(typeof value === "object" && value !== null))
|
||||
) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return value;
|
||||
|
|
@ -1588,6 +1597,7 @@ export class DynamicFormService {
|
|||
|
||||
/**
|
||||
* 제어관리 실행 (화면에 설정된 경우)
|
||||
* 다중 제어를 순서대로 순차 실행 지원
|
||||
*/
|
||||
private async executeDataflowControlIfConfigured(
|
||||
screenId: number,
|
||||
|
|
@ -1629,105 +1639,67 @@ export class DynamicFormService {
|
|||
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
|
||||
hasDiagramId:
|
||||
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
|
||||
hasFlowControls:
|
||||
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
||||
});
|
||||
|
||||
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
||||
if (
|
||||
properties?.componentType === "button-primary" &&
|
||||
properties?.componentConfig?.action?.type === "save" &&
|
||||
properties?.webTypeConfig?.enableDataflowControl === true &&
|
||||
properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId
|
||||
properties?.webTypeConfig?.enableDataflowControl === true
|
||||
) {
|
||||
controlConfigFound = true;
|
||||
const diagramId =
|
||||
properties.webTypeConfig.dataflowConfig.selectedDiagramId;
|
||||
const relationshipId =
|
||||
properties.webTypeConfig.dataflowConfig.selectedRelationshipId;
|
||||
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
||||
|
||||
console.log(`🎯 제어관리 설정 발견:`, {
|
||||
componentId: layout.component_id,
|
||||
diagramId,
|
||||
relationshipId,
|
||||
triggerType,
|
||||
});
|
||||
// 다중 제어 설정 확인 (flowControls 배열)
|
||||
const flowControls = dataflowConfig?.flowControls || [];
|
||||
|
||||
// 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주)
|
||||
let controlResult: any;
|
||||
// flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행
|
||||
if (flowControls.length > 0) {
|
||||
controlConfigFound = true;
|
||||
console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}개`);
|
||||
|
||||
if (!relationshipId) {
|
||||
// 노드 플로우 실행
|
||||
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
|
||||
const { NodeFlowExecutionService } = await import(
|
||||
"./nodeFlowExecutionService"
|
||||
// 순서대로 정렬
|
||||
const sortedControls = [...flowControls].sort(
|
||||
(a: any, b: any) => (a.order || 0) - (b.order || 0)
|
||||
);
|
||||
|
||||
const executionResult = await NodeFlowExecutionService.executeFlow(
|
||||
// 다중 제어 순차 실행
|
||||
await this.executeMultipleFlowControls(
|
||||
sortedControls,
|
||||
savedData,
|
||||
screenId,
|
||||
tableName,
|
||||
triggerType,
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
} else if (dataflowConfig?.selectedDiagramId) {
|
||||
// 기존 단일 제어 실행 (하위 호환성)
|
||||
controlConfigFound = true;
|
||||
const diagramId = dataflowConfig.selectedDiagramId;
|
||||
const relationshipId = dataflowConfig.selectedRelationshipId;
|
||||
|
||||
console.log(`🎯 단일 제어관리 설정 발견:`, {
|
||||
componentId: layout.component_id,
|
||||
diagramId,
|
||||
{
|
||||
sourceData: [savedData],
|
||||
dataSourceType: "formData",
|
||||
buttonId: "save-button",
|
||||
screenId: screenId,
|
||||
userId: userId,
|
||||
companyCode: companyCode,
|
||||
formData: savedData,
|
||||
}
|
||||
);
|
||||
relationshipId,
|
||||
triggerType,
|
||||
});
|
||||
|
||||
controlResult = {
|
||||
success: executionResult.success,
|
||||
message: executionResult.message,
|
||||
executedActions: executionResult.nodes?.map((node) => ({
|
||||
nodeId: node.nodeId,
|
||||
status: node.status,
|
||||
duration: node.duration,
|
||||
})),
|
||||
errors: executionResult.nodes
|
||||
?.filter((node) => node.status === "failed")
|
||||
.map((node) => node.error || "실행 실패"),
|
||||
};
|
||||
} else {
|
||||
// 관계 기반 제어관리 실행
|
||||
console.log(
|
||||
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
|
||||
await this.executeSingleFlowControl(
|
||||
diagramId,
|
||||
relationshipId,
|
||||
savedData,
|
||||
screenId,
|
||||
tableName,
|
||||
triggerType,
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
controlResult =
|
||||
await this.dataflowControlService.executeDataflowControl(
|
||||
diagramId,
|
||||
relationshipId,
|
||||
triggerType,
|
||||
savedData,
|
||||
tableName,
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🎯 제어관리 실행 결과:`, controlResult);
|
||||
|
||||
if (controlResult.success) {
|
||||
console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`);
|
||||
if (
|
||||
controlResult.executedActions &&
|
||||
controlResult.executedActions.length > 0
|
||||
) {
|
||||
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
|
||||
}
|
||||
|
||||
// 오류가 있는 경우 경고 로그 출력 (성공이지만 일부 액션 실패)
|
||||
if (controlResult.errors && controlResult.errors.length > 0) {
|
||||
console.warn(
|
||||
`⚠️ 제어관리 실행 중 일부 오류 발생:`,
|
||||
controlResult.errors
|
||||
);
|
||||
// 오류 정보를 별도로 저장하여 필요시 사용자에게 알림 가능
|
||||
// 현재는 로그만 출력하고 메인 저장 프로세스는 계속 진행
|
||||
}
|
||||
} else {
|
||||
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
|
||||
// 제어관리 실패는 메인 저장 프로세스에 영향을 주지 않음
|
||||
}
|
||||
|
||||
// 첫 번째 설정된 제어관리만 실행 (여러 개가 있을 경우)
|
||||
// 첫 번째 설정된 버튼의 제어관리만 실행
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1741,6 +1713,218 @@ export class DynamicFormService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다중 제어 순차 실행
|
||||
*/
|
||||
private async executeMultipleFlowControls(
|
||||
flowControls: Array<{
|
||||
id: string;
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
executionTiming: string;
|
||||
order: number;
|
||||
}>,
|
||||
savedData: Record<string, any>,
|
||||
screenId: number,
|
||||
tableName: string,
|
||||
triggerType: "insert" | "update" | "delete",
|
||||
userId: string,
|
||||
companyCode: string
|
||||
): Promise<void> {
|
||||
console.log(`🚀 다중 제어 순차 실행 시작: ${flowControls.length}개`);
|
||||
|
||||
const { NodeFlowExecutionService } = await import(
|
||||
"./nodeFlowExecutionService"
|
||||
);
|
||||
|
||||
const results: Array<{
|
||||
order: number;
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
duration: number;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < flowControls.length; i++) {
|
||||
const control = flowControls[i];
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(
|
||||
`\n📍 [${i + 1}/${flowControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})`
|
||||
);
|
||||
|
||||
try {
|
||||
// 유효하지 않은 flowId 스킵
|
||||
if (!control.flowId || control.flowId <= 0) {
|
||||
console.warn(`⚠️ 유효하지 않은 flowId, 스킵: ${control.flowId}`);
|
||||
results.push({
|
||||
order: control.order,
|
||||
flowId: control.flowId,
|
||||
flowName: control.flowName,
|
||||
success: false,
|
||||
message: "유효하지 않은 flowId",
|
||||
duration: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const executionResult = await NodeFlowExecutionService.executeFlow(
|
||||
control.flowId,
|
||||
{
|
||||
sourceData: [savedData],
|
||||
dataSourceType: "formData",
|
||||
buttonId: "save-button",
|
||||
screenId: screenId,
|
||||
userId: userId,
|
||||
companyCode: companyCode,
|
||||
formData: savedData,
|
||||
}
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
results.push({
|
||||
order: control.order,
|
||||
flowId: control.flowId,
|
||||
flowName: control.flowName,
|
||||
success: executionResult.success,
|
||||
message: executionResult.message,
|
||||
duration,
|
||||
});
|
||||
|
||||
if (executionResult.success) {
|
||||
console.log(
|
||||
`✅ [${i + 1}/${flowControls.length}] 제어 성공: ${control.flowName} (${duration}ms)`
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`❌ [${i + 1}/${flowControls.length}] 제어 실패: ${control.flowName} - ${executionResult.message}`
|
||||
);
|
||||
// 이전 제어 실패 시 다음 제어 실행 중단
|
||||
console.warn(`⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단`);
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
const duration = Date.now() - startTime;
|
||||
console.error(
|
||||
`❌ [${i + 1}/${flowControls.length}] 제어 실행 오류: ${control.flowName}`,
|
||||
error
|
||||
);
|
||||
|
||||
results.push({
|
||||
order: control.order,
|
||||
flowId: control.flowId,
|
||||
flowName: control.flowName,
|
||||
success: false,
|
||||
message: error.message || "실행 오류",
|
||||
duration,
|
||||
});
|
||||
|
||||
// 오류 발생 시 다음 제어 실행 중단
|
||||
console.warn(`⚠️ 제어 실행 오류로 인해 나머지 제어 실행 중단`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 실행 결과 요약
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const failCount = results.filter((r) => !r.success).length;
|
||||
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
|
||||
|
||||
console.log(`\n📊 다중 제어 실행 완료:`, {
|
||||
total: flowControls.length,
|
||||
executed: results.length,
|
||||
success: successCount,
|
||||
failed: failCount,
|
||||
totalDuration: `${totalDuration}ms`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 제어 실행 (기존 로직, 하위 호환성)
|
||||
*/
|
||||
private async executeSingleFlowControl(
|
||||
diagramId: number,
|
||||
relationshipId: string | null,
|
||||
savedData: Record<string, any>,
|
||||
screenId: number,
|
||||
tableName: string,
|
||||
triggerType: "insert" | "update" | "delete",
|
||||
userId: string,
|
||||
companyCode: string
|
||||
): Promise<void> {
|
||||
let controlResult: any;
|
||||
|
||||
if (!relationshipId) {
|
||||
// 노드 플로우 실행
|
||||
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
|
||||
const { NodeFlowExecutionService } = await import(
|
||||
"./nodeFlowExecutionService"
|
||||
);
|
||||
|
||||
const executionResult = await NodeFlowExecutionService.executeFlow(
|
||||
diagramId,
|
||||
{
|
||||
sourceData: [savedData],
|
||||
dataSourceType: "formData",
|
||||
buttonId: "save-button",
|
||||
screenId: screenId,
|
||||
userId: userId,
|
||||
companyCode: companyCode,
|
||||
formData: savedData,
|
||||
}
|
||||
);
|
||||
|
||||
controlResult = {
|
||||
success: executionResult.success,
|
||||
message: executionResult.message,
|
||||
executedActions: executionResult.nodes?.map((node) => ({
|
||||
nodeId: node.nodeId,
|
||||
status: node.status,
|
||||
duration: node.duration,
|
||||
})),
|
||||
errors: executionResult.nodes
|
||||
?.filter((node) => node.status === "failed")
|
||||
.map((node) => node.error || "실행 실패"),
|
||||
};
|
||||
} else {
|
||||
// 관계 기반 제어관리 실행
|
||||
console.log(
|
||||
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
|
||||
);
|
||||
controlResult = await this.dataflowControlService.executeDataflowControl(
|
||||
diagramId,
|
||||
relationshipId,
|
||||
triggerType,
|
||||
savedData,
|
||||
tableName,
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🎯 제어관리 실행 결과:`, controlResult);
|
||||
|
||||
if (controlResult.success) {
|
||||
console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`);
|
||||
if (
|
||||
controlResult.executedActions &&
|
||||
controlResult.executedActions.length > 0
|
||||
) {
|
||||
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
|
||||
}
|
||||
|
||||
if (controlResult.errors && controlResult.errors.length > 0) {
|
||||
console.warn(
|
||||
`⚠️ 제어관리 실행 중 일부 오류 발생:`,
|
||||
controlResult.errors
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 특정 필드 값만 업데이트
|
||||
* (다른 테이블의 레코드 업데이트 지원)
|
||||
|
|
|
|||
|
|
@ -186,8 +186,13 @@ export class EntityJoinService {
|
|||
}
|
||||
}
|
||||
|
||||
// 별칭 컬럼명 생성 (writer -> writer_name)
|
||||
const aliasColumn = `${column.column_name}_name`;
|
||||
// 🎯 별칭 컬럼명 생성 - 사용자가 선택한 displayColumns 기반으로 동적 생성
|
||||
// 단일 컬럼: manager + user_name → manager_user_name
|
||||
// 여러 컬럼: 첫 번째 컬럼 기준 (나머지는 개별 alias로 처리됨)
|
||||
const firstDisplayColumn = displayColumns[0] || "name";
|
||||
const aliasColumn = `${column.column_name}_${firstDisplayColumn}`;
|
||||
|
||||
logger.info(`🔧 별칭 컬럼명 생성: ${column.column_name} + ${firstDisplayColumn} → ${aliasColumn}`);
|
||||
|
||||
const joinConfig: EntityJoinConfig = {
|
||||
sourceTable: tableName,
|
||||
|
|
|
|||
|
|
@ -164,8 +164,8 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
|||
}
|
||||
|
||||
try {
|
||||
const [rows] = await this.pool.execute(sql, params);
|
||||
return rows;
|
||||
const [rows] = await this.pool.execute(sql, params);
|
||||
return rows;
|
||||
} catch (error: any) {
|
||||
// 연결 닫힘 오류 감지
|
||||
if (
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -898,9 +898,10 @@ class NumberingRuleService {
|
|||
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
// 순번 (현재 순번으로 미리보기, 증가 안 함)
|
||||
// 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시)
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||
const nextSequence = (rule.currentSequence || 0) + 1;
|
||||
return String(nextSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "number": {
|
||||
|
|
@ -958,9 +959,10 @@ class NumberingRuleService {
|
|||
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
// 순번 (자동 증가 숫자)
|
||||
// 순번 (자동 증가 숫자 - 다음 번호 사용)
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||
const nextSequence = (rule.currentSequence || 0) + 1;
|
||||
return String(nextSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "number": {
|
||||
|
|
|
|||
|
|
@ -477,6 +477,12 @@ export class ReportService {
|
|||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`;
|
||||
|
||||
// components가 이미 문자열이면 그대로, 객체면 JSON.stringify
|
||||
const componentsData =
|
||||
typeof originalLayout.components === "string"
|
||||
? originalLayout.components
|
||||
: JSON.stringify(originalLayout.components);
|
||||
|
||||
await client.query(copyLayoutQuery, [
|
||||
newLayoutId,
|
||||
newReportId,
|
||||
|
|
@ -487,7 +493,7 @@ export class ReportService {
|
|||
originalLayout.margin_bottom,
|
||||
originalLayout.margin_left,
|
||||
originalLayout.margin_right,
|
||||
JSON.stringify(originalLayout.components),
|
||||
componentsData,
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
|
@ -561,7 +567,7 @@ export class ReportService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 저장 (쿼리 포함)
|
||||
* 레이아웃 저장 (쿼리 포함) - 페이지 기반 구조
|
||||
*/
|
||||
async saveLayout(
|
||||
reportId: string,
|
||||
|
|
@ -569,6 +575,19 @@ export class ReportService {
|
|||
userId: string
|
||||
): Promise<boolean> {
|
||||
return transaction(async (client) => {
|
||||
// 첫 번째 페이지 정보를 기본 레이아웃으로 사용
|
||||
const firstPage = data.layoutConfig.pages[0];
|
||||
const canvasWidth = firstPage?.width || 210;
|
||||
const canvasHeight = firstPage?.height || 297;
|
||||
const pageOrientation =
|
||||
canvasWidth > canvasHeight ? "landscape" : "portrait";
|
||||
const margins = firstPage?.margins || {
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
};
|
||||
|
||||
// 1. 레이아웃 저장
|
||||
const existingQuery = `
|
||||
SELECT layout_id FROM report_layout WHERE report_id = $1
|
||||
|
|
@ -576,7 +595,7 @@ export class ReportService {
|
|||
const existing = await client.query(existingQuery, [reportId]);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// 업데이트
|
||||
// 업데이트 - components 컬럼에 전체 layoutConfig 저장
|
||||
const updateQuery = `
|
||||
UPDATE report_layout
|
||||
SET
|
||||
|
|
@ -594,14 +613,14 @@ export class ReportService {
|
|||
`;
|
||||
|
||||
await client.query(updateQuery, [
|
||||
data.canvasWidth,
|
||||
data.canvasHeight,
|
||||
data.pageOrientation,
|
||||
data.marginTop,
|
||||
data.marginBottom,
|
||||
data.marginLeft,
|
||||
data.marginRight,
|
||||
JSON.stringify(data.components),
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
pageOrientation,
|
||||
margins.top,
|
||||
margins.bottom,
|
||||
margins.left,
|
||||
margins.right,
|
||||
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
|
||||
userId,
|
||||
reportId,
|
||||
]);
|
||||
|
|
@ -627,14 +646,14 @@ export class ReportService {
|
|||
await client.query(insertQuery, [
|
||||
layoutId,
|
||||
reportId,
|
||||
data.canvasWidth,
|
||||
data.canvasHeight,
|
||||
data.pageOrientation,
|
||||
data.marginTop,
|
||||
data.marginBottom,
|
||||
data.marginLeft,
|
||||
data.marginRight,
|
||||
JSON.stringify(data.components),
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
pageOrientation,
|
||||
margins.top,
|
||||
margins.bottom,
|
||||
margins.left,
|
||||
margins.right,
|
||||
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1751,7 +1751,7 @@ export class ScreenManagementService {
|
|||
// 기타
|
||||
label: "text-display",
|
||||
code: "select-basic",
|
||||
entity: "select-basic",
|
||||
entity: "entity-search-input", // 엔티티는 entity-search-input 사용
|
||||
category: "select-basic",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,82 @@ class TableCategoryValueService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
|
||||
* 테이블 선택 없이 등록된 모든 카테고리 컬럼을 조회합니다.
|
||||
*/
|
||||
async getAllCategoryColumns(
|
||||
companyCode: string
|
||||
): Promise<CategoryColumn[]> {
|
||||
try {
|
||||
logger.info("전체 카테고리 컬럼 목록 조회", { companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 컬럼 조회 (중복 제거)
|
||||
query = `
|
||||
SELECT
|
||||
tc.table_name AS "tableName",
|
||||
tc.column_name AS "columnName",
|
||||
tc.column_name AS "columnLabel",
|
||||
COALESCE(cv_count.cnt, 0) AS "valueCount"
|
||||
FROM (
|
||||
SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order
|
||||
FROM table_type_columns
|
||||
WHERE input_type = 'category'
|
||||
GROUP BY table_name, column_name
|
||||
) tc
|
||||
LEFT JOIN (
|
||||
SELECT table_name, column_name, COUNT(*) as cnt
|
||||
FROM table_column_category_values
|
||||
WHERE is_active = true
|
||||
GROUP BY table_name, column_name
|
||||
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
|
||||
ORDER BY tc.table_name, tc.display_order, tc.column_name
|
||||
`;
|
||||
params = [];
|
||||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값만 카운트 (중복 제거)
|
||||
query = `
|
||||
SELECT
|
||||
tc.table_name AS "tableName",
|
||||
tc.column_name AS "columnName",
|
||||
tc.column_name AS "columnLabel",
|
||||
COALESCE(cv_count.cnt, 0) AS "valueCount"
|
||||
FROM (
|
||||
SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order
|
||||
FROM table_type_columns
|
||||
WHERE input_type = 'category'
|
||||
GROUP BY table_name, column_name
|
||||
) tc
|
||||
LEFT JOIN (
|
||||
SELECT table_name, column_name, COUNT(*) as cnt
|
||||
FROM table_column_category_values
|
||||
WHERE is_active = true AND company_code = $1
|
||||
GROUP BY table_name, column_name
|
||||
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
|
||||
ORDER BY tc.table_name, tc.display_order, tc.column_name
|
||||
`;
|
||||
params = [companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info(`전체 카테고리 컬럼 ${result.rows.length}개 조회 완료`, {
|
||||
companyCode,
|
||||
});
|
||||
|
||||
return result.rows;
|
||||
} catch (error: any) {
|
||||
logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1447,7 +1447,8 @@ export class TableManagementService {
|
|||
tableName,
|
||||
columnName,
|
||||
actualValue,
|
||||
paramIndex
|
||||
paramIndex,
|
||||
operator // operator 전달 (equals면 직접 매칭)
|
||||
);
|
||||
|
||||
default:
|
||||
|
|
@ -1676,7 +1677,8 @@ export class TableManagementService {
|
|||
tableName: string,
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
paramIndex: number,
|
||||
operator: string = "contains" // 연결 필터에서 "equals"로 전달되면 직접 매칭
|
||||
): Promise<{
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
|
|
@ -1688,7 +1690,7 @@ export class TableManagementService {
|
|||
columnName
|
||||
);
|
||||
|
||||
// 🆕 배열 처리: IN 절 사용
|
||||
// 배열 처리: IN 절 사용
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
// 빈 배열이면 항상 false 조건
|
||||
|
|
@ -1720,13 +1722,35 @@ export class TableManagementService {
|
|||
}
|
||||
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const displayColumn = entityTypeInfo.displayColumn || "name";
|
||||
// equals 연산자인 경우: 직접 값 매칭 (연결 필터에서 코드 값으로 필터링 시 사용)
|
||||
if (operator === "equals") {
|
||||
logger.info(
|
||||
`🔍 [buildEntitySearchCondition] equals 연산자 - 직접 매칭: ${columnName} = ${value}`
|
||||
);
|
||||
return {
|
||||
whereClause: `${columnName} = $${paramIndex}`,
|
||||
values: [value],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색
|
||||
const referenceColumn = entityTypeInfo.referenceColumn || "id";
|
||||
const referenceTable = entityTypeInfo.referenceTable;
|
||||
|
||||
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
|
||||
let displayColumn = entityTypeInfo.displayColumn;
|
||||
if (!displayColumn || displayColumn === "none" || displayColumn === "") {
|
||||
displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn);
|
||||
logger.info(
|
||||
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
|
||||
);
|
||||
}
|
||||
|
||||
// 참조 테이블의 표시 컬럼으로 검색
|
||||
return {
|
||||
whereClause: `EXISTS (
|
||||
SELECT 1 FROM ${entityTypeInfo.referenceTable} ref
|
||||
SELECT 1 FROM ${referenceTable} ref
|
||||
WHERE ref.${referenceColumn} = ${columnName}
|
||||
AND ref.${displayColumn} ILIKE $${paramIndex}
|
||||
)`,
|
||||
|
|
@ -1754,6 +1778,66 @@ export class TableManagementService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 테이블에서 표시 컬럼 자동 감지 (entityJoinService와 동일한 우선순위)
|
||||
* 우선순위: *_name > name > label/*_label > title > referenceColumn
|
||||
*/
|
||||
private async findDisplayColumnForTable(
|
||||
tableName: string,
|
||||
referenceColumn?: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const result = await query<{ column_name: string }>(
|
||||
`SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND table_schema = 'public'
|
||||
ORDER BY ordinal_position`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
const allColumns = result.map((r) => r.column_name);
|
||||
|
||||
// entityJoinService와 동일한 우선순위
|
||||
// 1. *_name 컬럼 (item_name, customer_name, process_name 등) - company_name 제외
|
||||
const nameColumn = allColumns.find(
|
||||
(col) => col.endsWith("_name") && col !== "company_name"
|
||||
);
|
||||
if (nameColumn) {
|
||||
return nameColumn;
|
||||
}
|
||||
|
||||
// 2. name 컬럼
|
||||
if (allColumns.includes("name")) {
|
||||
return "name";
|
||||
}
|
||||
|
||||
// 3. label 또는 *_label 컬럼
|
||||
const labelColumn = allColumns.find(
|
||||
(col) => col === "label" || col.endsWith("_label")
|
||||
);
|
||||
if (labelColumn) {
|
||||
return labelColumn;
|
||||
}
|
||||
|
||||
// 4. title 컬럼
|
||||
if (allColumns.includes("title")) {
|
||||
return "title";
|
||||
}
|
||||
|
||||
// 5. 참조 컬럼 (referenceColumn)
|
||||
if (referenceColumn && allColumns.includes(referenceColumn)) {
|
||||
return referenceColumn;
|
||||
}
|
||||
|
||||
// 6. 기본값: 첫 번째 비-id 컬럼 또는 id
|
||||
return allColumns.find((col) => col !== "id") || "id";
|
||||
} catch (error) {
|
||||
logger.error(`표시 컬럼 감지 실패: ${tableName}`, error);
|
||||
return referenceColumn || "id"; // 오류 시 기본값
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 불린 검색 조건 구성
|
||||
*/
|
||||
|
|
@ -2205,6 +2289,13 @@ export class TableManagementService {
|
|||
|
||||
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
||||
|
||||
// created_date 컬럼이 있고 값이 없으면 자동으로 현재 시간 추가
|
||||
const hasCreatedDate = columnTypeMap.has("created_date");
|
||||
if (hasCreatedDate && !data.created_date) {
|
||||
data.created_date = new Date().toISOString();
|
||||
logger.info(`created_date 자동 추가: ${data.created_date}`);
|
||||
}
|
||||
|
||||
// 컬럼명과 값을 분리하고 타입에 맞게 변환
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data).map((value, index) => {
|
||||
|
|
@ -2310,6 +2401,13 @@ export class TableManagementService {
|
|||
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
||||
logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys);
|
||||
|
||||
// updated_date 컬럼이 있으면 자동으로 현재 시간 추가
|
||||
const hasUpdatedDate = columnTypeMap.has("updated_date");
|
||||
if (hasUpdatedDate && !updatedData.updated_date) {
|
||||
updatedData.updated_date = new Date().toISOString();
|
||||
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
|
||||
}
|
||||
|
||||
// SET 절 생성 (수정할 데이터) - 먼저 생성
|
||||
const setConditions: string[] = [];
|
||||
const setValues: any[] = [];
|
||||
|
|
|
|||
|
|
@ -116,22 +116,55 @@ export interface UpdateReportRequest {
|
|||
useYn?: string;
|
||||
}
|
||||
|
||||
// 워터마크 설정
|
||||
export interface WatermarkConfig {
|
||||
enabled: boolean;
|
||||
type: "text" | "image";
|
||||
// 텍스트 워터마크
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
// 이미지 워터마크
|
||||
imageUrl?: string;
|
||||
// 공통 설정
|
||||
opacity: number; // 0~1
|
||||
style: "diagonal" | "center" | "tile";
|
||||
rotation?: number; // 대각선일 때 각도 (기본 -45)
|
||||
}
|
||||
|
||||
// 페이지 설정
|
||||
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[];
|
||||
watermark?: WatermarkConfig; // 전체 페이지 공유 워터마크
|
||||
}
|
||||
|
||||
// 레이아웃 저장 요청
|
||||
export interface SaveLayoutRequest {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
pageOrientation: string;
|
||||
marginTop: number;
|
||||
marginBottom: number;
|
||||
marginLeft: number;
|
||||
marginRight: number;
|
||||
components: any[];
|
||||
layoutConfig: ReportLayoutConfig;
|
||||
queries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: "MASTER" | "DETAIL";
|
||||
sqlQuery: string;
|
||||
parameters: string[];
|
||||
externalConnectionId?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -150,3 +183,113 @@ export interface CreateTemplateRequest {
|
|||
layoutConfig?: any;
|
||||
defaultQueries?: any;
|
||||
}
|
||||
|
||||
// 컴포넌트 설정 (프론트엔드와 동기화)
|
||||
export interface ComponentConfig {
|
||||
id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
fontWeight?: string;
|
||||
fontColor?: string;
|
||||
backgroundColor?: string;
|
||||
borderWidth?: number;
|
||||
borderColor?: string;
|
||||
borderRadius?: number;
|
||||
textAlign?: string;
|
||||
padding?: number;
|
||||
queryId?: string;
|
||||
fieldName?: string;
|
||||
defaultValue?: string;
|
||||
format?: string;
|
||||
visible?: boolean;
|
||||
printable?: boolean;
|
||||
conditional?: string;
|
||||
locked?: boolean;
|
||||
groupId?: string;
|
||||
// 이미지 전용
|
||||
imageUrl?: string;
|
||||
objectFit?: "contain" | "cover" | "fill" | "none";
|
||||
// 구분선 전용
|
||||
orientation?: "horizontal" | "vertical";
|
||||
lineStyle?: "solid" | "dashed" | "dotted" | "double";
|
||||
lineWidth?: number;
|
||||
lineColor?: string;
|
||||
// 서명/도장 전용
|
||||
showLabel?: boolean;
|
||||
labelText?: string;
|
||||
labelPosition?: "top" | "left" | "bottom" | "right";
|
||||
showUnderline?: boolean;
|
||||
personName?: string;
|
||||
// 테이블 전용
|
||||
tableColumns?: Array<{
|
||||
field: string;
|
||||
header: string;
|
||||
width?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
}>;
|
||||
headerBackgroundColor?: string;
|
||||
headerTextColor?: string;
|
||||
showBorder?: boolean;
|
||||
rowHeight?: number;
|
||||
// 페이지 번호 전용
|
||||
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber";
|
||||
// 카드 컴포넌트 전용
|
||||
cardTitle?: string;
|
||||
cardItems?: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
fieldName?: string;
|
||||
}>;
|
||||
labelWidth?: number;
|
||||
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;
|
||||
// 바코드 컴포넌트 전용
|
||||
barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
|
||||
barcodeValue?: string;
|
||||
barcodeFieldName?: string;
|
||||
showBarcodeText?: boolean;
|
||||
barcodeColor?: string;
|
||||
barcodeBackground?: string;
|
||||
barcodeMargin?: number;
|
||||
qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H";
|
||||
// QR코드 다중 필드 (JSON 형식)
|
||||
qrDataFields?: Array<{
|
||||
fieldName: string;
|
||||
label: string;
|
||||
}>;
|
||||
qrUseMultiField?: boolean;
|
||||
qrIncludeAllRows?: boolean;
|
||||
// 체크박스 컴포넌트 전용
|
||||
checkboxChecked?: boolean; // 체크 상태 (고정값)
|
||||
checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값)
|
||||
checkboxLabel?: string; // 체크박스 옆 레이블 텍스트
|
||||
checkboxSize?: number; // 체크박스 크기 (px)
|
||||
checkboxColor?: string; // 체크 색상
|
||||
checkboxBorderColor?: string; // 테두리 색상
|
||||
checkboxLabelPosition?: "left" | "right"; // 레이블 위치
|
||||
}
|
||||
|
|
|
|||
|
|
@ -582,3 +582,7 @@ const result = await executeNodeFlow(flowId, {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -355,3 +355,7 @@
|
|||
- [ ] 부모 화면에서 모달로 데이터가 전달되는가?
|
||||
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,347 @@
|
|||
# 즉시 저장(quickInsert) 버튼 액션 구현 계획서
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 목적
|
||||
화면에서 entity 타입 선택박스로 데이터를 선택한 후, 버튼 클릭 시 특정 테이블에 즉시 INSERT하는 기능 구현
|
||||
|
||||
### 1.2 사용 사례
|
||||
- **공정별 설비 관리**: 좌측에서 공정 선택 → 우측에서 설비 선택 → "설비 추가" 버튼 클릭 → `process_equipment` 테이블에 즉시 저장
|
||||
|
||||
### 1.3 화면 구성 예시
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [entity 선택박스] [버튼: quickInsert] │
|
||||
│ ┌─────────────────────────────┐ ┌──────────────┐ │
|
||||
│ │ MCT-01 - 머시닝센터 #1 ▼ │ │ + 설비 추가 │ │
|
||||
│ └─────────────────────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 기술 설계
|
||||
|
||||
### 2.1 버튼 액션 타입 추가
|
||||
|
||||
```typescript
|
||||
// types/screen-management.ts
|
||||
type ButtonActionType =
|
||||
| "save"
|
||||
| "cancel"
|
||||
| "delete"
|
||||
| "edit"
|
||||
| "add"
|
||||
| "search"
|
||||
| "reset"
|
||||
| "submit"
|
||||
| "close"
|
||||
| "popup"
|
||||
| "navigate"
|
||||
| "custom"
|
||||
| "quickInsert" // 🆕 즉시 저장
|
||||
```
|
||||
|
||||
### 2.2 quickInsert 설정 구조
|
||||
|
||||
```typescript
|
||||
interface QuickInsertColumnMapping {
|
||||
targetColumn: string; // 저장할 테이블의 컬럼명
|
||||
sourceType: "component" | "leftPanel" | "fixed" | "currentUser";
|
||||
|
||||
// sourceType별 추가 설정
|
||||
sourceComponentId?: string; // component: 값을 가져올 컴포넌트 ID
|
||||
sourceColumn?: string; // leftPanel: 좌측 선택 데이터의 컬럼명
|
||||
fixedValue?: any; // fixed: 고정값
|
||||
userField?: string; // currentUser: 사용자 정보 필드 (userId, userName, companyCode)
|
||||
}
|
||||
|
||||
interface QuickInsertConfig {
|
||||
targetTable: string; // 저장할 테이블명
|
||||
columnMappings: QuickInsertColumnMapping[];
|
||||
|
||||
// 저장 후 동작
|
||||
afterInsert?: {
|
||||
refreshRightPanel?: boolean; // 우측 패널 새로고침
|
||||
clearComponents?: string[]; // 초기화할 컴포넌트 ID 목록
|
||||
showSuccessMessage?: boolean; // 성공 메시지 표시
|
||||
successMessage?: string; // 커스텀 성공 메시지
|
||||
};
|
||||
|
||||
// 중복 체크 (선택사항)
|
||||
duplicateCheck?: {
|
||||
enabled: boolean;
|
||||
columns: string[]; // 중복 체크할 컬럼들
|
||||
errorMessage?: string; // 중복 시 에러 메시지
|
||||
};
|
||||
}
|
||||
|
||||
interface ButtonComponentConfig {
|
||||
// 기존 설정들...
|
||||
actionType: ButtonActionType;
|
||||
|
||||
// 🆕 quickInsert 전용 설정
|
||||
quickInsertConfig?: QuickInsertConfig;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 데이터 흐름
|
||||
|
||||
```
|
||||
1. 사용자가 entity 선택박스에서 설비 선택
|
||||
└─ equipment_code = "EQ-001" (내부값)
|
||||
└─ 표시: "MCT-01 - 머시닝센터 #1"
|
||||
|
||||
2. 사용자가 "설비 추가" 버튼 클릭
|
||||
|
||||
3. quickInsert 핸들러 실행
|
||||
├─ columnMappings 순회
|
||||
│ ├─ equipment_code: component에서 값 가져오기 → "EQ-001"
|
||||
│ └─ process_code: leftPanel에서 값 가져오기 → "PRC-001"
|
||||
│
|
||||
└─ INSERT 데이터 구성
|
||||
{
|
||||
equipment_code: "EQ-001",
|
||||
process_code: "PRC-001",
|
||||
company_code: "COMPANY_7", // 자동 추가
|
||||
writer: "wace" // 자동 추가
|
||||
}
|
||||
|
||||
4. API 호출: POST /api/table-management/tables/process_equipment/add
|
||||
|
||||
5. 성공 시
|
||||
├─ 성공 메시지 표시
|
||||
├─ 우측 패널(카드/테이블) 새로고침
|
||||
└─ 선택박스 초기화
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 구현 계획
|
||||
|
||||
### 3.1 Phase 1: 타입 정의 및 설정 UI
|
||||
|
||||
| 작업 | 파일 | 설명 |
|
||||
|------|------|------|
|
||||
| 1-1 | `frontend/types/screen-management.ts` | QuickInsertConfig 타입 추가 |
|
||||
| 1-2 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | quickInsert 설정 UI 추가 |
|
||||
|
||||
### 3.2 Phase 2: 버튼 액션 핸들러 구현
|
||||
|
||||
| 작업 | 파일 | 설명 |
|
||||
|------|------|------|
|
||||
| 2-1 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | quickInsert 핸들러 추가 |
|
||||
| 2-2 | 컴포넌트 값 수집 로직 | 같은 화면의 다른 컴포넌트에서 값 가져오기 |
|
||||
|
||||
### 3.3 Phase 3: 테스트 및 검증
|
||||
|
||||
| 작업 | 설명 |
|
||||
|------|------|
|
||||
| 3-1 | 공정별 설비 화면에서 테스트 |
|
||||
| 3-2 | 중복 저장 방지 테스트 |
|
||||
| 3-3 | 에러 처리 테스트 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 상세 구현
|
||||
|
||||
### 4.1 ButtonConfigPanel 설정 UI
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 버튼 액션 타입 │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 즉시 저장 (quickInsert) ▼ │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ─────────────── 즉시 저장 설정 ─────────────── │
|
||||
│ │
|
||||
│ 대상 테이블 * │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ process_equipment ▼ │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 컬럼 매핑 [+ 추가] │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 매핑 #1 [삭제] │ │
|
||||
│ │ 대상 컬럼: equipment_code │ │
|
||||
│ │ 값 소스: 컴포넌트 선택 │ │
|
||||
│ │ 컴포넌트: [equipment-select ▼] │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 매핑 #2 [삭제] │ │
|
||||
│ │ 대상 컬럼: process_code │ │
|
||||
│ │ 값 소스: 좌측 패널 데이터 │ │
|
||||
│ │ 소스 컬럼: process_code │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ─────────────── 저장 후 동작 ─────────────── │
|
||||
│ │
|
||||
│ ☑ 우측 패널 새로고침 │
|
||||
│ ☑ 선택박스 초기화 │
|
||||
│ ☑ 성공 메시지 표시 │
|
||||
│ │
|
||||
│ ─────────────── 중복 체크 (선택) ─────────────── │
|
||||
│ │
|
||||
│ ☐ 중복 체크 활성화 │
|
||||
│ 체크 컬럼: equipment_code, process_code │
|
||||
│ 에러 메시지: 이미 등록된 설비입니다. │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 핸들러 구현 (의사 코드)
|
||||
|
||||
```typescript
|
||||
const handleQuickInsert = async (config: QuickInsertConfig) => {
|
||||
// 1. 컬럼 매핑에서 값 수집
|
||||
const insertData: Record<string, any> = {};
|
||||
|
||||
for (const mapping of config.columnMappings) {
|
||||
let value: any;
|
||||
|
||||
switch (mapping.sourceType) {
|
||||
case "component":
|
||||
// 같은 화면의 컴포넌트에서 값 가져오기
|
||||
value = getComponentValue(mapping.sourceComponentId);
|
||||
break;
|
||||
|
||||
case "leftPanel":
|
||||
// 분할 패널 좌측 선택 데이터에서 값 가져오기
|
||||
value = splitPanelContext?.selectedLeftData?.[mapping.sourceColumn];
|
||||
break;
|
||||
|
||||
case "fixed":
|
||||
value = mapping.fixedValue;
|
||||
break;
|
||||
|
||||
case "currentUser":
|
||||
value = user?.[mapping.userField];
|
||||
break;
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
insertData[mapping.targetColumn] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 필수값 검증
|
||||
if (Object.keys(insertData).length === 0) {
|
||||
toast.error("저장할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 중복 체크 (설정된 경우)
|
||||
if (config.duplicateCheck?.enabled) {
|
||||
const isDuplicate = await checkDuplicate(
|
||||
config.targetTable,
|
||||
config.duplicateCheck.columns,
|
||||
insertData
|
||||
);
|
||||
if (isDuplicate) {
|
||||
toast.error(config.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. API 호출
|
||||
try {
|
||||
await tableTypeApi.addTableData(config.targetTable, insertData);
|
||||
|
||||
// 5. 성공 후 동작
|
||||
if (config.afterInsert?.showSuccessMessage) {
|
||||
toast.success(config.afterInsert.successMessage || "저장되었습니다.");
|
||||
}
|
||||
|
||||
if (config.afterInsert?.refreshRightPanel) {
|
||||
// 우측 패널 새로고침 트리거
|
||||
onRefresh?.();
|
||||
}
|
||||
|
||||
if (config.afterInsert?.clearComponents) {
|
||||
// 지정된 컴포넌트 초기화
|
||||
for (const componentId of config.afterInsert.clearComponents) {
|
||||
clearComponentValue(componentId);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
toast.error("저장에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 컴포넌트 간 통신 방안
|
||||
|
||||
### 5.1 문제점
|
||||
- 버튼 컴포넌트에서 같은 화면의 entity 선택박스 값을 가져와야 함
|
||||
- 현재는 각 컴포넌트가 독립적으로 동작
|
||||
|
||||
### 5.2 해결 방안: formData 활용
|
||||
|
||||
현재 `InteractiveScreenViewerDynamic`에서 `formData` 상태로 모든 입력값을 관리하고 있음.
|
||||
|
||||
```typescript
|
||||
// InteractiveScreenViewerDynamic.tsx
|
||||
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// entity 선택박스에서 값 변경 시
|
||||
const handleFormDataChange = (fieldName: string, value: any) => {
|
||||
setLocalFormData(prev => ({ ...prev, [fieldName]: value }));
|
||||
};
|
||||
|
||||
// 버튼 클릭 시 formData에서 값 가져오기
|
||||
const getComponentValue = (componentId: string) => {
|
||||
// componentId로 컴포넌트의 columnName 찾기
|
||||
const component = allComponents.find(c => c.id === componentId);
|
||||
if (component?.columnName) {
|
||||
return formData[component.columnName];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 시나리오
|
||||
|
||||
### 6.1 정상 케이스
|
||||
1. 좌측 테이블에서 공정 "PRC-001" 선택
|
||||
2. 우측 설비 선택박스에서 "MCT-01" 선택
|
||||
3. "설비 추가" 버튼 클릭
|
||||
4. `process_equipment` 테이블에 데이터 저장 확인
|
||||
5. 우측 카드/테이블에 새 항목 표시 확인
|
||||
|
||||
### 6.2 에러 케이스
|
||||
1. 좌측 미선택 상태에서 버튼 클릭 → "좌측에서 항목을 선택해주세요" 메시지
|
||||
2. 설비 미선택 상태에서 버튼 클릭 → "설비를 선택해주세요" 메시지
|
||||
3. 중복 데이터 저장 시도 → "이미 등록된 설비입니다" 메시지
|
||||
|
||||
### 6.3 엣지 케이스
|
||||
1. 동일 설비 연속 추가 시도
|
||||
2. 네트워크 오류 시 재시도
|
||||
3. 권한 없는 사용자의 저장 시도
|
||||
|
||||
---
|
||||
|
||||
## 7. 일정
|
||||
|
||||
| Phase | 작업 | 예상 시간 |
|
||||
|-------|------|----------|
|
||||
| Phase 1 | 타입 정의 및 설정 UI | 1시간 |
|
||||
| Phase 2 | 버튼 액션 핸들러 구현 | 1시간 |
|
||||
| Phase 3 | 테스트 및 검증 | 30분 |
|
||||
| **합계** | | **2시간 30분** |
|
||||
|
||||
---
|
||||
|
||||
## 8. 향후 확장 가능성
|
||||
|
||||
1. **다중 행 추가**: 여러 설비를 한 번에 선택하여 추가
|
||||
2. **수정 모드**: 기존 데이터 수정 기능
|
||||
3. **조건부 저장**: 특정 조건 만족 시에만 저장
|
||||
4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장
|
||||
|
||||
|
||||
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react";
|
||||
import { Link2, Layers, Filter, FormInput, Ban, Tags } from "lucide-react";
|
||||
|
||||
// 탭별 컴포넌트
|
||||
import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
|
||||
|
|
@ -11,6 +11,7 @@ import AutoFillTab from "./tabs/AutoFillTab";
|
|||
import HierarchyTab from "./tabs/HierarchyTab";
|
||||
import ConditionTab from "./tabs/ConditionTab";
|
||||
import MutualExclusionTab from "./tabs/MutualExclusionTab";
|
||||
import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab";
|
||||
|
||||
export default function CascadingManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
|
|
@ -20,7 +21,7 @@ export default function CascadingManagementPage() {
|
|||
// URL 쿼리 파라미터에서 탭 설정
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) {
|
||||
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value"].includes(tab)) {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
|
@ -46,7 +47,7 @@ export default function CascadingManagementPage() {
|
|||
|
||||
{/* 탭 네비게이션 */}
|
||||
<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">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">2단계 연쇄관계</span>
|
||||
|
|
@ -72,6 +73,11 @@ export default function CascadingManagementPage() {
|
|||
<span className="hidden sm:inline">상호 배제</span>
|
||||
<span className="sm:hidden">배제</span>
|
||||
</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>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
|
|
@ -95,6 +101,10 @@ export default function CascadingManagementPage() {
|
|||
<TabsContent value="exclusion">
|
||||
<MutualExclusionTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="category-value">
|
||||
<CategoryValueCascadingTab />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -93,7 +93,7 @@ export default function TableManagementPage() {
|
|||
const [createTableModalOpen, setCreateTableModalOpen] = useState(false);
|
||||
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
|
||||
const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false);
|
||||
|
||||
|
||||
// 테이블 복제 관련 상태
|
||||
const [duplicateModalMode, setDuplicateModalMode] = useState<"create" | "duplicate">("create");
|
||||
const [duplicateSourceTable, setDuplicateSourceTable] = useState<string | null>(null);
|
||||
|
|
@ -109,7 +109,7 @@ export default function TableManagementPage() {
|
|||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [tableToDelete, setTableToDelete] = useState<string>("");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
|
||||
// 선택된 테이블 목록 (체크박스)
|
||||
const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(new Set());
|
||||
|
||||
|
|
@ -459,11 +459,39 @@ export default function TableManagementPage() {
|
|||
if (!selectedTable) return;
|
||||
|
||||
try {
|
||||
// 🎯 Entity 타입인 경우 detailSettings에 엔티티 설정을 JSON으로 포함
|
||||
let finalDetailSettings = column.detailSettings || "";
|
||||
|
||||
if (column.inputType === "entity" && column.referenceTable) {
|
||||
// 기존 detailSettings를 파싱하거나 새로 생성
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
if (typeof column.detailSettings === "string" && column.detailSettings.trim().startsWith("{")) {
|
||||
try {
|
||||
existingSettings = JSON.parse(column.detailSettings);
|
||||
} catch {
|
||||
existingSettings = {};
|
||||
}
|
||||
}
|
||||
|
||||
// 엔티티 설정 추가
|
||||
const entitySettings = {
|
||||
...existingSettings,
|
||||
entityTable: column.referenceTable,
|
||||
entityCodeColumn: column.referenceColumn || "id",
|
||||
entityLabelColumn: column.displayColumn || "name",
|
||||
placeholder: (existingSettings.placeholder as string) || "항목을 선택하세요",
|
||||
searchable: existingSettings.searchable ?? true,
|
||||
};
|
||||
|
||||
finalDetailSettings = JSON.stringify(entitySettings);
|
||||
console.log("🔧 Entity 설정 JSON 생성:", entitySettings);
|
||||
}
|
||||
|
||||
const columnSetting = {
|
||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
||||
inputType: column.inputType || "text",
|
||||
detailSettings: column.detailSettings || "",
|
||||
detailSettings: finalDetailSettings,
|
||||
codeCategory: column.codeCategory || "",
|
||||
codeValue: column.codeValue || "",
|
||||
referenceTable: column.referenceTable || "",
|
||||
|
|
@ -487,7 +515,7 @@ export default function TableManagementPage() {
|
|||
|
||||
if (response.data.success) {
|
||||
console.log("✅ 컬럼 설정 저장 성공");
|
||||
|
||||
|
||||
// 🆕 Category 타입인 경우 컬럼 매핑 처리
|
||||
console.log("🔍 카테고리 조건 체크:", {
|
||||
isCategory: column.inputType === "category",
|
||||
|
|
@ -547,7 +575,7 @@ export default function TableManagementPage() {
|
|||
} else if (successCount > 0 && failCount > 0) {
|
||||
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`);
|
||||
} else if (failCount > 0) {
|
||||
toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`);
|
||||
toast.error("컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.");
|
||||
}
|
||||
} else {
|
||||
toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)");
|
||||
|
|
@ -680,9 +708,7 @@ export default function TableManagementPage() {
|
|||
console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount });
|
||||
|
||||
if (totalSuccessCount > 0) {
|
||||
toast.success(
|
||||
`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`
|
||||
);
|
||||
toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`);
|
||||
} else if (totalFailCount > 0) {
|
||||
toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`);
|
||||
} else {
|
||||
|
|
@ -1000,14 +1026,15 @@ export default function TableManagementPage() {
|
|||
.filter(
|
||||
(table) =>
|
||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
(table.displayName &&
|
||||
table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
)
|
||||
.every((table) => selectedTableIds.has(table.tableName))
|
||||
}
|
||||
onCheckedChange={handleSelectAll}
|
||||
aria-label="전체 선택"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{selectedTableIds.size > 0 && `${selectedTableIds.size}개 선택됨`}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -1047,9 +1074,9 @@ export default function TableManagementPage() {
|
|||
<div
|
||||
key={table.tableName}
|
||||
className={`bg-card rounded-lg p-4 shadow-sm transition-all ${
|
||||
selectedTable === table.tableName
|
||||
? "shadow-md bg-muted/30"
|
||||
: "hover:shadow-lg hover:bg-muted/20"
|
||||
selectedTable === table.tableName
|
||||
? "bg-muted/30 shadow-md"
|
||||
: "hover:bg-muted/20 hover:shadow-lg"
|
||||
}`}
|
||||
style={
|
||||
selectedTable === table.tableName
|
||||
|
|
@ -1068,10 +1095,7 @@ export default function TableManagementPage() {
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex-1 cursor-pointer"
|
||||
onClick={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<div className="flex-1 cursor-pointer" onClick={() => handleTableSelect(table.tableName)}>
|
||||
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
|
||||
|
|
@ -1147,7 +1171,10 @@ export default function TableManagementPage() {
|
|||
) : (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* 컬럼 헤더 (고정) */}
|
||||
<div className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}>
|
||||
<div
|
||||
className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold"
|
||||
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
||||
>
|
||||
<div className="pr-4">컬럼명</div>
|
||||
<div className="px-4">라벨</div>
|
||||
<div className="pr-6">입력 타입</div>
|
||||
|
|
@ -1171,7 +1198,7 @@ export default function TableManagementPage() {
|
|||
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
|
||||
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
||||
>
|
||||
<div className="pr-4 pt-1">
|
||||
<div className="pt-1 pr-4">
|
||||
<div className="font-mono text-sm">{column.columnName}</div>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
|
|
@ -1226,9 +1253,9 @@ export default function TableManagementPage() {
|
|||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
적용할 메뉴 (2레벨)
|
||||
</label>
|
||||
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border p-3">
|
||||
{secondLevelMenus.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
|
||||
</p>
|
||||
) : (
|
||||
|
|
@ -1236,7 +1263,7 @@ export default function TableManagementPage() {
|
|||
// menuObjid를 숫자로 변환하여 비교
|
||||
const menuObjidNum = Number(menu.menuObjid);
|
||||
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
|
||||
|
||||
|
||||
return (
|
||||
<div key={menu.menuObjid} className="flex items-center gap-2">
|
||||
<input
|
||||
|
|
@ -1253,15 +1280,15 @@ export default function TableManagementPage() {
|
|||
prev.map((col) =>
|
||||
col.columnName === column.columnName
|
||||
? { ...col, categoryMenus: newMenus }
|
||||
: col
|
||||
)
|
||||
: col,
|
||||
),
|
||||
);
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
|
||||
className="text-primary focus:ring-ring h-4 w-4 rounded border-gray-300 focus:ring-2"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||
className="text-xs cursor-pointer flex-1"
|
||||
className="flex-1 cursor-pointer text-xs"
|
||||
>
|
||||
{menu.parentMenuName} → {menu.menuName}
|
||||
</label>
|
||||
|
|
@ -1282,9 +1309,7 @@ export default function TableManagementPage() {
|
|||
<>
|
||||
{/* 참조 테이블 */}
|
||||
<div className="w-48">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
참조 테이블
|
||||
</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs">참조 테이블</label>
|
||||
<Select
|
||||
value={column.referenceTable || "none"}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -1296,15 +1321,10 @@ export default function TableManagementPage() {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceTableOptions.map((option, index) => (
|
||||
<SelectItem
|
||||
key={`entity-${option.value}-${index}`}
|
||||
value={option.value}
|
||||
>
|
||||
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{option.value}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{option.value}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -1315,9 +1335,7 @@ export default function TableManagementPage() {
|
|||
{/* 조인 컬럼 */}
|
||||
{column.referenceTable && column.referenceTable !== "none" && (
|
||||
<div className="w-48">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
조인 컬럼
|
||||
</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs">조인 컬럼</label>
|
||||
<Select
|
||||
value={column.referenceColumn || "none"}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -1361,9 +1379,7 @@ export default function TableManagementPage() {
|
|||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" && (
|
||||
<div className="w-48">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
표시 컬럼
|
||||
</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs">표시 컬럼</label>
|
||||
<Select
|
||||
value={column.displayColumn || "none"}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -1408,7 +1424,7 @@ export default function TableManagementPage() {
|
|||
column.referenceColumn !== "none" &&
|
||||
column.displayColumn &&
|
||||
column.displayColumn !== "none" && (
|
||||
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs w-48">
|
||||
<div className="bg-primary/10 text-primary flex w-48 items-center gap-1 rounded px-2 py-1 text-xs">
|
||||
<span>✓</span>
|
||||
<span className="truncate">설정 완료</span>
|
||||
</div>
|
||||
|
|
@ -1460,9 +1476,10 @@ export default function TableManagementPage() {
|
|||
setDuplicateSourceTable(null);
|
||||
}}
|
||||
onSuccess={async (result) => {
|
||||
const message = duplicateModalMode === "duplicate"
|
||||
? "테이블이 성공적으로 복제되었습니다!"
|
||||
: "테이블이 성공적으로 생성되었습니다!";
|
||||
const message =
|
||||
duplicateModalMode === "duplicate"
|
||||
? "테이블이 성공적으로 복제되었습니다!"
|
||||
: "테이블이 성공적으로 생성되었습니다!";
|
||||
toast.success(message);
|
||||
// 테이블 목록 새로고침
|
||||
await loadTables();
|
||||
|
|
@ -1516,13 +1533,10 @@ export default function TableManagementPage() {
|
|||
{selectedTableIds.size > 0 ? (
|
||||
<>
|
||||
선택된 <strong>{selectedTableIds.size}개</strong>의 테이블을 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
정말로 테이블을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</>
|
||||
<>정말로 테이블을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
@ -1600,4 +1614,3 @@ export default function TableManagementPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,9 +18,11 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
|
|||
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
||||
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
|
||||
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
|
||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
|
||||
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신
|
||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 테이블 옵션
|
||||
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 높이 관리
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
|
||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
|
||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
|
||||
|
||||
function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -306,11 +308,9 @@ function ScreenViewPage() {
|
|||
|
||||
return (
|
||||
<ScreenPreviewProvider isPreviewMode={false}>
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="bg-background h-full w-full overflow-auto p-3"
|
||||
>
|
||||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
|
||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||
{!layoutReady && (
|
||||
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
||||
|
|
@ -358,7 +358,6 @@ function ScreenViewPage() {
|
|||
return isButton;
|
||||
});
|
||||
|
||||
|
||||
topLevelComponents.forEach((component) => {
|
||||
const isButton =
|
||||
(component.type === "component" &&
|
||||
|
|
@ -789,7 +788,8 @@ function ScreenViewPage() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
</TableOptionsProvider>
|
||||
</ActiveTabProvider>
|
||||
</ScreenPreviewProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -799,7 +799,9 @@ function ScreenViewPageWrapper() {
|
|||
return (
|
||||
<TableSearchWidgetHeightProvider>
|
||||
<ScreenContextProvider>
|
||||
<ScreenViewPage />
|
||||
<SplitPanelProvider>
|
||||
<ScreenViewPage />
|
||||
</SplitPanelProvider>
|
||||
</ScreenContextProvider>
|
||||
</TableSearchWidgetHeightProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* ===== 서명용 손글씨 폰트 ===== */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Allura&family=Dancing+Script:wght@700&family=Great+Vibes&family=Pacifico&family=Satisfy&family=Caveat:wght@700&family=Permanent+Marker&family=Shadows+Into+Light&family=Kalam:wght@700&family=Patrick+Hand&family=Indie+Flower&family=Amatic+SC:wght@700&family=Covered+By+Your+Grace&family=Nanum+Brush+Script&family=Nanum+Pen+Script&family=Gaegu:wght@700&family=Gugi&family=Single+Day&family=Stylish&family=Sunflower:wght@700&family=Dokdo&display=swap");
|
||||
/* ===== 서명용 손글씨 폰트 (완전한 한글 지원 폰트) ===== */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Allura&family=Dancing+Script:wght@700&family=Great+Vibes&family=Pacifico&family=Satisfy&family=Caveat:wght@700&family=Permanent+Marker&family=Shadows+Into+Light&family=Kalam:wght@700&family=Patrick+Hand&family=Indie+Flower&family=Amatic+SC:wght@700&family=Covered+By+Your+Grace&family=Nanum+Brush+Script&family=Nanum+Pen+Script&family=Gaegu:wght@700&family=Hi+Melody&family=Gamja+Flower&family=Poor+Story&family=Do+Hyeon&family=Jua&display=swap");
|
||||
|
||||
/* ===== Tailwind CSS & Animations ===== */
|
||||
@import "tailwindcss";
|
||||
|
|
|
|||
|
|
@ -56,6 +56,13 @@ export function MenuCopyDialog({
|
|||
const [removeText, setRemoveText] = 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(() => {
|
||||
if (open) {
|
||||
|
|
@ -66,6 +73,11 @@ export function MenuCopyDialog({
|
|||
setUseBulkRename(false);
|
||||
setRemoveText("");
|
||||
setAddPrefix("");
|
||||
setCopyCodeCategory(false);
|
||||
setCopyNumberingRules(false);
|
||||
setCopyCategoryMapping(false);
|
||||
setCopyTableTypeColumns(false);
|
||||
setCopyCascadingRelation(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
|
|
@ -112,10 +124,20 @@ export function MenuCopyDialog({
|
|||
}
|
||||
: undefined;
|
||||
|
||||
// 추가 복사 옵션
|
||||
const additionalCopyOptions = {
|
||||
copyCodeCategory,
|
||||
copyNumberingRules,
|
||||
copyCategoryMapping,
|
||||
copyTableTypeColumns,
|
||||
copyCascadingRelation,
|
||||
};
|
||||
|
||||
const response = await menuApi.copyMenu(
|
||||
menuObjid,
|
||||
targetCompanyCode,
|
||||
screenNameConfig
|
||||
screenNameConfig,
|
||||
additionalCopyOptions
|
||||
);
|
||||
|
||||
if (response.success && response.data) {
|
||||
|
|
@ -264,19 +286,96 @@ export function MenuCopyDialog({
|
|||
</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 && (
|
||||
<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">
|
||||
<li>메뉴 구조 (하위 메뉴 포함)</li>
|
||||
<li>화면 + 레이아웃 (모달, 조건부 컨테이너)</li>
|
||||
<li>플로우 제어 (스텝, 연결)</li>
|
||||
<li>코드 카테고리 + 코드</li>
|
||||
<li>카테고리 설정 + 채번 규칙</li>
|
||||
</ul>
|
||||
<p className="mt-2 text-warning">
|
||||
⚠️ 실제 데이터는 복사되지 않습니다.
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
* 코드, 채번규칙, 카테고리는 위 옵션 선택 시 복사됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -294,10 +393,46 @@ export function MenuCopyDialog({
|
|||
<span className="text-muted-foreground">화면:</span>{" "}
|
||||
<span className="font-medium">{result.copiedScreens}개</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">플로우:</span>{" "}
|
||||
<span className="font-medium">{result.copiedFlows}개</span>
|
||||
</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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -916,7 +916,7 @@ export function CanvasElement({
|
|||
) : element.type === "widget" && element.subtype === "weather" ? (
|
||||
// 날씨 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<WeatherWidget city="서울" refreshInterval={600000} />
|
||||
<WeatherWidget element={element} city="서울" refreshInterval={600000} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "exchange" ? (
|
||||
// 환율 위젯 렌더링
|
||||
|
|
|
|||
|
|
@ -390,9 +390,11 @@ export interface RowDetailPopupConfig {
|
|||
// 추가 데이터 조회 설정
|
||||
additionalQuery?: {
|
||||
enabled: boolean;
|
||||
queryMode?: "table" | "custom"; // 조회 모드: table(테이블 조회), custom(커스텀 쿼리)
|
||||
tableName: string; // 조회할 테이블명 (예: vehicles)
|
||||
matchColumn: string; // 매칭할 컬럼 (예: id)
|
||||
sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일)
|
||||
customQuery?: string; // 커스텀 쿼리 ({id}, {vehicle_number} 등 파라미터 사용)
|
||||
// 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시)
|
||||
displayColumns?: DisplayColumnConfig[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
|||
checked={popupConfig.additionalQuery?.enabled || false}
|
||||
onCheckedChange={(enabled) =>
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" },
|
||||
additionalQuery: { ...popupConfig.additionalQuery, enabled, queryMode: "table", tableName: "", matchColumn: "" },
|
||||
})
|
||||
}
|
||||
aria-label="추가 데이터 조회 활성화"
|
||||
|
|
@ -167,116 +167,230 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
|||
|
||||
{popupConfig.additionalQuery?.enabled && (
|
||||
<div className="space-y-2">
|
||||
{/* 조회 모드 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">테이블명</Label>
|
||||
<Input
|
||||
value={popupConfig.additionalQuery?.tableName || ""}
|
||||
onChange={(e) =>
|
||||
<Label className="text-xs">조회 모드</Label>
|
||||
<Select
|
||||
value={popupConfig.additionalQuery?.queryMode || "table"}
|
||||
onValueChange={(value: "table" | "custom") =>
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, queryMode: value },
|
||||
})
|
||||
}
|
||||
placeholder="vehicles"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">매칭 컬럼 (조회 테이블)</Label>
|
||||
<Input
|
||||
value={popupConfig.additionalQuery?.matchColumn || ""}
|
||||
onChange={(e) =>
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="id"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">소스 컬럼 (클릭한 행)</Label>
|
||||
<Input
|
||||
value={popupConfig.additionalQuery?.sourceColumn || ""}
|
||||
onChange={(e) =>
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="비워두면 매칭 컬럼과 동일"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="table">테이블 조회</SelectItem>
|
||||
<SelectItem value="custom">커스텀 쿼리</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */}
|
||||
{/* 테이블 조회 모드 */}
|
||||
{(popupConfig.additionalQuery?.queryMode || "table") === "table" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">테이블명</Label>
|
||||
<Input
|
||||
value={popupConfig.additionalQuery?.tableName || ""}
|
||||
onChange={(e) =>
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="vehicles"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">매칭 컬럼 (조회 테이블)</Label>
|
||||
<Input
|
||||
value={popupConfig.additionalQuery?.matchColumn || ""}
|
||||
onChange={(e) =>
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="id"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">소스 컬럼 (클릭한 행)</Label>
|
||||
<Input
|
||||
value={popupConfig.additionalQuery?.sourceColumn || ""}
|
||||
onChange={(e) =>
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="비워두면 매칭 컬럼과 동일"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 커스텀 쿼리 모드 */}
|
||||
{popupConfig.additionalQuery?.queryMode === "custom" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">소스 컬럼 (클릭한 행)</Label>
|
||||
<Input
|
||||
value={popupConfig.additionalQuery?.sourceColumn || ""}
|
||||
onChange={(e) =>
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="id"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">쿼리에서 사용할 파라미터 컬럼</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">커스텀 쿼리</Label>
|
||||
<textarea
|
||||
value={popupConfig.additionalQuery?.customQuery || ""}
|
||||
onChange={(e) =>
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, customQuery: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder={`SELECT
|
||||
v.vehicle_number AS "차량번호",
|
||||
ROUND(SUM(ts.loaded_distance_km)::NUMERIC, 2) AS "운행거리"
|
||||
FROM vehicles v
|
||||
LEFT JOIN transport_statistics ts ON v.id = ts.vehicle_id
|
||||
WHERE v.id = {id}
|
||||
GROUP BY v.id;`}
|
||||
className="mt-1 h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{"{id}"}, {"{vehicle_number}"} 등 클릭한 행의 컬럼값을 파라미터로 사용
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 표시할 컬럼 선택 - 테이블 모드와 커스텀 쿼리 모드 분기 */}
|
||||
<div>
|
||||
<Label className="text-xs">표시할 컬럼 선택</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
|
||||
<span className="truncate">
|
||||
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
|
||||
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
|
||||
: "전체 표시 (클릭하여 선택)"}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-2" align="start">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium">컬럼 선택</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() =>
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
|
||||
})
|
||||
}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{/* 쿼리 결과 컬럼 목록 */}
|
||||
{queryResult?.columns.map((col) => {
|
||||
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
|
||||
const existingConfig = currentColumns.find((c) =>
|
||||
typeof c === 'object' ? c.column === col : c === col
|
||||
);
|
||||
const isSelected = !!existingConfig;
|
||||
return (
|
||||
<div
|
||||
key={col}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
|
||||
onClick={() => {
|
||||
const newColumns = isSelected
|
||||
? currentColumns.filter((c) =>
|
||||
typeof c === 'object' ? c.column !== col : c !== col
|
||||
)
|
||||
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
|
||||
|
||||
{/* 테이블 모드: 기존 쿼리 결과에서 선택 */}
|
||||
{popupConfig.additionalQuery?.queryMode !== "custom" && (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
|
||||
<span className="truncate">
|
||||
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
|
||||
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
|
||||
: "전체 표시 (클릭하여 선택)"}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-2" align="start">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium">컬럼 선택</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() =>
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||
});
|
||||
}}
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Checkbox checked={isSelected} className="h-3 w-3" />
|
||||
<span className="text-xs">{col}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!queryResult?.columns || queryResult.columns.length === 0) && (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||
쿼리를 먼저 실행해주세요
|
||||
</p>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{/* 쿼리 결과 컬럼 목록 */}
|
||||
{queryResult?.columns.map((col) => {
|
||||
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
|
||||
const existingConfig = currentColumns.find((c) =>
|
||||
typeof c === 'object' ? c.column === col : c === col
|
||||
);
|
||||
const isSelected = !!existingConfig;
|
||||
return (
|
||||
<div
|
||||
key={col}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
|
||||
onClick={() => {
|
||||
const newColumns = isSelected
|
||||
? currentColumns.filter((c) =>
|
||||
typeof c === 'object' ? c.column !== col : c !== col
|
||||
)
|
||||
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={isSelected} className="h-3 w-3" />
|
||||
<span className="text-xs">{col}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!queryResult?.columns || queryResult.columns.length === 0) && (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||
쿼리를 먼저 실행해주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground mt-1 text-xs">비워두면 모든 컬럼이 표시됩니다</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 커스텀 쿼리 모드: 직접 입력 방식 */}
|
||||
{popupConfig.additionalQuery?.queryMode === "custom" && (
|
||||
<>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
커스텀 쿼리의 결과 컬럼이 자동으로 표시됩니다.
|
||||
쿼리에서 AS "라벨명" 형태로 alias를 지정하면 해당 라벨로 표시됩니다.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() => {
|
||||
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || []), { column: "", label: "" }];
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
컬럼 추가 (선택사항)
|
||||
</Button>
|
||||
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() =>
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
|
||||
})
|
||||
}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground mt-1 text-xs">비워두면 모든 컬럼이 표시됩니다</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 선택된 컬럼 라벨 편집 */}
|
||||
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
||||
{/* 선택된 컬럼 라벨 편집 (테이블 모드) */}
|
||||
{popupConfig.additionalQuery?.queryMode !== "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label className="text-xs">컬럼 라벨 설정</Label>
|
||||
<div className="space-y-1.5">
|
||||
|
|
@ -321,6 +435,63 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 커스텀 쿼리 모드: 직접 입력 컬럼 편집 */}
|
||||
{popupConfig.additionalQuery?.queryMode === "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label className="text-xs">표시할 컬럼 직접 입력</Label>
|
||||
<p className="text-muted-foreground text-xs">커스텀 쿼리 결과의 컬럼명을 직접 입력하세요</p>
|
||||
<div className="space-y-1.5">
|
||||
{popupConfig.additionalQuery?.displayColumns?.map((colConfig, index) => {
|
||||
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={column}
|
||||
onChange={(e) => {
|
||||
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
|
||||
newColumns[index] = { column: e.target.value, label: label || e.target.value };
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||
});
|
||||
}}
|
||||
placeholder="컬럼명 (쿼리 결과)"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => {
|
||||
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
|
||||
newColumns[index] = { column, label: e.target.value };
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||
});
|
||||
}}
|
||||
placeholder="표시 라벨"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => {
|
||||
const newColumns = (popupConfig.additionalQuery?.displayColumns || []).filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
updatePopupConfig({
|
||||
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -64,22 +64,35 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
|
||||
// 추가 데이터 조회 설정이 있으면 실행
|
||||
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
||||
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
|
||||
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
||||
const matchValue = row[sourceColumn];
|
||||
|
||||
if (matchValue !== undefined && matchValue !== null) {
|
||||
if (additionalQuery?.enabled) {
|
||||
const queryMode = additionalQuery.queryMode || "table";
|
||||
|
||||
// 커스텀 쿼리 모드
|
||||
if (queryMode === "custom" && additionalQuery.customQuery) {
|
||||
setDetailPopupLoading(true);
|
||||
try {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM ${additionalQuery.tableName}
|
||||
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
||||
LIMIT 1;
|
||||
`;
|
||||
// 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
|
||||
let query = additionalQuery.customQuery;
|
||||
// console.log("🔍 [ListWidget] 커스텀 쿼리 파라미터 치환 시작");
|
||||
// console.log("🔍 [ListWidget] 클릭한 행 데이터:", row);
|
||||
// console.log("🔍 [ListWidget] 행 컬럼 목록:", Object.keys(row));
|
||||
|
||||
Object.keys(row).forEach((key) => {
|
||||
const value = row[key];
|
||||
const placeholder = new RegExp(`\\{${key}\\}`, "g");
|
||||
// SQL 인젝션 방지를 위해 값 이스케이프
|
||||
const safeValue = typeof value === "string"
|
||||
? value.replace(/'/g, "''")
|
||||
: value;
|
||||
query = query.replace(placeholder, String(safeValue ?? ""));
|
||||
// console.log(`🔍 [ListWidget] 치환: {${key}} → ${safeValue}`);
|
||||
});
|
||||
|
||||
// console.log("🔍 [ListWidget] 최종 쿼리:", query);
|
||||
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(query);
|
||||
// console.log("🔍 [ListWidget] 쿼리 결과:", result);
|
||||
|
||||
if (result.success && result.rows.length > 0) {
|
||||
setAdditionalDetailData(result.rows[0]);
|
||||
|
|
@ -87,12 +100,43 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
setAdditionalDetailData({});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("추가 데이터 로드 실패:", error);
|
||||
console.error("커스텀 쿼리 실행 실패:", error);
|
||||
setAdditionalDetailData({});
|
||||
} finally {
|
||||
setDetailPopupLoading(false);
|
||||
}
|
||||
}
|
||||
// 테이블 조회 모드
|
||||
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
|
||||
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
||||
const matchValue = row[sourceColumn];
|
||||
|
||||
if (matchValue !== undefined && matchValue !== null) {
|
||||
setDetailPopupLoading(true);
|
||||
try {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM ${additionalQuery.tableName}
|
||||
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
||||
LIMIT 1;
|
||||
`;
|
||||
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(query);
|
||||
|
||||
if (result.success && result.rows.length > 0) {
|
||||
setAdditionalDetailData(result.rows[0]);
|
||||
} else {
|
||||
setAdditionalDetailData({});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("추가 데이터 로드 실패:", error);
|
||||
setAdditionalDetailData({});
|
||||
} finally {
|
||||
setDetailPopupLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[config.rowDetailPopup],
|
||||
|
|
@ -104,9 +148,19 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
|
||||
switch (format) {
|
||||
case "date":
|
||||
return new Date(value).toLocaleDateString("ko-KR");
|
||||
try {
|
||||
const dateVal = new Date(value);
|
||||
return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" });
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
case "datetime":
|
||||
return new Date(value).toLocaleString("ko-KR");
|
||||
try {
|
||||
const dateVal = new Date(value);
|
||||
return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
case "number":
|
||||
return Number(value).toLocaleString("ko-KR");
|
||||
case "currency":
|
||||
|
|
@ -190,22 +244,34 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
||||
const groups: FieldGroup[] = [];
|
||||
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
||||
const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
|
||||
|
||||
// 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
|
||||
// row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
|
||||
const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
|
||||
? { ...row, ...additional } // additional이 row를 덮어씀
|
||||
: row;
|
||||
|
||||
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
||||
let basicFields: { column: string; label: string }[] = [];
|
||||
|
||||
if (displayColumns && displayColumns.length > 0) {
|
||||
// DisplayColumnConfig 형식 지원
|
||||
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
|
||||
basicFields = displayColumns
|
||||
.map((colConfig) => {
|
||||
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
||||
return { column, label };
|
||||
})
|
||||
.filter((item) => item.column in row);
|
||||
.filter((item) => item.column in mergedData);
|
||||
} else {
|
||||
// 전체 컬럼
|
||||
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
|
||||
// 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
|
||||
if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
|
||||
basicFields = Object.keys(additional).map((key) => ({ column: key, label: key }));
|
||||
} else {
|
||||
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
|
||||
}
|
||||
}
|
||||
|
||||
groups.push({
|
||||
|
|
@ -220,8 +286,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
})),
|
||||
});
|
||||
|
||||
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
|
||||
if (additional && Object.keys(additional).length > 0) {
|
||||
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
|
||||
if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
|
||||
// 운행 정보
|
||||
if (additional.last_trip_start || additional.last_trip_end) {
|
||||
groups.push({
|
||||
|
|
|
|||
|
|
@ -2,11 +2,24 @@
|
|||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
Grid3x3,
|
||||
Move,
|
||||
Box,
|
||||
Package,
|
||||
Truck,
|
||||
Check,
|
||||
ParkingCircle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
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 { useToast } from "@/hooks/use-toast";
|
||||
import type { PlacedObject, ToolType, Warehouse, Area, Location, ObjectType } from "@/types/digitalTwin";
|
||||
|
|
@ -78,7 +91,7 @@ const DebouncedInput = ({
|
|||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsEditing(false);
|
||||
if (onCommit && debounce === 0) {
|
||||
// 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
|
||||
// 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
|
||||
// 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨)
|
||||
onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
|
||||
}
|
||||
|
|
@ -545,150 +558,170 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
|
||||
// 레이아웃 데이터 로드
|
||||
const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadLayout = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getLayoutById(layoutId);
|
||||
// 레이아웃 로드 함수
|
||||
const loadLayout = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getLayoutById(layoutId);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const { layout, objects } = response.data;
|
||||
setLayoutData({ layout, objects }); // 레이아웃 데이터 저장
|
||||
if (response.success && response.data) {
|
||||
const { layout, objects } = response.data;
|
||||
setLayoutData({ layout, objects }); // 레이아웃 데이터 저장
|
||||
|
||||
// 외부 DB 연결 ID 복원
|
||||
if (layout.external_db_connection_id) {
|
||||
setSelectedDbConnection(layout.external_db_connection_id);
|
||||
}
|
||||
|
||||
// 계층 구조 설정 로드
|
||||
if (layout.hierarchy_config) {
|
||||
try {
|
||||
// hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용
|
||||
const config =
|
||||
typeof layout.hierarchy_config === "string"
|
||||
? JSON.parse(layout.hierarchy_config)
|
||||
: layout.hierarchy_config;
|
||||
setHierarchyConfig(config);
|
||||
|
||||
// 선택된 테이블 정보도 복원
|
||||
const newSelectedTables: any = {
|
||||
warehouse: config.warehouse?.tableName || "",
|
||||
area: "",
|
||||
location: "",
|
||||
material: "",
|
||||
};
|
||||
|
||||
if (config.levels && config.levels.length > 0) {
|
||||
// 레벨 1 = Area
|
||||
if (config.levels[0]?.tableName) {
|
||||
newSelectedTables.area = config.levels[0].tableName;
|
||||
}
|
||||
// 레벨 2 = Location
|
||||
if (config.levels[1]?.tableName) {
|
||||
newSelectedTables.location = config.levels[1].tableName;
|
||||
}
|
||||
}
|
||||
|
||||
// 자재 테이블 정보
|
||||
if (config.material?.tableName) {
|
||||
newSelectedTables.material = config.material.tableName;
|
||||
}
|
||||
|
||||
setSelectedTables(newSelectedTables);
|
||||
} catch (e) {
|
||||
console.error("계층 구조 설정 파싱 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 객체 데이터 변환 (DB -> PlacedObject)
|
||||
const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({
|
||||
id: obj.id,
|
||||
type: obj.object_type,
|
||||
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: 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 || 1,
|
||||
parentKey: obj.parent_key,
|
||||
externalKey: obj.external_key,
|
||||
}));
|
||||
|
||||
setPlacedObjects(loadedObjects);
|
||||
|
||||
// 다음 임시 ID 설정 (기존 ID 중 최소값 - 1)
|
||||
const minId = Math.min(...loadedObjects.map((o) => o.id), 0);
|
||||
setNextObjectId(minId - 1);
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
toast({
|
||||
title: "레이아웃 불러오기 완료",
|
||||
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
|
||||
});
|
||||
|
||||
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
|
||||
const dbConnectionId = layout.external_db_connection_id;
|
||||
const hierarchyConfigParsed =
|
||||
typeof layout.hierarchy_config === "string"
|
||||
? JSON.parse(layout.hierarchy_config)
|
||||
: layout.hierarchy_config;
|
||||
const materialTableName = hierarchyConfigParsed?.material?.tableName;
|
||||
|
||||
const locationObjects = loadedObjects.filter(
|
||||
(obj) =>
|
||||
(obj.type === "location-bed" ||
|
||||
obj.type === "location-stp" ||
|
||||
obj.type === "location-temp" ||
|
||||
obj.type === "location-dest") &&
|
||||
obj.locaKey,
|
||||
);
|
||||
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
|
||||
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
|
||||
setTimeout(() => {
|
||||
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.error || "레이아웃 조회 실패");
|
||||
// 외부 DB 연결 ID 복원
|
||||
if (layout.external_db_connection_id) {
|
||||
setSelectedDbConnection(layout.external_db_connection_id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("레이아웃 로드 실패:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "오류",
|
||||
description: errorMessage,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 계층 구조 설정 로드
|
||||
if (layout.hierarchy_config) {
|
||||
try {
|
||||
// hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용
|
||||
const config =
|
||||
typeof layout.hierarchy_config === "string"
|
||||
? JSON.parse(layout.hierarchy_config)
|
||||
: layout.hierarchy_config;
|
||||
setHierarchyConfig(config);
|
||||
|
||||
// 선택된 테이블 정보도 복원
|
||||
const newSelectedTables: any = {
|
||||
warehouse: config.warehouse?.tableName || "",
|
||||
area: "",
|
||||
location: "",
|
||||
material: "",
|
||||
};
|
||||
|
||||
if (config.levels && config.levels.length > 0) {
|
||||
// 레벨 1 = Area
|
||||
if (config.levels[0]?.tableName) {
|
||||
newSelectedTables.area = config.levels[0].tableName;
|
||||
}
|
||||
// 레벨 2 = Location
|
||||
if (config.levels[1]?.tableName) {
|
||||
newSelectedTables.location = config.levels[1].tableName;
|
||||
}
|
||||
}
|
||||
|
||||
// 자재 테이블 정보
|
||||
if (config.material?.tableName) {
|
||||
newSelectedTables.material = config.material.tableName;
|
||||
}
|
||||
|
||||
setSelectedTables(newSelectedTables);
|
||||
} catch (e) {
|
||||
console.error("계층 구조 설정 파싱 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 객체 데이터 변환 (DB -> PlacedObject)
|
||||
const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({
|
||||
id: obj.id,
|
||||
type: obj.object_type,
|
||||
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: 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 || 1,
|
||||
parentKey: obj.parent_key,
|
||||
externalKey: obj.external_key,
|
||||
}));
|
||||
|
||||
setPlacedObjects(loadedObjects);
|
||||
|
||||
// 다음 임시 ID 설정 (기존 ID 중 최소값 - 1)
|
||||
const minId = Math.min(...loadedObjects.map((o) => o.id), 0);
|
||||
setNextObjectId(minId - 1);
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
toast({
|
||||
title: "레이아웃 불러오기 완료",
|
||||
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
|
||||
});
|
||||
|
||||
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
|
||||
const dbConnectionId = layout.external_db_connection_id;
|
||||
const hierarchyConfigParsed =
|
||||
typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config;
|
||||
const materialTableName = hierarchyConfigParsed?.material?.tableName;
|
||||
|
||||
const locationObjects = loadedObjects.filter(
|
||||
(obj) =>
|
||||
(obj.type === "location-bed" ||
|
||||
obj.type === "location-stp" ||
|
||||
obj.type === "location-temp" ||
|
||||
obj.type === "location-dest") &&
|
||||
obj.locaKey,
|
||||
);
|
||||
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
|
||||
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
|
||||
setTimeout(() => {
|
||||
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.error || "레이아웃 조회 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("레이아웃 로드 실패:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "오류",
|
||||
description: errorMessage,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 위젯 새로고침 핸들러
|
||||
const handleRefresh = async () => {
|
||||
if (hasUnsavedChanges) {
|
||||
const confirmed = window.confirm(
|
||||
"저장되지 않은 변경사항이 있습니다. 새로고침하면 변경사항이 사라집니다. 계속하시겠습니까?",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
setIsRefreshing(true);
|
||||
setSelectedObject(null);
|
||||
setMaterials([]);
|
||||
await loadLayout();
|
||||
setIsRefreshing(false);
|
||||
toast({
|
||||
title: "새로고침 완료",
|
||||
description: "데이터가 갱신되었습니다.",
|
||||
});
|
||||
};
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
loadLayout();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layoutId]); // toast 제거
|
||||
}, [layoutId]);
|
||||
|
||||
// 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시)
|
||||
useEffect(() => {
|
||||
|
|
@ -1052,7 +1085,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
};
|
||||
|
||||
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
|
||||
const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => {
|
||||
const loadMaterialCountsForLocations = async (
|
||||
locaKeys: string[],
|
||||
dbConnectionId?: number,
|
||||
materialTableName?: string,
|
||||
) => {
|
||||
const connectionId = dbConnectionId || selectedDbConnection;
|
||||
const tableName = materialTableName || selectedTables.material;
|
||||
if (!connectionId || locaKeys.length === 0) return;
|
||||
|
|
@ -1060,7 +1097,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
try {
|
||||
const response = await getMaterialCounts(connectionId, tableName, locaKeys);
|
||||
console.log("📊 자재 개수 API 응답:", response);
|
||||
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
|
||||
setPlacedObjects((prev) =>
|
||||
|
|
@ -1073,10 +1110,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
}
|
||||
// 백엔드 응답 필드명: location_key, count (대소문자 모두 체크)
|
||||
const materialCount = response.data?.find(
|
||||
(mc: any) =>
|
||||
mc.LOCAKEY === obj.locaKey ||
|
||||
mc.location_key === obj.locaKey ||
|
||||
mc.locakey === obj.locaKey
|
||||
(mc: any) => mc.LOCAKEY === obj.locaKey || mc.location_key === obj.locaKey || mc.locakey === obj.locaKey,
|
||||
);
|
||||
if (materialCount) {
|
||||
// count 또는 material_count 필드 사용
|
||||
|
|
@ -1527,6 +1561,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
|
||||
<div className="flex items-center gap-2">
|
||||
{hasUnsavedChanges && <span className="text-warning text-sm font-medium">미저장 변경사항 있음</span>}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing || isLoading}
|
||||
title="새로고침"
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
{isRefreshing ? "갱신 중..." : "새로고침"}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
|
|
@ -1620,27 +1664,20 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={selectedTemplateId}
|
||||
onValueChange={(val) => setSelectedTemplateId(val)}
|
||||
>
|
||||
<Select value={selectedTemplateId} onValueChange={(val) => setSelectedTemplateId(val)}>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||
<SelectValue placeholder={loadingTemplates ? "로딩 중..." : "템플릿 선택..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mappingTemplates.length === 0 ? (
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs">
|
||||
사용 가능한 템플릿이 없습니다
|
||||
</div>
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs">사용 가능한 템플릿이 없습니다</div>
|
||||
) : (
|
||||
mappingTemplates.map((tpl) => (
|
||||
<SelectItem key={tpl.id} value={tpl.id} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{tpl.name}</span>
|
||||
{tpl.description && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{tpl.description}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">{tpl.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -1704,17 +1741,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
}}
|
||||
onLoadColumns={async (tableName: string) => {
|
||||
try {
|
||||
const response = await ExternalDbConnectionAPI.getTableColumns(
|
||||
selectedDbConnection,
|
||||
tableName,
|
||||
);
|
||||
const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName);
|
||||
if (response.success && response.data) {
|
||||
// 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그)
|
||||
return response.data.map((col: any) => ({
|
||||
column_name:
|
||||
typeof col === "string"
|
||||
? col
|
||||
: col.column_name || col.COLUMN_NAME || String(col),
|
||||
column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col),
|
||||
data_type: col.data_type || col.DATA_TYPE,
|
||||
description: col.description || col.COLUMN_COMMENT || undefined,
|
||||
is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY,
|
||||
|
|
@ -2111,45 +2142,39 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
자재가 없습니다
|
||||
</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) => {
|
||||
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
|
||||
const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY";
|
||||
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
|
||||
|
||||
const layerValue = material[layerColumn] || index + 1;
|
||||
const keyValue = material[keyColumn] || `자재 ${index + 1}`;
|
||||
const layerNumber = material[layerColumn] || index + 1;
|
||||
|
||||
return (
|
||||
<AccordionItem key={`${keyValue}-${index}`} value={`item-${index}`} className="border-b">
|
||||
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
||||
<div className="flex w-full items-center justify-between pr-2">
|
||||
<span className="text-sm font-medium">층 {layerValue}</span>
|
||||
<span className="text-muted-foreground max-w-[150px] truncate text-xs">{keyValue}</span>
|
||||
</div>
|
||||
</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>
|
||||
<TableRow key={material[keyColumn] || `material-${index}`}>
|
||||
<TableCell className="whitespace-nowrap px-3 py-3 text-sm font-medium">{layerNumber}단</TableCell>
|
||||
{displayColumns.map((col) => (
|
||||
<TableCell key={col.column} className="px-3 py-3 text-sm">
|
||||
{material[col.column] || "-"}
|
||||
</TableCell>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : selectedObject ? (
|
||||
|
|
@ -2354,10 +2379,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveTemplate}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
<Button onClick={handleSaveTemplate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -29,7 +29,6 @@ import {
|
|||
Plus,
|
||||
Minus,
|
||||
ArrowRight,
|
||||
Save,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
||||
|
|
@ -52,12 +51,6 @@ interface ColumnMapping {
|
|||
systemColumn: string | null;
|
||||
}
|
||||
|
||||
interface UploadConfig {
|
||||
name: string;
|
||||
type: string;
|
||||
mappings: ColumnMapping[];
|
||||
}
|
||||
|
||||
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -88,8 +81,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
const [excelColumns, setExcelColumns] = useState<string[]>([]);
|
||||
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
|
||||
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
|
||||
const [configName, setConfigName] = useState<string>("");
|
||||
const [configType, setConfigType] = useState<string>("");
|
||||
|
||||
// 4단계: 확인
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
|
@ -114,7 +105,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
|
||||
const data = await importFromExcel(selectedFile, sheets[0]);
|
||||
setAllData(data);
|
||||
setDisplayData(data.slice(0, 10));
|
||||
setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
|
||||
|
||||
if (data.length > 0) {
|
||||
const columns = Object.keys(data[0]);
|
||||
|
|
@ -139,7 +130,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
try {
|
||||
const data = await importFromExcel(file, sheetName);
|
||||
setAllData(data);
|
||||
setDisplayData(data.slice(0, 10));
|
||||
setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
|
||||
|
||||
if (data.length > 0) {
|
||||
const columns = Object.keys(data[0]);
|
||||
|
|
@ -236,13 +227,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 자동 매핑
|
||||
// 자동 매핑 - 컬럼명과 라벨 모두 비교
|
||||
const handleAutoMapping = () => {
|
||||
const newMappings = excelColumns.map((excelCol) => {
|
||||
const matchedSystemCol = systemColumns.find(
|
||||
(sysCol) => sysCol.name.toLowerCase() === excelCol.toLowerCase()
|
||||
const normalizedExcelCol = excelCol.toLowerCase().trim();
|
||||
|
||||
// 1. 먼저 라벨로 매칭 시도
|
||||
let matchedSystemCol = systemColumns.find(
|
||||
(sysCol) => sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
|
||||
);
|
||||
|
||||
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
|
||||
if (!matchedSystemCol) {
|
||||
matchedSystemCol = systemColumns.find(
|
||||
(sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
excelColumn: excelCol,
|
||||
systemColumn: matchedSystemCol ? matchedSystemCol.name : null,
|
||||
|
|
@ -265,28 +266,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// 설정 저장
|
||||
const handleSaveConfig = () => {
|
||||
if (!configName.trim()) {
|
||||
toast.error("거래처명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const config: UploadConfig = {
|
||||
name: configName,
|
||||
type: configType,
|
||||
mappings: columnMappings,
|
||||
};
|
||||
|
||||
const savedConfigs = JSON.parse(
|
||||
localStorage.getItem("excelUploadConfigs") || "[]"
|
||||
);
|
||||
savedConfigs.push(config);
|
||||
localStorage.setItem("excelUploadConfigs", JSON.stringify(savedConfigs));
|
||||
|
||||
toast.success("설정이 저장되었습니다.");
|
||||
};
|
||||
|
||||
// 다음 단계
|
||||
const handleNext = () => {
|
||||
if (currentStep === 1 && !file) {
|
||||
|
|
@ -317,7 +296,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const mappedData = displayData.map((row) => {
|
||||
// allData를 사용하여 전체 데이터 업로드 (displayData는 미리보기용 10개만)
|
||||
const mappedData = allData.map((row) => {
|
||||
const mappedRow: Record<string, any> = {};
|
||||
columnMappings.forEach((mapping) => {
|
||||
if (mapping.systemColumn) {
|
||||
|
|
@ -379,8 +359,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
setExcelColumns([]);
|
||||
setSystemColumns([]);
|
||||
setColumnMappings([]);
|
||||
setConfigName("");
|
||||
setConfigType("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
|
|
@ -689,27 +667,25 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 3단계: 컬럼 매핑 - 3단 레이아웃 */}
|
||||
{/* 3단계: 컬럼 매핑 */}
|
||||
{currentStep === 3 && (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_3fr_2fr]">
|
||||
{/* 왼쪽: 컬럼 매핑 설정 제목 + 자동 매핑 버튼 */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold sm:text-base">컬럼 매핑 설정</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleAutoMapping}
|
||||
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
자동 매핑
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* 상단: 제목 + 자동 매핑 버튼 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold sm:text-base">컬럼 매핑 설정</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleAutoMapping}
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
자동 매핑
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 매핑 리스트 */}
|
||||
{/* 매핑 리스트 */}
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
|
||||
<div>엑셀 컬럼</div>
|
||||
|
|
@ -734,7 +710,14 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="매핑 안함" />
|
||||
<SelectValue placeholder="매핑 안함">
|
||||
{mapping.systemColumn
|
||||
? (() => {
|
||||
const col = systemColumns.find(c => c.name === mapping.systemColumn);
|
||||
return col?.label || mapping.systemColumn;
|
||||
})()
|
||||
: "매핑 안함"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none" className="text-xs sm:text-sm">
|
||||
|
|
@ -746,7 +729,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
value={col.name}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{col.name} ({col.type})
|
||||
{col.label || col.name} ({col.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -755,50 +738,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 현재 설정 저장 */}
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold sm:text-base">현재 설정 저장</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="config-name" className="text-[10px] sm:text-xs">
|
||||
거래처명 *
|
||||
</Label>
|
||||
<Input
|
||||
id="config-name"
|
||||
value={configName}
|
||||
onChange={(e) => setConfigName(e.target.value)}
|
||||
placeholder="거래처 선택"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="config-type" className="text-[10px] sm:text-xs">
|
||||
유형
|
||||
</Label>
|
||||
<Input
|
||||
id="config-type"
|
||||
value={configType}
|
||||
onChange={(e) => setConfigType(e.target.value)}
|
||||
placeholder="유형을 입력하세요 (예: 원자재)"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSaveConfig}
|
||||
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Save className="mr-2 h-3 w-3" />
|
||||
설정 저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -815,7 +754,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
<span className="font-medium">시트:</span> {selectedSheet}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">데이터 행:</span> {displayData.length}개
|
||||
<span className="font-medium">데이터 행:</span> {allData.length}개
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">테이블:</span> {tableName}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
|
|
@ -18,6 +12,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
|
||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||
|
||||
interface ScreenModalState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -183,15 +178,66 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||
} else {
|
||||
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
|
||||
// 1순위: 이벤트로 전달된 splitPanelParentData (탭 안에서 열린 모달)
|
||||
// 2순위: splitPanelContext에서 직접 가져온 데이터 (분할 패널 내에서 열린 모달)
|
||||
const parentData =
|
||||
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
|
||||
// 모든 필드를 전달하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생
|
||||
// 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감
|
||||
|
||||
// parentDataMapping에서 명시된 필드만 추출
|
||||
const parentDataMapping = splitPanelContext?.parentDataMapping || [];
|
||||
|
||||
// 부모 데이터 소스
|
||||
const rawParentData =
|
||||
splitPanelParentData && Object.keys(splitPanelParentData).length > 0
|
||||
? splitPanelParentData
|
||||
: splitPanelContext?.getMappedParentData() || {};
|
||||
: splitPanelContext?.selectedLeftData || {};
|
||||
|
||||
// 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달
|
||||
const parentData: Record<string, any> = {};
|
||||
|
||||
// 필수 연결 필드: company_code (멀티테넌시)
|
||||
if (rawParentData.company_code) {
|
||||
parentData.company_code = rawParentData.company_code;
|
||||
}
|
||||
|
||||
// parentDataMapping에 정의된 필드만 전달
|
||||
for (const mapping of parentDataMapping) {
|
||||
const sourceValue = rawParentData[mapping.sourceColumn];
|
||||
if (sourceValue !== undefined && sourceValue !== null) {
|
||||
parentData[mapping.targetColumn] = sourceValue;
|
||||
console.log(
|
||||
`🔗 [ScreenModal] 매핑 필드 전달: ${mapping.sourceColumn} → ${mapping.targetColumn} = ${sourceValue}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴)
|
||||
if (parentDataMapping.length === 0) {
|
||||
const linkFieldPatterns = ["_code", "_id"];
|
||||
const excludeFields = [
|
||||
"id",
|
||||
"company_code",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"writer",
|
||||
];
|
||||
|
||||
for (const [key, value] of Object.entries(rawParentData)) {
|
||||
if (excludeFields.includes(key)) continue;
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
// 연결 필드 패턴 확인
|
||||
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
|
||||
if (isLinkField) {
|
||||
parentData[key] = value;
|
||||
console.log(`🔗 [ScreenModal] 연결 필드 자동 감지: ${key} = ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(parentData).length > 0) {
|
||||
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정:", parentData);
|
||||
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정 (연결 필드만):", parentData);
|
||||
setFormData(parentData);
|
||||
} else {
|
||||
setFormData({});
|
||||
|
|
@ -604,19 +650,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
<div className="flex items-center gap-2">
|
||||
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
|
||||
{modalState.description && !loading && (
|
||||
<DialogDescription className="text-muted-foreground text-xs">
|
||||
{modalState.description}
|
||||
</DialogDescription>
|
||||
<DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
|
||||
)}
|
||||
{loading && (
|
||||
<DialogDescription className="text-xs">
|
||||
{loading ? "화면을 불러오는 중입니다..." : ""}
|
||||
</DialogDescription>
|
||||
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -625,6 +667,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</div>
|
||||
) : screenData ? (
|
||||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
className="relative mx-auto bg-white"
|
||||
|
|
@ -697,6 +740,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
})}
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
</ActiveTabProvider>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
||||
|
|
|
|||
|
|
@ -96,22 +96,35 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
|
||||
// 추가 데이터 조회 설정이 있으면 실행
|
||||
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
||||
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
|
||||
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
||||
const matchValue = row[sourceColumn];
|
||||
|
||||
if (matchValue !== undefined && matchValue !== null) {
|
||||
if (additionalQuery?.enabled) {
|
||||
const queryMode = additionalQuery.queryMode || "table";
|
||||
|
||||
// 커스텀 쿼리 모드
|
||||
if (queryMode === "custom" && additionalQuery.customQuery) {
|
||||
setDetailPopupLoading(true);
|
||||
try {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM ${additionalQuery.tableName}
|
||||
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
||||
LIMIT 1;
|
||||
`;
|
||||
// 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
|
||||
let query = additionalQuery.customQuery;
|
||||
// console.log("🔍 [ListTestWidget] 커스텀 쿼리 파라미터 치환 시작");
|
||||
// console.log("🔍 [ListTestWidget] 클릭한 행 데이터:", row);
|
||||
// console.log("🔍 [ListTestWidget] 행 컬럼 목록:", Object.keys(row));
|
||||
|
||||
Object.keys(row).forEach((key) => {
|
||||
const value = row[key];
|
||||
const placeholder = new RegExp(`\\{${key}\\}`, "g");
|
||||
// SQL 인젝션 방지를 위해 값 이스케이프
|
||||
const safeValue = typeof value === "string"
|
||||
? value.replace(/'/g, "''")
|
||||
: value;
|
||||
query = query.replace(placeholder, String(safeValue ?? ""));
|
||||
// console.log(`🔍 [ListTestWidget] 치환: {${key}} → ${safeValue}`);
|
||||
});
|
||||
|
||||
// console.log("🔍 [ListTestWidget] 최종 쿼리:", query);
|
||||
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(query);
|
||||
// console.log("🔍 [ListTestWidget] 쿼리 결과:", result);
|
||||
|
||||
if (result.success && result.rows.length > 0) {
|
||||
setAdditionalDetailData(result.rows[0]);
|
||||
|
|
@ -119,12 +132,43 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
setAdditionalDetailData({});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("추가 데이터 로드 실패:", err);
|
||||
console.error("커스텀 쿼리 실행 실패:", err);
|
||||
setAdditionalDetailData({});
|
||||
} finally {
|
||||
setDetailPopupLoading(false);
|
||||
}
|
||||
}
|
||||
// 테이블 조회 모드
|
||||
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
|
||||
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
||||
const matchValue = row[sourceColumn];
|
||||
|
||||
if (matchValue !== undefined && matchValue !== null) {
|
||||
setDetailPopupLoading(true);
|
||||
try {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM ${additionalQuery.tableName}
|
||||
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
||||
LIMIT 1;
|
||||
`;
|
||||
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(query);
|
||||
|
||||
if (result.success && result.rows.length > 0) {
|
||||
setAdditionalDetailData(result.rows[0]);
|
||||
} else {
|
||||
setAdditionalDetailData({});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("추가 데이터 로드 실패:", err);
|
||||
setAdditionalDetailData({});
|
||||
} finally {
|
||||
setDetailPopupLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[config.rowDetailPopup],
|
||||
|
|
@ -136,9 +180,19 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
|
||||
switch (format) {
|
||||
case "date":
|
||||
return new Date(value).toLocaleDateString("ko-KR");
|
||||
try {
|
||||
const dateVal = new Date(value);
|
||||
return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" });
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
case "datetime":
|
||||
return new Date(value).toLocaleString("ko-KR");
|
||||
try {
|
||||
const dateVal = new Date(value);
|
||||
return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
case "number":
|
||||
return Number(value).toLocaleString("ko-KR");
|
||||
case "currency":
|
||||
|
|
@ -222,13 +276,21 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
||||
const groups: FieldGroup[] = [];
|
||||
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
||||
const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
|
||||
|
||||
// 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
|
||||
// row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
|
||||
const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
|
||||
? { ...row, ...additional } // additional이 row를 덮어씀
|
||||
: row;
|
||||
|
||||
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
||||
const allKeys = Object.keys(row).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
|
||||
const allKeys = Object.keys(mergedData).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
|
||||
let basicFields: { column: string; label: string }[] = [];
|
||||
|
||||
if (displayColumns && displayColumns.length > 0) {
|
||||
// DisplayColumnConfig 형식 지원
|
||||
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
|
||||
basicFields = displayColumns
|
||||
.map((colConfig) => {
|
||||
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||
|
|
@ -237,8 +299,14 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
})
|
||||
.filter((item) => allKeys.includes(item.column));
|
||||
} else {
|
||||
// 전체 컬럼
|
||||
basicFields = allKeys.map((key) => ({ column: key, label: key }));
|
||||
// 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
|
||||
if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
|
||||
basicFields = Object.keys(additional)
|
||||
.filter((key) => !key.startsWith("_"))
|
||||
.map((key) => ({ column: key, label: key }));
|
||||
} else {
|
||||
basicFields = allKeys.map((key) => ({ column: key, label: key }));
|
||||
}
|
||||
}
|
||||
|
||||
groups.push({
|
||||
|
|
@ -253,8 +321,8 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
})),
|
||||
});
|
||||
|
||||
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
|
||||
if (additional && Object.keys(additional).length > 0) {
|
||||
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
|
||||
if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
|
||||
// 운행 정보
|
||||
if (additional.last_trip_start || additional.last_trip_end) {
|
||||
groups.push({
|
||||
|
|
|
|||
|
|
@ -203,11 +203,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
setTripInfoLoading(identifier);
|
||||
|
||||
try {
|
||||
// user_id 또는 vehicle_number로 조회
|
||||
// user_id 또는 vehicle_number로 조회 (TIMESTAMPTZ는 변환 불필요)
|
||||
const query = `SELECT
|
||||
id, vehicle_number, user_id,
|
||||
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
|
||||
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
|
||||
last_trip_start,
|
||||
last_trip_end,
|
||||
last_trip_distance, last_trip_time,
|
||||
last_empty_start,
|
||||
last_empty_end,
|
||||
last_empty_distance, last_empty_time,
|
||||
departure, arrival, status
|
||||
FROM vehicles
|
||||
WHERE user_id = '${identifier}'
|
||||
|
|
@ -277,12 +281,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
if (identifiers.length === 0) return;
|
||||
|
||||
try {
|
||||
// 모든 마커의 운행/공차 정보를 한 번에 조회
|
||||
// 모든 마커의 운행/공차 정보를 한 번에 조회 (TIMESTAMPTZ는 변환 불필요)
|
||||
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const query = `SELECT
|
||||
id, vehicle_number, user_id,
|
||||
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
|
||||
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
|
||||
last_trip_start,
|
||||
last_trip_end,
|
||||
last_trip_distance, last_trip_time,
|
||||
last_empty_start,
|
||||
last_empty_end,
|
||||
last_empty_distance, last_empty_time,
|
||||
departure, arrival, status
|
||||
FROM vehicles
|
||||
WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")})
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input";
|
||||
|
||||
/**
|
||||
* 수주 등록 전용 거래처 검색 컴포넌트
|
||||
*
|
||||
* 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다.
|
||||
* 범용 AutocompleteSearchInput과 달리 customer_mng 테이블만 조회합니다.
|
||||
*/
|
||||
|
||||
interface OrderCustomerSearchProps {
|
||||
/** 현재 선택된 거래처 코드 */
|
||||
value: string;
|
||||
/** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */
|
||||
onChange: (customerCode: string | null, fullData?: any) => void;
|
||||
/** 비활성화 여부 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function OrderCustomerSearch({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: OrderCustomerSearchProps) {
|
||||
return (
|
||||
<AutocompleteSearchInputComponent
|
||||
// 고정 설정 (수주 등록 전용)
|
||||
tableName="customer_mng"
|
||||
displayField="customer_name"
|
||||
valueField="customer_code"
|
||||
searchFields={[
|
||||
"customer_name",
|
||||
"customer_code",
|
||||
"business_number",
|
||||
]}
|
||||
placeholder="거래처명 입력하여 검색"
|
||||
showAdditionalInfo
|
||||
additionalFields={["customer_code", "address", "contact_phone"]}
|
||||
|
||||
// 외부에서 제어 가능한 prop
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ModalRepeaterTableComponent } from "@/lib/registry/components/modal-repeater-table";
|
||||
import type {
|
||||
RepeaterColumnConfig,
|
||||
CalculationRule,
|
||||
} from "@/lib/registry/components/modal-repeater-table";
|
||||
|
||||
/**
|
||||
* 수주 등록 전용 품목 반복 테이블 컴포넌트
|
||||
*
|
||||
* 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다.
|
||||
* 범용 ModalRepeaterTable과 달리 item_info 테이블만 조회하며,
|
||||
* 수주 등록에 필요한 컬럼과 계산 공식이 미리 설정되어 있습니다.
|
||||
*/
|
||||
|
||||
interface OrderItemRepeaterTableProps {
|
||||
/** 현재 선택된 품목 목록 */
|
||||
value: any[];
|
||||
/** 품목 목록 변경 시 콜백 */
|
||||
onChange: (items: any[]) => void;
|
||||
/** 비활성화 여부 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// 수주 등록 전용 컬럼 설정 (고정)
|
||||
const ORDER_COLUMNS: RepeaterColumnConfig[] = [
|
||||
{
|
||||
field: "item_number",
|
||||
label: "품번",
|
||||
editable: false,
|
||||
width: "120px",
|
||||
},
|
||||
{
|
||||
field: "item_name",
|
||||
label: "품명",
|
||||
editable: false,
|
||||
width: "180px",
|
||||
},
|
||||
{
|
||||
field: "specification",
|
||||
label: "규격",
|
||||
editable: false,
|
||||
width: "150px",
|
||||
},
|
||||
{
|
||||
field: "material",
|
||||
label: "재질",
|
||||
editable: false,
|
||||
width: "120px",
|
||||
},
|
||||
{
|
||||
field: "quantity",
|
||||
label: "수량",
|
||||
type: "number",
|
||||
editable: true,
|
||||
required: true,
|
||||
defaultValue: 1,
|
||||
width: "100px",
|
||||
},
|
||||
{
|
||||
field: "selling_price",
|
||||
label: "단가",
|
||||
type: "number",
|
||||
editable: true,
|
||||
required: true,
|
||||
width: "120px",
|
||||
},
|
||||
{
|
||||
field: "amount",
|
||||
label: "금액",
|
||||
type: "number",
|
||||
editable: false,
|
||||
calculated: true,
|
||||
width: "120px",
|
||||
},
|
||||
{
|
||||
field: "order_date",
|
||||
label: "수주일",
|
||||
type: "date",
|
||||
editable: true,
|
||||
width: "130px",
|
||||
},
|
||||
{
|
||||
field: "delivery_date",
|
||||
label: "납기일",
|
||||
type: "date",
|
||||
editable: true,
|
||||
width: "130px",
|
||||
},
|
||||
];
|
||||
|
||||
// 수주 등록 전용 계산 공식 (고정)
|
||||
const ORDER_CALCULATION_RULES: CalculationRule[] = [
|
||||
{
|
||||
result: "amount",
|
||||
formula: "quantity * selling_price",
|
||||
dependencies: ["quantity", "selling_price"],
|
||||
},
|
||||
];
|
||||
|
||||
export function OrderItemRepeaterTable({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: OrderItemRepeaterTableProps) {
|
||||
return (
|
||||
<ModalRepeaterTableComponent
|
||||
// 고정 설정 (수주 등록 전용)
|
||||
sourceTable="item_info"
|
||||
sourceColumns={[
|
||||
"item_number",
|
||||
"item_name",
|
||||
"specification",
|
||||
"material",
|
||||
"unit",
|
||||
"selling_price",
|
||||
]}
|
||||
sourceSearchFields={["item_name", "item_number", "specification"]}
|
||||
modalTitle="품목 검색 및 선택"
|
||||
modalButtonText="품목 검색"
|
||||
multiSelect={true}
|
||||
columns={ORDER_COLUMNS}
|
||||
calculationRules={ORDER_CALCULATION_RULES}
|
||||
uniqueField="item_number"
|
||||
|
||||
// 외부에서 제어 가능한 prop
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,572 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { OrderCustomerSearch } from "./OrderCustomerSearch";
|
||||
import { OrderItemRepeaterTable } from "./OrderItemRepeaterTable";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface OrderRegistrationModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function OrderRegistrationModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: OrderRegistrationModalProps) {
|
||||
// 입력 방식
|
||||
const [inputMode, setInputMode] = useState<string>("customer_first");
|
||||
|
||||
// 판매 유형 (국내/해외)
|
||||
const [salesType, setSalesType] = useState<string>("domestic");
|
||||
|
||||
// 단가 기준 (기준단가/거래처별단가)
|
||||
const [priceType, setPriceType] = useState<string>("standard");
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<any>({
|
||||
customerCode: "",
|
||||
customerName: "",
|
||||
contactPerson: "",
|
||||
deliveryDestination: "",
|
||||
deliveryAddress: "",
|
||||
deliveryDate: "",
|
||||
memo: "",
|
||||
// 무역 정보 (해외 판매 시)
|
||||
incoterms: "",
|
||||
paymentTerms: "",
|
||||
currency: "KRW",
|
||||
portOfLoading: "",
|
||||
portOfDischarge: "",
|
||||
hsCode: "",
|
||||
});
|
||||
|
||||
// 선택된 품목 목록
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
|
||||
// 납기일 일괄 적용 플래그 (딱 한 번만 실행)
|
||||
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
|
||||
|
||||
// 저장 중
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 유효성 검사
|
||||
if (!formData.customerCode) {
|
||||
toast.error("거래처를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedItems.length === 0) {
|
||||
toast.error("품목을 추가해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
// 수주 등록 API 호출
|
||||
const orderData: any = {
|
||||
inputMode,
|
||||
salesType,
|
||||
priceType,
|
||||
customerCode: formData.customerCode,
|
||||
contactPerson: formData.contactPerson,
|
||||
deliveryDestination: formData.deliveryDestination,
|
||||
deliveryAddress: formData.deliveryAddress,
|
||||
deliveryDate: formData.deliveryDate,
|
||||
items: selectedItems,
|
||||
memo: formData.memo,
|
||||
};
|
||||
|
||||
// 해외 판매 시 무역 정보 추가
|
||||
if (salesType === "export") {
|
||||
orderData.tradeInfo = {
|
||||
incoterms: formData.incoterms,
|
||||
paymentTerms: formData.paymentTerms,
|
||||
currency: formData.currency,
|
||||
portOfLoading: formData.portOfLoading,
|
||||
portOfDischarge: formData.portOfDischarge,
|
||||
hsCode: formData.hsCode,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await apiClient.post("/orders", orderData);
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success("수주가 등록되었습니다");
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
|
||||
// 폼 초기화
|
||||
resetForm();
|
||||
} else {
|
||||
toast.error(response.data.message || "수주 등록에 실패했습니다");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("수주 등록 오류:", error);
|
||||
toast.error(
|
||||
error.response?.data?.message || "수주 등록 중 오류가 발생했습니다"
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 취소 처리
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
// 폼 초기화
|
||||
const resetForm = () => {
|
||||
setInputMode("customer_first");
|
||||
setSalesType("domestic");
|
||||
setPriceType("standard");
|
||||
setFormData({
|
||||
customerCode: "",
|
||||
customerName: "",
|
||||
contactPerson: "",
|
||||
deliveryDestination: "",
|
||||
deliveryAddress: "",
|
||||
deliveryDate: "",
|
||||
memo: "",
|
||||
incoterms: "",
|
||||
paymentTerms: "",
|
||||
currency: "KRW",
|
||||
portOfLoading: "",
|
||||
portOfDischarge: "",
|
||||
hsCode: "",
|
||||
});
|
||||
setSelectedItems([]);
|
||||
setIsDeliveryDateApplied(false); // 플래그 초기화
|
||||
};
|
||||
|
||||
// 품목 목록 변경 핸들러 (납기일 일괄 적용 로직 포함)
|
||||
const handleItemsChange = (newItems: any[]) => {
|
||||
// 1️⃣ 플래그가 이미 true면 그냥 업데이트만 (일괄 적용 완료 상태)
|
||||
if (isDeliveryDateApplied) {
|
||||
setSelectedItems(newItems);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2️⃣ 품목이 없으면 그냥 업데이트
|
||||
if (newItems.length === 0) {
|
||||
setSelectedItems(newItems);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3️⃣ 현재 상태: 납기일이 있는 행과 없는 행 개수 체크
|
||||
const itemsWithDate = newItems.filter((item) => item.delivery_date);
|
||||
const itemsWithoutDate = newItems.filter((item) => !item.delivery_date);
|
||||
|
||||
// 4️⃣ 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용
|
||||
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
|
||||
// 5️⃣ 전체 일괄 적용
|
||||
const selectedDate = itemsWithDate[0].delivery_date;
|
||||
const updatedItems = newItems.map((item) => ({
|
||||
...item,
|
||||
delivery_date: selectedDate, // 모든 행에 동일한 납기일 적용
|
||||
}));
|
||||
|
||||
setSelectedItems(updatedItems);
|
||||
setIsDeliveryDateApplied(true); // 플래그 활성화 (다음부터는 일괄 적용 안 함)
|
||||
|
||||
console.log("✅ 납기일 일괄 적용 완료:", selectedDate);
|
||||
console.log(` - 대상: ${itemsWithoutDate.length}개 행에 ${selectedDate} 적용`);
|
||||
} else {
|
||||
// 그냥 업데이트
|
||||
setSelectedItems(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
// 전체 금액 계산
|
||||
const totalAmount = selectedItems.reduce(
|
||||
(sum, item) => sum + (item.amount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">수주 등록</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
새로운 수주를 등록합니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 상단 셀렉트 박스 3개 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* 입력 방식 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inputMode" className="text-xs sm:text-sm flex items-center gap-1">
|
||||
<span className="text-amber-500">📝</span> 입력 방식
|
||||
</Label>
|
||||
<Select value={inputMode} onValueChange={setInputMode}>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="입력 방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="customer_first">거래처 우선</SelectItem>
|
||||
<SelectItem value="quotation">견대 방식</SelectItem>
|
||||
<SelectItem value="unit_price">단가 방식</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 판매 유형 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salesType" className="text-xs sm:text-sm flex items-center gap-1">
|
||||
<span className="text-blue-500">🌏</span> 판매 유형
|
||||
</Label>
|
||||
<Select value={salesType} onValueChange={setSalesType}>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="판매 유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="domestic">국내 판매</SelectItem>
|
||||
<SelectItem value="export">해외 판매</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 단가 기준 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="priceType" className="text-xs sm:text-sm flex items-center gap-1">
|
||||
<span className="text-green-500">💰</span> 단가 방식
|
||||
</Label>
|
||||
<Select value={priceType} onValueChange={setPriceType}>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="단가 방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">기준 단가</SelectItem>
|
||||
<SelectItem value="customer">거래처별 단가</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 거래처 정보 (항상 표시) */}
|
||||
{inputMode === "customer_first" && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<span>🏢</span>
|
||||
<span>거래처 정보</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">거래처 *</Label>
|
||||
<OrderCustomerSearch
|
||||
value={formData.customerCode}
|
||||
onChange={(code, fullData) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
customerCode: code || "",
|
||||
customerName: fullData?.customer_name || "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 담당자 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactPerson" className="text-xs sm:text-sm">
|
||||
담당자
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
id="contactPerson"
|
||||
placeholder="담당자"
|
||||
value={formData.contactPerson}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, contactPerson: e.target.value })
|
||||
}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 납품처 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deliveryDestination" className="text-xs sm:text-sm">
|
||||
납품처
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
id="deliveryDestination"
|
||||
placeholder="납품처"
|
||||
value={formData.deliveryDestination}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, deliveryDestination: e.target.value })
|
||||
}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 납품장소 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deliveryAddress" className="text-xs sm:text-sm">
|
||||
납품장소
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
id="deliveryAddress"
|
||||
placeholder="납품장소"
|
||||
value={formData.deliveryAddress}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, deliveryAddress: e.target.value })
|
||||
}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputMode === "quotation" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">견대 번호 *</Label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="견대 번호를 입력하세요"
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputMode === "unit_price" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">단가 방식 설정</Label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="단가 정보 입력"
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가된 품목 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">추가된 품목</Label>
|
||||
<OrderItemRepeaterTable
|
||||
value={selectedItems}
|
||||
onChange={handleItemsChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 전체 금액 표시 */}
|
||||
{selectedItems.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<div className="text-sm sm:text-base font-semibold">
|
||||
전체 금액: {totalAmount.toLocaleString()}원
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 무역 정보 (해외 판매 시에만 표시) */}
|
||||
{salesType === "export" && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50/50 p-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-blue-700">
|
||||
<span>🌏</span>
|
||||
<span>무역 정보</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* 인코텀즈 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="incoterms" className="text-xs sm:text-sm">
|
||||
인코텀즈
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.incoterms}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, incoterms: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EXW">EXW</SelectItem>
|
||||
<SelectItem value="FOB">FOB</SelectItem>
|
||||
<SelectItem value="CIF">CIF</SelectItem>
|
||||
<SelectItem value="DDP">DDP</SelectItem>
|
||||
<SelectItem value="DAP">DAP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 결제 조건 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="paymentTerms" className="text-xs sm:text-sm">
|
||||
결제 조건
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.paymentTerms}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, paymentTerms: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="advance">선결제</SelectItem>
|
||||
<SelectItem value="cod">착불</SelectItem>
|
||||
<SelectItem value="lc">신용장(L/C)</SelectItem>
|
||||
<SelectItem value="net30">NET 30</SelectItem>
|
||||
<SelectItem value="net60">NET 60</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 통화 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currency" className="text-xs sm:text-sm">
|
||||
통화
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.currency}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, currency: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="통화 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="KRW">KRW (원)</SelectItem>
|
||||
<SelectItem value="USD">USD (달러)</SelectItem>
|
||||
<SelectItem value="EUR">EUR (유로)</SelectItem>
|
||||
<SelectItem value="JPY">JPY (엔)</SelectItem>
|
||||
<SelectItem value="CNY">CNY (위안)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* 선적항 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portOfLoading" className="text-xs sm:text-sm">
|
||||
선적항
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
id="portOfLoading"
|
||||
placeholder="선적항"
|
||||
value={formData.portOfLoading}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, portOfLoading: e.target.value })
|
||||
}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 도착항 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portOfDischarge" className="text-xs sm:text-sm">
|
||||
도착항
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
id="portOfDischarge"
|
||||
placeholder="도착항"
|
||||
value={formData.portOfDischarge}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, portOfDischarge: e.target.value })
|
||||
}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HS Code */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hsCode" className="text-xs sm:text-sm">
|
||||
HS Code
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
id="hsCode"
|
||||
placeholder="HS Code"
|
||||
value={formData.hsCode}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, hsCode: e.target.value })
|
||||
}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 메모 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memo" className="text-xs sm:text-sm">
|
||||
메모
|
||||
</Label>
|
||||
<textarea
|
||||
id="memo"
|
||||
placeholder="메모를 입력하세요"
|
||||
value={formData.memo}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, memo: e.target.value })
|
||||
}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isSaving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,374 +0,0 @@
|
|||
# 수주 등록 컴포넌트
|
||||
|
||||
## 개요
|
||||
|
||||
수주 등록 기능을 위한 전용 컴포넌트들입니다. 이 컴포넌트들은 범용 컴포넌트를 래핑하여 수주 등록에 최적화된 고정 설정을 제공합니다.
|
||||
|
||||
## 컴포넌트 구조
|
||||
|
||||
```
|
||||
frontend/components/order/
|
||||
├── OrderRegistrationModal.tsx # 수주 등록 메인 모달
|
||||
├── OrderCustomerSearch.tsx # 거래처 검색 (전용)
|
||||
├── OrderItemRepeaterTable.tsx # 품목 반복 테이블 (전용)
|
||||
└── README.md # 문서 (현재 파일)
|
||||
```
|
||||
|
||||
## 1. OrderRegistrationModal
|
||||
|
||||
수주 등록 메인 모달 컴포넌트입니다.
|
||||
|
||||
### Props
|
||||
|
||||
```typescript
|
||||
interface OrderRegistrationModalProps {
|
||||
/** 모달 열림/닫힘 상태 */
|
||||
open: boolean;
|
||||
/** 모달 상태 변경 핸들러 */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 수주 등록 성공 시 콜백 */
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```tsx
|
||||
import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
|
||||
|
||||
function MyComponent() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsOpen(true)}>수주 등록</Button>
|
||||
|
||||
<OrderRegistrationModal
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
onSuccess={() => {
|
||||
console.log("수주 등록 완료!");
|
||||
// 목록 새로고침 등
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 기능
|
||||
|
||||
- **입력 방식 선택**: 거래처 우선, 견적 방식, 단가 방식
|
||||
- **거래처 검색**: 자동완성 드롭다운으로 거래처 검색 및 선택
|
||||
- **품목 관리**: 모달에서 품목 검색 및 추가, 수량/단가 입력, 금액 자동 계산
|
||||
- **전체 금액 표시**: 추가된 품목들의 총 금액 계산
|
||||
- **유효성 검사**: 거래처 및 품목 필수 입력 체크
|
||||
|
||||
---
|
||||
|
||||
## 2. OrderCustomerSearch
|
||||
|
||||
수주 등록 전용 거래처 검색 컴포넌트입니다.
|
||||
|
||||
### 특징
|
||||
|
||||
- `customer_mng` 테이블만 조회 (고정)
|
||||
- 거래처명, 거래처코드, 사업자번호로 검색 (고정)
|
||||
- 추가 정보 표시 (주소, 연락처)
|
||||
|
||||
### Props
|
||||
|
||||
```typescript
|
||||
interface OrderCustomerSearchProps {
|
||||
/** 현재 선택된 거래처 코드 */
|
||||
value: string;
|
||||
/** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */
|
||||
onChange: (customerCode: string | null, fullData?: any) => void;
|
||||
/** 비활성화 여부 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```tsx
|
||||
import { OrderCustomerSearch } from "@/components/order/OrderCustomerSearch";
|
||||
|
||||
function MyForm() {
|
||||
const [customerCode, setCustomerCode] = useState("");
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
|
||||
return (
|
||||
<OrderCustomerSearch
|
||||
value={customerCode}
|
||||
onChange={(code, fullData) => {
|
||||
setCustomerCode(code || "");
|
||||
setCustomerName(fullData?.customer_name || "");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 고정 설정
|
||||
|
||||
| 설정 | 값 | 설명 |
|
||||
|------|-----|------|
|
||||
| `tableName` | `customer_mng` | 거래처 테이블 |
|
||||
| `displayField` | `customer_name` | 표시 필드 |
|
||||
| `valueField` | `customer_code` | 값 필드 |
|
||||
| `searchFields` | `["customer_name", "customer_code", "business_number"]` | 검색 대상 필드 |
|
||||
| `additionalFields` | `["customer_code", "address", "contact_phone"]` | 추가 표시 필드 |
|
||||
|
||||
---
|
||||
|
||||
## 3. OrderItemRepeaterTable
|
||||
|
||||
수주 등록 전용 품목 반복 테이블 컴포넌트입니다.
|
||||
|
||||
### 특징
|
||||
|
||||
- `item_info` 테이블만 조회 (고정)
|
||||
- 수주에 필요한 컬럼만 표시 (품번, 품명, 수량, 단가, 금액 등)
|
||||
- 금액 자동 계산 (`수량 * 단가`)
|
||||
|
||||
### Props
|
||||
|
||||
```typescript
|
||||
interface OrderItemRepeaterTableProps {
|
||||
/** 현재 선택된 품목 목록 */
|
||||
value: any[];
|
||||
/** 품목 목록 변경 시 콜백 */
|
||||
onChange: (items: any[]) => void;
|
||||
/** 비활성화 여부 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```tsx
|
||||
import { OrderItemRepeaterTable } from "@/components/order/OrderItemRepeaterTable";
|
||||
|
||||
function MyForm() {
|
||||
const [items, setItems] = useState([]);
|
||||
|
||||
return (
|
||||
<OrderItemRepeaterTable
|
||||
value={items}
|
||||
onChange={setItems}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 고정 컬럼 설정
|
||||
|
||||
| 필드 | 라벨 | 타입 | 편집 | 필수 | 계산 | 설명 |
|
||||
|------|------|------|------|------|------|------|
|
||||
| `id` | 품번 | text | ❌ | - | - | 품목 ID |
|
||||
| `item_name` | 품명 | text | ❌ | - | - | 품목명 |
|
||||
| `item_number` | 품목번호 | text | ❌ | - | - | 품목 번호 |
|
||||
| `quantity` | 수량 | number | ✅ | ✅ | - | 주문 수량 (기본값: 1) |
|
||||
| `selling_price` | 단가 | number | ✅ | ✅ | - | 판매 단가 |
|
||||
| `amount` | 금액 | number | ❌ | - | ✅ | 자동 계산 (수량 * 단가) |
|
||||
| `delivery_date` | 납품일 | date | ✅ | - | - | 납품 예정일 |
|
||||
| `note` | 비고 | text | ✅ | - | - | 추가 메모 |
|
||||
|
||||
### 계산 규칙
|
||||
|
||||
```javascript
|
||||
amount = quantity * selling_price
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 범용 컴포넌트 vs 전용 컴포넌트
|
||||
|
||||
### 왜 전용 컴포넌트를 만들었나?
|
||||
|
||||
| 항목 | 범용 컴포넌트 | 전용 컴포넌트 |
|
||||
|------|--------------|--------------|
|
||||
| **목적** | 화면 편집기에서 다양한 용도로 사용 | 수주 등록 전용 |
|
||||
| **설정** | ConfigPanel에서 자유롭게 변경 가능 | 하드코딩으로 고정 |
|
||||
| **유연성** | 높음 (모든 테이블/필드 지원) | 낮음 (수주에 최적화) |
|
||||
| **안정성** | 사용자 실수 가능 | 설정 변경 불가로 안전 |
|
||||
| **위치** | `lib/registry/components/` | `components/order/` |
|
||||
|
||||
### 범용 컴포넌트 (화면 편집기용)
|
||||
|
||||
```tsx
|
||||
// ❌ 수주 등록에서 사용 금지
|
||||
<AutocompleteSearchInputComponent
|
||||
tableName="???" // ConfigPanel에서 변경 가능
|
||||
displayField="???" // 다른 테이블로 바꿀 수 있음
|
||||
valueField="???" // 필드가 맞지 않으면 에러
|
||||
/>
|
||||
```
|
||||
|
||||
**문제점:**
|
||||
- 사용자가 `tableName`을 `item_info`로 변경하면 거래처가 아닌 품목이 조회됨
|
||||
- `valueField`를 변경하면 `formData.customerCode`에 잘못된 값 저장
|
||||
- 수주 로직이 깨짐
|
||||
|
||||
### 전용 컴포넌트 (수주 등록용)
|
||||
|
||||
```tsx
|
||||
// ✅ 수주 등록에서 사용
|
||||
<OrderCustomerSearch
|
||||
value={customerCode} // 외부에서 제어 가능
|
||||
onChange={handleChange} // 값 변경만 처리
|
||||
// 나머지 설정은 내부에서 고정
|
||||
/>
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- 설정이 하드코딩되어 있어 변경 불가
|
||||
- 수주 등록 로직에 최적화
|
||||
- 안전하고 예측 가능
|
||||
|
||||
---
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### 거래처 검색
|
||||
|
||||
```
|
||||
GET /api/entity-search/customer_mng
|
||||
Query Parameters:
|
||||
- searchText: 검색어
|
||||
- searchFields: customer_name,customer_code,business_number
|
||||
- page: 페이지 번호
|
||||
- limit: 페이지 크기
|
||||
```
|
||||
|
||||
### 품목 검색
|
||||
|
||||
```
|
||||
GET /api/entity-search/item_info
|
||||
Query Parameters:
|
||||
- searchText: 검색어
|
||||
- searchFields: item_name,id,item_number
|
||||
- page: 페이지 번호
|
||||
- limit: 페이지 크기
|
||||
```
|
||||
|
||||
### 수주 등록
|
||||
|
||||
```
|
||||
POST /api/orders
|
||||
Body:
|
||||
{
|
||||
inputMode: "customer_first" | "quotation" | "unit_price",
|
||||
customerCode: string,
|
||||
deliveryDate?: string,
|
||||
items: Array<{
|
||||
id: string,
|
||||
item_name: string,
|
||||
quantity: number,
|
||||
selling_price: number,
|
||||
amount: number,
|
||||
delivery_date?: string,
|
||||
note?: string
|
||||
}>,
|
||||
memo?: string
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
success: boolean,
|
||||
data?: {
|
||||
orderNumber: string,
|
||||
orderId: number
|
||||
},
|
||||
message?: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 멀티테넌시 (Multi-Tenancy)
|
||||
|
||||
모든 API 호출은 자동으로 `company_code` 필터링이 적용됩니다.
|
||||
|
||||
- 거래처 검색: 현재 로그인한 사용자의 회사에 속한 거래처만 조회
|
||||
- 품목 검색: 현재 로그인한 사용자의 회사에 속한 품목만 조회
|
||||
- 수주 등록: 자동으로 현재 사용자의 `company_code` 추가
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### 1. 거래처가 검색되지 않음
|
||||
|
||||
**원인**: `customer_mng` 테이블에 데이터가 없거나 `company_code`가 다름
|
||||
|
||||
**해결**:
|
||||
```sql
|
||||
-- 거래처 데이터 확인
|
||||
SELECT * FROM customer_mng WHERE company_code = 'YOUR_COMPANY_CODE';
|
||||
```
|
||||
|
||||
### 2. 품목이 검색되지 않음
|
||||
|
||||
**원인**: `item_info` 테이블에 데이터가 없거나 `company_code`가 다름
|
||||
|
||||
**해결**:
|
||||
```sql
|
||||
-- 품목 데이터 확인
|
||||
SELECT * FROM item_info WHERE company_code = 'YOUR_COMPANY_CODE';
|
||||
```
|
||||
|
||||
### 3. 수주 등록 실패
|
||||
|
||||
**원인**: 필수 필드 누락 또는 백엔드 API 오류
|
||||
|
||||
**해결**:
|
||||
1. 브라우저 개발자 도구 콘솔 확인
|
||||
2. 네트워크 탭에서 API 응답 확인
|
||||
3. 백엔드 로그 확인
|
||||
|
||||
---
|
||||
|
||||
## 개발 참고 사항
|
||||
|
||||
### 새로운 전용 컴포넌트 추가 시
|
||||
|
||||
1. **범용 컴포넌트 활용**: 기존 범용 컴포넌트를 래핑
|
||||
2. **설정 고정**: 비즈니스 로직에 필요한 설정을 하드코딩
|
||||
3. **Props 최소화**: 외부에서 제어 가능한 최소한의 prop만 노출
|
||||
4. **문서 작성**: README에 사용법 및 고정 설정 명시
|
||||
|
||||
### 예시: 견적 등록 전용 컴포넌트
|
||||
|
||||
```tsx
|
||||
// QuotationCustomerSearch.tsx
|
||||
export function QuotationCustomerSearch({ value, onChange }: Props) {
|
||||
return (
|
||||
<AutocompleteSearchInputComponent
|
||||
tableName="customer_mng" // 고정
|
||||
displayField="customer_name" // 고정
|
||||
valueField="customer_code" // 고정
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- 범용 컴포넌트:
|
||||
- `lib/registry/components/autocomplete-search-input/`
|
||||
- `lib/registry/components/entity-search-input/`
|
||||
- `lib/registry/components/modal-repeater-table/`
|
||||
|
||||
- 백엔드 API:
|
||||
- `backend-node/src/controllers/entitySearchController.ts`
|
||||
- `backend-node/src/controllers/orderController.ts`
|
||||
|
||||
- 계획서:
|
||||
- `수주등록_화면_개발_계획서.md`
|
||||
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
export const INPUT_MODE = {
|
||||
CUSTOMER_FIRST: "customer_first",
|
||||
QUOTATION: "quotation",
|
||||
UNIT_PRICE: "unit_price",
|
||||
} as const;
|
||||
|
||||
export type InputMode = (typeof INPUT_MODE)[keyof typeof INPUT_MODE];
|
||||
|
||||
export const SALES_TYPE = {
|
||||
DOMESTIC: "domestic",
|
||||
EXPORT: "export",
|
||||
} as const;
|
||||
|
||||
export type SalesType = (typeof SALES_TYPE)[keyof typeof SALES_TYPE];
|
||||
|
||||
export const PRICE_TYPE = {
|
||||
STANDARD: "standard",
|
||||
CUSTOMER: "customer",
|
||||
} as const;
|
||||
|
||||
export type PriceType = (typeof PRICE_TYPE)[keyof typeof PRICE_TYPE];
|
||||
|
|
@ -14,7 +14,7 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Pencil, Copy, Trash2, Loader2 } from "lucide-react";
|
||||
import { Copy, Trash2, Loader2 } from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
|
@ -149,7 +149,11 @@ export function ReportListTable({
|
|||
{reports.map((report, index) => {
|
||||
const rowNumber = (page - 1) * limit + index + 1;
|
||||
return (
|
||||
<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>
|
||||
<div>
|
||||
|
|
@ -162,34 +166,25 @@ export function ReportListTable({
|
|||
<TableCell>{report.created_by || "-"}</TableCell>
|
||||
<TableCell>{formatDate(report.updated_at || report.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(report.report_id)}
|
||||
className="gap-1"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => handleCopy(report.report_id)}
|
||||
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
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
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>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,159 @@ import { useRef, useState, useEffect } from "react";
|
|||
import { ComponentConfig } from "@/types/report";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import JsBarcode from "jsbarcode";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
// 고정 스케일 팩터 (화면 해상도와 무관)
|
||||
const MM_TO_PX = 4;
|
||||
|
||||
// 1D 바코드 렌더러 컴포넌트
|
||||
interface BarcodeRendererProps {
|
||||
value: string;
|
||||
format: string;
|
||||
width: number;
|
||||
height: number;
|
||||
displayValue: boolean;
|
||||
lineColor: string;
|
||||
background: string;
|
||||
margin: number;
|
||||
}
|
||||
|
||||
function BarcodeRenderer({
|
||||
value,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
displayValue,
|
||||
lineColor,
|
||||
background,
|
||||
margin,
|
||||
}: BarcodeRendererProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !value) return;
|
||||
|
||||
// 매번 에러 상태 초기화 후 재검사
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 바코드 형식에 따른 유효성 검사
|
||||
let isValid = true;
|
||||
let errorMsg = "";
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (format === "EAN13" && !/^\d{12,13}$/.test(trimmedValue)) {
|
||||
isValid = false;
|
||||
errorMsg = "EAN-13: 12~13자리 숫자 필요";
|
||||
} else if (format === "EAN8" && !/^\d{7,8}$/.test(trimmedValue)) {
|
||||
isValid = false;
|
||||
errorMsg = "EAN-8: 7~8자리 숫자 필요";
|
||||
} else if (format === "UPC" && !/^\d{11,12}$/.test(trimmedValue)) {
|
||||
isValid = false;
|
||||
errorMsg = "UPC: 11~12자리 숫자 필요";
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
setError(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// JsBarcode는 format을 소문자로 받음
|
||||
const barcodeFormat = format.toLowerCase();
|
||||
// transparent는 빈 문자열로 변환 (SVG 배경 없음)
|
||||
const bgColor = background === "transparent" ? "" : background;
|
||||
|
||||
JsBarcode(svgRef.current, trimmedValue, {
|
||||
format: barcodeFormat,
|
||||
width: 2,
|
||||
height: Math.max(30, height - (displayValue ? 30 : 10)),
|
||||
displayValue: displayValue,
|
||||
lineColor: lineColor,
|
||||
background: bgColor,
|
||||
margin: margin,
|
||||
fontSize: 12,
|
||||
textMargin: 2,
|
||||
});
|
||||
} catch (err: any) {
|
||||
// JsBarcode 체크섬 오류 등
|
||||
setError(err?.message || "바코드 생성 실패");
|
||||
}
|
||||
}, [value, format, width, height, displayValue, lineColor, background, margin]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* SVG는 항상 렌더링 (에러 시 숨김) */}
|
||||
<svg ref={svgRef} className={`max-h-full max-w-full ${error ? "hidden" : ""}`} />
|
||||
{/* 에러 메시지 오버레이 */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-xs text-red-500">
|
||||
<span>{error}</span>
|
||||
<span className="mt-1 text-gray-400">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// QR코드 렌더러 컴포넌트
|
||||
interface QRCodeRendererProps {
|
||||
value: string;
|
||||
size: number;
|
||||
fgColor: string;
|
||||
bgColor: string;
|
||||
level: "L" | "M" | "Q" | "H";
|
||||
}
|
||||
|
||||
function QRCodeRenderer({ value, size, fgColor, bgColor, level }: QRCodeRendererProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !value) return;
|
||||
|
||||
// 매번 에러 상태 초기화 후 재시도
|
||||
setError(null);
|
||||
|
||||
// qrcode 라이브러리는 hex 색상만 지원, transparent는 흰색으로 대체
|
||||
const lightColor = bgColor === "transparent" ? "#ffffff" : bgColor;
|
||||
|
||||
QRCode.toCanvas(
|
||||
canvasRef.current,
|
||||
value,
|
||||
{
|
||||
width: Math.max(50, size),
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: fgColor,
|
||||
light: lightColor,
|
||||
},
|
||||
errorCorrectionLevel: level,
|
||||
},
|
||||
(err) => {
|
||||
if (err) {
|
||||
// 실제 에러 메시지 표시
|
||||
setError(err.message || "QR코드 생성 실패");
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [value, size, fgColor, bgColor, level]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* Canvas는 항상 렌더링 (에러 시 숨김) */}
|
||||
<canvas ref={canvasRef} className={`max-h-full max-w-full ${error ? "hidden" : ""}`} />
|
||||
{/* 에러 메시지 오버레이 */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-xs text-red-500">
|
||||
<span>{error}</span>
|
||||
<span className="mt-1 text-gray-400">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CanvasComponentProps {
|
||||
component: ComponentConfig;
|
||||
|
|
@ -23,6 +176,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
canvasWidth,
|
||||
canvasHeight,
|
||||
margins,
|
||||
layoutConfig,
|
||||
currentPageId,
|
||||
} = useReportDesigner();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
|
@ -100,15 +255,15 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
const newX = Math.max(0, e.clientX - dragStart.x);
|
||||
const newY = Math.max(0, e.clientY - dragStart.y);
|
||||
|
||||
// 여백을 px로 변환 (1mm ≈ 3.7795px)
|
||||
const marginTopPx = margins.top * 3.7795;
|
||||
const marginBottomPx = margins.bottom * 3.7795;
|
||||
const marginLeftPx = margins.left * 3.7795;
|
||||
const marginRightPx = margins.right * 3.7795;
|
||||
// 여백을 px로 변환
|
||||
const marginTopPx = margins.top * MM_TO_PX;
|
||||
const marginBottomPx = margins.bottom * MM_TO_PX;
|
||||
const marginLeftPx = margins.left * MM_TO_PX;
|
||||
const marginRightPx = margins.right * MM_TO_PX;
|
||||
|
||||
// 캔버스 경계 체크 (mm를 px로 변환)
|
||||
const canvasWidthPx = canvasWidth * 3.7795;
|
||||
const canvasHeightPx = canvasHeight * 3.7795;
|
||||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||||
|
||||
// 컴포넌트가 여백 안에 있도록 제한
|
||||
const minX = marginLeftPx;
|
||||
|
|
@ -160,12 +315,12 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
const newHeight = Math.max(30, resizeStart.height + deltaY);
|
||||
|
||||
// 여백을 px로 변환
|
||||
const marginRightPx = margins.right * 3.7795;
|
||||
const marginBottomPx = margins.bottom * 3.7795;
|
||||
const marginRightPx = margins.right * MM_TO_PX;
|
||||
const marginBottomPx = margins.bottom * MM_TO_PX;
|
||||
|
||||
// 캔버스 경계 체크
|
||||
const canvasWidthPx = canvasWidth * 3.7795;
|
||||
const canvasHeightPx = canvasHeight * 3.7795;
|
||||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||||
|
||||
// 컴포넌트가 여백을 벗어나지 않도록 최대 크기 제한
|
||||
const maxWidth = canvasWidthPx - marginRightPx - component.x;
|
||||
|
|
@ -174,11 +329,40 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
const boundedWidth = Math.min(newWidth, maxWidth);
|
||||
const boundedHeight = Math.min(newHeight, maxHeight);
|
||||
|
||||
// Grid Snap 적용
|
||||
updateComponent(component.id, {
|
||||
width: snapValueToGrid(boundedWidth),
|
||||
height: snapValueToGrid(boundedHeight),
|
||||
});
|
||||
// 구분선은 방향에 따라 한 축만 조절 가능
|
||||
if (component.type === "divider") {
|
||||
if (component.orientation === "vertical") {
|
||||
// 세로 구분선: 높이만 조절
|
||||
updateComponent(component.id, {
|
||||
height: snapValueToGrid(boundedHeight),
|
||||
});
|
||||
} else {
|
||||
// 가로 구분선: 너비만 조절
|
||||
updateComponent(component.id, {
|
||||
width: snapValueToGrid(boundedWidth),
|
||||
});
|
||||
}
|
||||
} else if (component.type === "barcode" && component.barcodeType === "QR") {
|
||||
// QR코드는 정사각형 유지: 더 큰 변화량 기준으로 동기화
|
||||
const maxDelta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
|
||||
const newSize = Math.max(50, resizeStart.width + maxDelta);
|
||||
const maxSize = Math.min(
|
||||
canvasWidthPx - marginRightPx - component.x,
|
||||
canvasHeightPx - marginBottomPx - component.y,
|
||||
);
|
||||
const boundedSize = Math.min(newSize, maxSize);
|
||||
const snappedSize = snapValueToGrid(boundedSize);
|
||||
updateComponent(component.id, {
|
||||
width: snappedSize,
|
||||
height: snappedSize,
|
||||
});
|
||||
} else {
|
||||
// Grid Snap 적용
|
||||
updateComponent(component.id, {
|
||||
width: snapValueToGrid(boundedWidth),
|
||||
height: snapValueToGrid(boundedHeight),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -258,43 +442,19 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
|
||||
switch (component.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>텍스트 필드</span>
|
||||
{hasBinding && <span className="text-blue-600">● 연결됨</span>}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: `${component.fontSize}px`,
|
||||
color: component.fontColor,
|
||||
fontWeight: component.fontWeight,
|
||||
textAlign: component.textAlign as "left" | "center" | "right",
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "label":
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>레이블</span>
|
||||
{hasBinding && <span className="text-blue-600">● 연결됨</span>}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: `${component.fontSize}px`,
|
||||
color: component.fontColor,
|
||||
fontWeight: component.fontWeight,
|
||||
textAlign: component.textAlign as "left" | "center" | "right",
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
fontSize: `${component.fontSize}px`,
|
||||
color: component.fontColor,
|
||||
fontWeight: component.fontWeight,
|
||||
textAlign: component.textAlign as "left" | "center" | "right",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -317,10 +477,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>테이블</span>
|
||||
<span className="text-blue-600">● 연결됨 ({queryResult.rows.length}행)</span>
|
||||
</div>
|
||||
<table
|
||||
className="w-full border-collapse text-xs"
|
||||
style={{
|
||||
|
|
@ -377,30 +533,26 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
|
||||
// 기본 테이블 (데이터 없을 때)
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-1 text-xs text-gray-500">테이블</div>
|
||||
<div className="flex h-[calc(100%-20px)] items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
||||
쿼리를 연결하세요
|
||||
</div>
|
||||
<div className="flex h-full w-full items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
||||
쿼리를 연결하세요
|
||||
</div>
|
||||
);
|
||||
|
||||
case "image":
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div className="mb-1 text-xs text-gray-500">이미지</div>
|
||||
{component.imageUrl ? (
|
||||
<img
|
||||
src={getFullImageUrl(component.imageUrl)}
|
||||
alt="이미지"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "calc(100% - 20px)",
|
||||
height: "100%",
|
||||
objectFit: component.objectFit || "contain",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[calc(100%-20px)] w-full items-center justify-center border border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
||||
<div className="flex h-full w-full items-center justify-center border border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
||||
이미지를 업로드하세요
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -408,21 +560,23 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
);
|
||||
|
||||
case "divider":
|
||||
const lineWidth = component.lineWidth || 1;
|
||||
const lineColor = component.lineColor || "#000000";
|
||||
// 구분선 (가로: 너비만 조절, 세로: 높이만 조절)
|
||||
const dividerLineWidth = component.lineWidth || 1;
|
||||
const dividerLineColor = component.lineColor || "#000000";
|
||||
const isHorizontal = component.orientation !== "vertical";
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className={`flex h-full w-full ${isHorizontal ? "items-center" : "justify-center"}`}>
|
||||
<div
|
||||
style={{
|
||||
width: component.orientation === "horizontal" ? "100%" : `${lineWidth}px`,
|
||||
height: component.orientation === "vertical" ? "100%" : `${lineWidth}px`,
|
||||
backgroundColor: lineColor,
|
||||
width: isHorizontal ? "100%" : `${dividerLineWidth}px`,
|
||||
height: isHorizontal ? `${dividerLineWidth}px` : "100%",
|
||||
backgroundColor: dividerLineColor,
|
||||
...(component.lineStyle === "dashed" && {
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
||||
${lineColor} 0px,
|
||||
${lineColor} 10px,
|
||||
${isHorizontal ? "90deg" : "0deg"},
|
||||
${dividerLineColor} 0px,
|
||||
${dividerLineColor} 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)`,
|
||||
|
|
@ -430,19 +584,18 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
}),
|
||||
...(component.lineStyle === "dotted" && {
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
||||
${lineColor} 0px,
|
||||
${lineColor} 3px,
|
||||
${isHorizontal ? "90deg" : "0deg"},
|
||||
${dividerLineColor} 0px,
|
||||
${dividerLineColor} 3px,
|
||||
transparent 3px,
|
||||
transparent 10px
|
||||
)`,
|
||||
backgroundColor: "transparent",
|
||||
}),
|
||||
...(component.lineStyle === "double" && {
|
||||
boxShadow:
|
||||
component.orientation === "horizontal"
|
||||
? `0 ${lineWidth * 2}px 0 0 ${lineColor}`
|
||||
: `${lineWidth * 2}px 0 0 0 ${lineColor}`,
|
||||
boxShadow: isHorizontal
|
||||
? `0 ${dividerLineWidth * 2}px 0 0 ${dividerLineColor}`
|
||||
: `${dividerLineWidth * 2}px 0 0 0 ${dividerLineColor}`,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
|
@ -457,9 +610,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-1 text-xs text-gray-500">서명란</div>
|
||||
<div
|
||||
className={`flex h-[calc(100%-20px)] gap-2 ${
|
||||
className={`flex h-full gap-2 ${
|
||||
sigLabelPos === "top"
|
||||
? "flex-col"
|
||||
: sigLabelPos === "bottom"
|
||||
|
|
@ -521,8 +673,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-1 text-xs text-gray-500">도장란</div>
|
||||
<div className="flex h-[calc(100%-20px)] gap-2">
|
||||
<div className="flex h-full gap-2">
|
||||
{stampPersonName && <div className="flex items-center text-xs font-medium">{stampPersonName}</div>}
|
||||
<div className="relative flex-1">
|
||||
{component.imageUrl ? (
|
||||
|
|
@ -561,6 +712,454 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
</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>
|
||||
);
|
||||
|
||||
case "barcode":
|
||||
// 바코드/QR코드 컴포넌트 렌더링
|
||||
const barcodeType = component.barcodeType || "CODE128";
|
||||
const showBarcodeText = component.showBarcodeText !== false;
|
||||
const barcodeColor = component.barcodeColor || "#000000";
|
||||
const barcodeBackground = component.barcodeBackground || "transparent";
|
||||
const barcodeMargin = component.barcodeMargin ?? 10;
|
||||
const qrErrorLevel = component.qrErrorCorrectionLevel || "M";
|
||||
|
||||
// 바코드 값 결정 (쿼리 바인딩 또는 고정값)
|
||||
const getBarcodeValue = (): string => {
|
||||
// QR코드 다중 필드 모드
|
||||
if (
|
||||
barcodeType === "QR" &&
|
||||
component.qrUseMultiField &&
|
||||
component.qrDataFields &&
|
||||
component.qrDataFields.length > 0 &&
|
||||
component.queryId
|
||||
) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
// 모든 행 포함 모드
|
||||
if (component.qrIncludeAllRows) {
|
||||
const allRowsData: Record<string, string>[] = [];
|
||||
queryResult.rows.forEach((row) => {
|
||||
const rowData: Record<string, string> = {};
|
||||
component.qrDataFields!.forEach((field) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
rowData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
allRowsData.push(rowData);
|
||||
});
|
||||
return JSON.stringify(allRowsData);
|
||||
}
|
||||
|
||||
// 단일 행 (첫 번째 행만)
|
||||
const row = queryResult.rows[0];
|
||||
const jsonData: Record<string, string> = {};
|
||||
component.qrDataFields.forEach((field) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
jsonData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
return JSON.stringify(jsonData);
|
||||
}
|
||||
// 쿼리 결과가 없으면 플레이스홀더 표시
|
||||
const placeholderData: Record<string, string> = {};
|
||||
component.qrDataFields.forEach((field) => {
|
||||
if (field.label) {
|
||||
placeholderData[field.label] = `{${field.fieldName || "field"}}`;
|
||||
}
|
||||
});
|
||||
return component.qrIncludeAllRows
|
||||
? JSON.stringify([placeholderData, { "...": "..." }])
|
||||
: JSON.stringify(placeholderData);
|
||||
}
|
||||
|
||||
// 단일 필드 바인딩
|
||||
if (component.barcodeFieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
// QR코드 + 모든 행 포함
|
||||
if (barcodeType === "QR" && component.qrIncludeAllRows) {
|
||||
const allValues = queryResult.rows
|
||||
.map((row) => {
|
||||
const val = row[component.barcodeFieldName!];
|
||||
return val !== null && val !== undefined ? String(val) : "";
|
||||
})
|
||||
.filter((v) => v !== "");
|
||||
return JSON.stringify(allValues);
|
||||
}
|
||||
|
||||
// 단일 행 (첫 번째 행만)
|
||||
const row = queryResult.rows[0];
|
||||
const val = row[component.barcodeFieldName];
|
||||
if (val !== null && val !== undefined) {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
// 플레이스홀더
|
||||
if (barcodeType === "QR" && component.qrIncludeAllRows) {
|
||||
return JSON.stringify([`{${component.barcodeFieldName}}`, "..."]);
|
||||
}
|
||||
return `{${component.barcodeFieldName}}`;
|
||||
}
|
||||
return component.barcodeValue || "SAMPLE123";
|
||||
};
|
||||
|
||||
const barcodeValue = getBarcodeValue();
|
||||
const isQR = barcodeType === "QR";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center overflow-hidden"
|
||||
style={{ backgroundColor: barcodeBackground }}
|
||||
>
|
||||
{isQR ? (
|
||||
<QRCodeRenderer
|
||||
value={barcodeValue}
|
||||
size={Math.min(component.width, component.height) - 10}
|
||||
fgColor={barcodeColor}
|
||||
bgColor={barcodeBackground}
|
||||
level={qrErrorLevel}
|
||||
/>
|
||||
) : (
|
||||
<BarcodeRenderer
|
||||
value={barcodeValue}
|
||||
format={barcodeType}
|
||||
width={component.width}
|
||||
height={component.height}
|
||||
displayValue={showBarcodeText}
|
||||
lineColor={barcodeColor}
|
||||
background={barcodeBackground}
|
||||
margin={barcodeMargin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "checkbox":
|
||||
// 체크박스 컴포넌트 렌더링
|
||||
const checkboxSize = component.checkboxSize || 18;
|
||||
const checkboxColor = component.checkboxColor || "#2563eb";
|
||||
const checkboxBorderColor = component.checkboxBorderColor || "#6b7280";
|
||||
const checkboxLabelPosition = component.checkboxLabelPosition || "right";
|
||||
const checkboxLabel = component.checkboxLabel || "";
|
||||
|
||||
// 체크 상태 결정 (쿼리 바인딩 또는 고정값)
|
||||
const getCheckboxValue = (): boolean => {
|
||||
if (component.checkboxFieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0];
|
||||
const val = row[component.checkboxFieldName];
|
||||
// truthy/falsy 값 판정
|
||||
if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return component.checkboxChecked === true;
|
||||
};
|
||||
|
||||
const isChecked = getCheckboxValue();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full w-full items-center gap-2 ${
|
||||
checkboxLabelPosition === "left" ? "flex-row-reverse justify-end" : ""
|
||||
}`}
|
||||
>
|
||||
{/* 체크박스 */}
|
||||
<div
|
||||
className="flex items-center justify-center rounded-sm border-2 transition-colors"
|
||||
style={{
|
||||
width: `${checkboxSize}px`,
|
||||
height: `${checkboxSize}px`,
|
||||
borderColor: isChecked ? checkboxColor : checkboxBorderColor,
|
||||
backgroundColor: isChecked ? checkboxColor : "transparent",
|
||||
}}
|
||||
>
|
||||
{isChecked && (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
width: `${checkboxSize * 0.7}px`,
|
||||
height: `${checkboxSize * 0.7}px`,
|
||||
}}
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{/* 레이블 */}
|
||||
{/* 레이블 */}
|
||||
{checkboxLabel && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${component.fontSize || 14}px`,
|
||||
color: component.fontColor || "#374151",
|
||||
}}
|
||||
>
|
||||
{checkboxLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div>알 수 없는 컴포넌트</div>;
|
||||
}
|
||||
|
|
@ -569,7 +1168,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
return (
|
||||
<div
|
||||
ref={componentRef}
|
||||
className={`absolute p-2 shadow-sm ${isLocked ? "cursor-not-allowed opacity-80" : "cursor-move"} ${
|
||||
className={`absolute ${component.type === "divider" ? "p-0" : "p-2"} shadow-sm ${isLocked ? "cursor-not-allowed opacity-80" : "cursor-move"} ${
|
||||
isSelected
|
||||
? isLocked
|
||||
? "ring-2 ring-red-500"
|
||||
|
|
@ -608,8 +1207,21 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
{/* 리사이즈 핸들 (선택된 경우만, 잠금 안 된 경우만) */}
|
||||
{isSelected && !isLocked && (
|
||||
<div
|
||||
className="resize-handle absolute right-0 bottom-0 h-3 w-3 cursor-se-resize rounded-full bg-blue-500"
|
||||
style={{ transform: "translate(50%, 50%)" }}
|
||||
className={`resize-handle absolute h-3 w-3 rounded-full bg-blue-500 ${
|
||||
component.type === "divider"
|
||||
? component.orientation === "vertical"
|
||||
? "bottom-0 left-1/2 cursor-s-resize" // 세로 구분선: 하단 중앙
|
||||
: "top-1/2 right-0 cursor-e-resize" // 가로 구분선: 우측 중앙
|
||||
: "right-0 bottom-0 cursor-se-resize" // 일반 컴포넌트: 우하단
|
||||
}`}
|
||||
style={{
|
||||
transform:
|
||||
component.type === "divider"
|
||||
? component.orientation === "vertical"
|
||||
? "translate(-50%, 50%)" // 세로 구분선
|
||||
: "translate(50%, -50%)" // 가로 구분선
|
||||
: "translate(50%, 50%)", // 일반 컴포넌트
|
||||
}}
|
||||
onMouseDown={handleResizeStart}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useDrag } from "react-dnd";
|
||||
import { Type, Table, Tag, Image, Minus, PenLine, Stamp as StampIcon } from "lucide-react";
|
||||
import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator, Barcode, CheckSquare } from "lucide-react";
|
||||
|
||||
interface ComponentItem {
|
||||
type: string;
|
||||
|
|
@ -12,11 +12,15 @@ interface ComponentItem {
|
|||
const COMPONENTS: ComponentItem[] = [
|
||||
{ type: "text", label: "텍스트", icon: <Type 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: "divider", label: "구분선", icon: <Minus 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: "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" /> },
|
||||
{ type: "barcode", label: "바코드/QR", icon: <Barcode className="h-4 w-4" /> },
|
||||
{ type: "checkbox", label: "체크박스", icon: <CheckSquare className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
function DraggableComponentItem({ type, label, icon }: ComponentItem) {
|
||||
|
|
|
|||
|
|
@ -76,25 +76,25 @@ export function PageListPanel() {
|
|||
};
|
||||
|
||||
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">
|
||||
<h3 className="text-sm font-semibold">페이지 목록</h3>
|
||||
<Button size="sm" variant="ghost" onClick={() => addPage()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<div className="flex items-center justify-between border-b px-2 py-1.5">
|
||||
<h3 className="text-[10px] font-semibold">페이지</h3>
|
||||
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => addPage()}>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 페이지 목록 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full p-2">
|
||||
<div className="space-y-2">
|
||||
<ScrollArea className="h-full p-1">
|
||||
<div className="space-y-1">
|
||||
{layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page, index) => (
|
||||
<div
|
||||
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
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-primary/50 hover:bg-accent/50"
|
||||
|
|
@ -103,7 +103,7 @@ export function PageListPanel() {
|
|||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 드래그 핸들 */}
|
||||
<div
|
||||
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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
|
||||
{/* 페이지 정보 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{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
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
|
|
@ -129,21 +129,21 @@ export function PageListPanel() {
|
|||
if (e.key === "Enter") handleSaveEdit();
|
||||
if (e.key === "Escape") handleCancelEdit();
|
||||
}}
|
||||
className="h-6 text-xs"
|
||||
className="h-5 text-[10px]"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={handleSaveEdit}>
|
||||
<Check className="h-3 w-3" />
|
||||
<Button size="sm" variant="ghost" className="h-4 w-4 p-0" onClick={handleSaveEdit}>
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={handleCancelEdit}>
|
||||
<X className="h-3 w-3" />
|
||||
<Button size="sm" variant="ghost" className="h-4 w-4 p-0" onClick={handleCancelEdit}>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</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]">
|
||||
{page.width}x{page.height}mm • {page.components.length}개
|
||||
<div className="text-muted-foreground text-[8px]">
|
||||
{page.width}x{page.height}mm
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -153,10 +153,10 @@ export function PageListPanel() {
|
|||
<Button
|
||||
size="sm"
|
||||
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="text-sm leading-none">⋮</span>
|
||||
<span className="text-[10px] leading-none">⋮</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
|
|
@ -199,9 +199,9 @@ export function PageListPanel() {
|
|||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t p-2">
|
||||
<Button size="sm" variant="outline" className="w-full" onClick={() => addPage()}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 페이지 추가
|
||||
<div className="border-t p-1">
|
||||
<Button size="sm" variant="outline" className="h-6 w-full text-[10px]" onClick={() => addPage()}>
|
||||
<Plus className="mr-1 h-3 w-3" />추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -201,7 +201,8 @@ export function QueryManager() {
|
|||
setIsTestRunning({ ...isTestRunning, [query.id]: true });
|
||||
try {
|
||||
const testReportId = reportId === "new" ? "TEMP_TEST" : reportId;
|
||||
const sqlQuery = reportId === "new" ? query.sqlQuery : undefined;
|
||||
// 항상 sqlQuery를 전달 (새 쿼리가 아직 DB에 저장되지 않았을 수 있음)
|
||||
const sqlQuery = query.sqlQuery;
|
||||
const externalConnectionId = (query as any).externalConnectionId || null;
|
||||
const queryParams = parameterValues[query.id] || {};
|
||||
|
||||
|
|
@ -264,24 +265,24 @@ export function QueryManager() {
|
|||
|
||||
return (
|
||||
<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 w-full items-center justify-between pr-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<AccordionTrigger className="flex-1 px-0 py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{query.name}</span>
|
||||
<Badge variant={query.type === "MASTER" ? "default" : "secondary"} className="text-xs">
|
||||
{query.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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" />
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-1 pr-0 pb-3 pl-0">
|
||||
{/* 쿼리 이름 */}
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -3,10 +3,191 @@
|
|||
import { useRef, useEffect } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { ComponentConfig } from "@/types/report";
|
||||
import { ComponentConfig, WatermarkConfig } from "@/types/report";
|
||||
import { CanvasComponent } from "./CanvasComponent";
|
||||
import { Ruler } from "./Ruler";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
|
||||
// mm를 px로 변환하는 고정 스케일 팩터 (화면 해상도와 무관하게 일정)
|
||||
// A4 기준: 210mm x 297mm → 840px x 1188px
|
||||
export const MM_TO_PX = 4;
|
||||
|
||||
// 워터마크 레이어 컴포넌트
|
||||
interface WatermarkLayerProps {
|
||||
watermark: WatermarkConfig;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
}
|
||||
|
||||
function WatermarkLayer({ watermark, canvasWidth, canvasHeight }: WatermarkLayerProps) {
|
||||
// 공통 스타일
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
zIndex: 1, // 컴포넌트보다 낮은 z-index
|
||||
};
|
||||
|
||||
// 대각선 스타일
|
||||
if (watermark.style === "diagonal") {
|
||||
const rotation = watermark.rotation ?? -45;
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={getFullImageUrl(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 중앙 스타일
|
||||
if (watermark.style === "center") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={getFullImageUrl(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 타일 스타일
|
||||
if (watermark.style === "tile") {
|
||||
const rotation = watermark.rotation ?? -30;
|
||||
// 타일 간격 계산
|
||||
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
||||
const cols = Math.ceil(canvasWidth / tileSize) + 2;
|
||||
const rows = Math.ceil(canvasHeight / tileSize) + 2;
|
||||
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-50%",
|
||||
left: "-50%",
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignContent: "flex-start",
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: rows * cols }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
width: `${tileSize}px`,
|
||||
height: `${tileSize}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 24}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={getFullImageUrl(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
width: `${tileSize * 0.6}px`,
|
||||
height: `${tileSize * 0.6}px`,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ReportDesignerCanvas() {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -32,6 +213,7 @@ export function ReportDesignerCanvas() {
|
|||
undo,
|
||||
redo,
|
||||
showRuler,
|
||||
layoutConfig,
|
||||
} = useReportDesigner();
|
||||
|
||||
const [{ isOver }, drop] = useDrop(() => ({
|
||||
|
|
@ -58,24 +240,33 @@ export function ReportDesignerCanvas() {
|
|||
height = 150;
|
||||
} else if (item.componentType === "divider") {
|
||||
width = 300;
|
||||
height = 2;
|
||||
height = 10; // 선 두께 + 여백 (선택/드래그를 위한 최소 높이)
|
||||
} else if (item.componentType === "signature") {
|
||||
width = 120;
|
||||
height = 70;
|
||||
} else if (item.componentType === "stamp") {
|
||||
width = 70;
|
||||
height = 70;
|
||||
} else if (item.componentType === "pageNumber") {
|
||||
width = 100;
|
||||
height = 30;
|
||||
} else if (item.componentType === "barcode") {
|
||||
width = 200;
|
||||
height = 80;
|
||||
} else if (item.componentType === "checkbox") {
|
||||
width = 150;
|
||||
height = 30;
|
||||
}
|
||||
|
||||
// 여백을 px로 변환 (1mm ≈ 3.7795px)
|
||||
const marginTopPx = margins.top * 3.7795;
|
||||
const marginLeftPx = margins.left * 3.7795;
|
||||
const marginRightPx = margins.right * 3.7795;
|
||||
const marginBottomPx = margins.bottom * 3.7795;
|
||||
// 여백을 px로 변환
|
||||
const marginTopPx = margins.top * MM_TO_PX;
|
||||
const marginLeftPx = margins.left * MM_TO_PX;
|
||||
const marginRightPx = margins.right * MM_TO_PX;
|
||||
const marginBottomPx = margins.bottom * MM_TO_PX;
|
||||
|
||||
// 캔버스 경계 (px)
|
||||
const canvasWidthPx = canvasWidth * 3.7795;
|
||||
const canvasHeightPx = canvasHeight * 3.7795;
|
||||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||||
|
||||
// 드롭 위치 계산 (여백 내부로 제한)
|
||||
const rawX = x - 100;
|
||||
|
|
@ -143,6 +334,55 @@ export function ReportDesignerCanvas() {
|
|||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
}),
|
||||
// 페이지 번호 전용
|
||||
...(item.componentType === "pageNumber" && {
|
||||
pageNumberFormat: "number" as const, // number, numberTotal, koreanNumber
|
||||
textAlign: "center" as const,
|
||||
}),
|
||||
// 카드 컴포넌트 전용
|
||||
...(item.componentType === "card" && {
|
||||
width: 300,
|
||||
height: 180,
|
||||
cardTitle: "정보 카드",
|
||||
showCardTitle: true,
|
||||
cardItems: [
|
||||
{ label: "항목1", value: "내용1", fieldName: "" },
|
||||
{ label: "항목2", value: "내용2", fieldName: "" },
|
||||
{ label: "항목3", value: "내용3", fieldName: "" },
|
||||
],
|
||||
labelWidth: 80,
|
||||
showCardBorder: true,
|
||||
titleFontSize: 14,
|
||||
labelFontSize: 13,
|
||||
valueFontSize: 13,
|
||||
titleColor: "#1e40af",
|
||||
labelColor: "#374151",
|
||||
valueColor: "#000000",
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e7eb",
|
||||
}),
|
||||
// 계산 컴포넌트 전용
|
||||
...(item.componentType === "calculation" && {
|
||||
width: 350,
|
||||
height: 120,
|
||||
calcItems: [
|
||||
{ label: "공급가액", value: 0, operator: "+" as const, fieldName: "" },
|
||||
{ label: "부가세 (10%)", value: 0, operator: "+" as const, fieldName: "" },
|
||||
],
|
||||
resultLabel: "합계 금액",
|
||||
labelWidth: 120,
|
||||
labelFontSize: 13,
|
||||
valueFontSize: 13,
|
||||
resultFontSize: 16,
|
||||
labelColor: "#374151",
|
||||
valueColor: "#000000",
|
||||
resultColor: "#2563eb",
|
||||
showCalcBorder: false,
|
||||
numberFormat: "currency" as const,
|
||||
currencySuffix: "원",
|
||||
borderWidth: 0,
|
||||
borderColor: "#e5e7eb",
|
||||
}),
|
||||
// 테이블 전용
|
||||
...(item.componentType === "table" && {
|
||||
queryId: undefined,
|
||||
|
|
@ -152,6 +392,26 @@ export function ReportDesignerCanvas() {
|
|||
showBorder: true,
|
||||
rowHeight: 32,
|
||||
}),
|
||||
// 바코드 컴포넌트 전용
|
||||
...(item.componentType === "barcode" && {
|
||||
barcodeType: "CODE128" as const,
|
||||
barcodeValue: "SAMPLE123",
|
||||
barcodeFieldName: "",
|
||||
showBarcodeText: true,
|
||||
barcodeColor: "#000000",
|
||||
barcodeBackground: "transparent",
|
||||
barcodeMargin: 10,
|
||||
qrErrorCorrectionLevel: "M" as const,
|
||||
}),
|
||||
// 체크박스 컴포넌트 전용
|
||||
...(item.componentType === "checkbox" && {
|
||||
checkboxChecked: false,
|
||||
checkboxLabel: "항목",
|
||||
checkboxSize: 18,
|
||||
checkboxColor: "#2563eb",
|
||||
checkboxBorderColor: "#6b7280",
|
||||
checkboxLabelPosition: "right" as const,
|
||||
}),
|
||||
};
|
||||
|
||||
addComponent(newComponent);
|
||||
|
|
@ -297,13 +557,8 @@ export function ReportDesignerCanvas() {
|
|||
|
||||
return (
|
||||
<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">
|
||||
{/* 좌상단 코너 + 가로 눈금자 */}
|
||||
|
|
@ -329,8 +584,8 @@ export function ReportDesignerCanvas() {
|
|||
}}
|
||||
className={`relative bg-white shadow-lg ${isOver ? "ring-2 ring-blue-500" : ""}`}
|
||||
style={{
|
||||
width: `${canvasWidth}mm`,
|
||||
minHeight: `${canvasHeight}mm`,
|
||||
width: `${canvasWidth * MM_TO_PX}px`,
|
||||
minHeight: `${canvasHeight * MM_TO_PX}px`,
|
||||
backgroundImage: showGrid
|
||||
? `
|
||||
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
|
||||
|
|
@ -346,14 +601,23 @@ export function ReportDesignerCanvas() {
|
|||
<div
|
||||
className="pointer-events-none absolute border-2 border-dashed border-blue-300/50"
|
||||
style={{
|
||||
top: `${currentPage.margins.top}mm`,
|
||||
left: `${currentPage.margins.left}mm`,
|
||||
right: `${currentPage.margins.right}mm`,
|
||||
bottom: `${currentPage.margins.bottom}mm`,
|
||||
top: `${currentPage.margins.top * MM_TO_PX}px`,
|
||||
left: `${currentPage.margins.left * MM_TO_PX}px`,
|
||||
right: `${currentPage.margins.right * MM_TO_PX}px`,
|
||||
bottom: `${currentPage.margins.bottom * MM_TO_PX}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 워터마크 렌더링 (전체 페이지 공유) */}
|
||||
{layoutConfig.watermark?.enabled && (
|
||||
<WatermarkLayer
|
||||
watermark={layoutConfig.watermark}
|
||||
canvasWidth={canvasWidth * MM_TO_PX}
|
||||
canvasHeight={canvasHeight * MM_TO_PX}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 정렬 가이드라인 렌더링 */}
|
||||
{alignmentGuides.vertical.map((x, index) => (
|
||||
<div
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -8,9 +8,12 @@ interface RulerProps {
|
|||
offset?: number; // 스크롤 오프셋 (px)
|
||||
}
|
||||
|
||||
// 고정 스케일 팩터 (화면 해상도와 무관)
|
||||
const MM_TO_PX = 4;
|
||||
|
||||
export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
// mm를 px로 변환 (1mm = 3.7795px, 96dpi 기준)
|
||||
const mmToPx = (mm: number) => mm * 3.7795;
|
||||
// mm를 px로 변환
|
||||
const mmToPx = (mm: number) => mm * MM_TO_PX;
|
||||
|
||||
const lengthPx = mmToPx(length);
|
||||
const isHorizontal = orientation === "horizontal";
|
||||
|
|
|
|||
|
|
@ -13,17 +13,17 @@ interface SignatureGeneratorProps {
|
|||
onSignatureSelect: (dataUrl: string) => void;
|
||||
}
|
||||
|
||||
// 서명용 손글씨 폰트 목록 (스타일이 확실히 구분되는 폰트들)
|
||||
// 서명용 손글씨 폰트 목록 (완전한 한글 지원 폰트만 사용)
|
||||
const SIGNATURE_FONTS = {
|
||||
korean: [
|
||||
{ name: "나눔손글씨 붓", style: "'Nanum Brush Script', cursive", weight: 400 },
|
||||
{ name: "나눔손글씨 펜", style: "'Nanum Pen Script', cursive", weight: 400 },
|
||||
{ name: "배달의민족 도현", style: "Dokdo, cursive", weight: 400 },
|
||||
{ name: "귀여운", style: "Gugi, cursive", weight: 400 },
|
||||
{ name: "싱글데이", style: "'Single Day', cursive", weight: 400 },
|
||||
{ name: "스타일리시", style: "Stylish, cursive", weight: 400 },
|
||||
{ name: "해바라기", style: "Sunflower, sans-serif", weight: 700 },
|
||||
{ name: "손글씨", style: "Gaegu, cursive", weight: 700 },
|
||||
{ name: "손글씨 (Gaegu)", style: "Gaegu, cursive", weight: 700 },
|
||||
{ name: "하이멜로디", style: "'Hi Melody', cursive", weight: 400 },
|
||||
{ name: "감자꽃", style: "'Gamja Flower', cursive", weight: 400 },
|
||||
{ name: "푸어스토리", style: "'Poor Story', cursive", weight: 400 },
|
||||
{ name: "도현", style: "'Do Hyeon', sans-serif", weight: 400 },
|
||||
{ name: "주아", style: "Jua, sans-serif", weight: 400 },
|
||||
],
|
||||
english: [
|
||||
{ name: "Allura (우아한)", style: "Allura, cursive", weight: 400 },
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { Trash2, Loader2, RefreshCw } from "lucide-react";
|
|||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface Template {
|
||||
template_id: string;
|
||||
|
|
@ -17,7 +16,6 @@ interface Template {
|
|||
|
||||
export function TemplatePalette() {
|
||||
const { applyTemplate } = useReportDesigner();
|
||||
const [systemTemplates, setSystemTemplates] = useState<Template[]>([]);
|
||||
const [customTemplates, setCustomTemplates] = useState<Template[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
|
@ -28,7 +26,6 @@ export function TemplatePalette() {
|
|||
try {
|
||||
const response = await reportApi.getTemplates();
|
||||
if (response.success && response.data) {
|
||||
setSystemTemplates(Array.isArray(response.data.system) ? response.data.system : []);
|
||||
setCustomTemplates(Array.isArray(response.data.custom) ? response.data.custom : []);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -79,31 +76,10 @@ export function TemplatePalette() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 시스템 템플릿 (DB에서 조회) */}
|
||||
{systemTemplates.length > 0 && (
|
||||
<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">
|
||||
<p className="text-xs font-semibold text-gray-600">사용자 정의 템플릿</p>
|
||||
<div className="flex items-center justify-end">
|
||||
<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" : ""}`} />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -40,38 +40,42 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [screenInfo, setScreenInfo] = useState<any>(null);
|
||||
const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작
|
||||
const [formDataVersion, setFormDataVersion] = useState(0); // 🆕 폼 데이터 버전 (강제 리렌더링용)
|
||||
|
||||
// 컴포넌트 참조 맵
|
||||
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
|
||||
|
||||
|
||||
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
|
||||
|
||||
// 🆕 selectedLeftData 참조 안정화 (실제 값이 바뀔 때만 업데이트)
|
||||
const selectedLeftData = splitPanelContext?.selectedLeftData;
|
||||
const prevSelectedLeftDataRef = useRef<string>("");
|
||||
|
||||
// 🆕 사용자 정보 가져오기 (저장 액션에 필요)
|
||||
const { userId, userName, companyCode } = useAuth();
|
||||
|
||||
// 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해)
|
||||
const contentBounds = React.useMemo(() => {
|
||||
if (layout.length === 0) return { width: 0, height: 0 };
|
||||
|
||||
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
|
||||
|
||||
layout.forEach((component) => {
|
||||
const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component;
|
||||
const right = (compPosition.x || 0) + (size.width || 200);
|
||||
const bottom = (compPosition.y || 0) + (size.height || 40);
|
||||
|
||||
|
||||
if (right > maxRight) maxRight = right;
|
||||
if (bottom > maxBottom) maxBottom = bottom;
|
||||
});
|
||||
|
||||
|
||||
return { width: maxRight, height: maxBottom };
|
||||
}, [layout]);
|
||||
|
||||
// 필드 값 변경 핸들러
|
||||
const handleFieldChange = useCallback((fieldName: string, value: any) => {
|
||||
console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value });
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
|
|
@ -83,35 +87,55 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
loadScreenData();
|
||||
}, [embedding.childScreenId]);
|
||||
|
||||
// 🆕 initialFormData 변경 시 formData 업데이트 (수정 모드)
|
||||
// initialFormData 변경 시 formData 업데이트 (수정 모드)
|
||||
useEffect(() => {
|
||||
if (initialFormData && Object.keys(initialFormData).length > 0) {
|
||||
console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData);
|
||||
setFormData(initialFormData);
|
||||
}
|
||||
}, [initialFormData]);
|
||||
|
||||
// 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영
|
||||
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
||||
// 🆕 좌측 선택 데이터가 변경되면 우측 formData를 업데이트
|
||||
useEffect(() => {
|
||||
// 우측 화면인 경우에만 적용
|
||||
if (position !== "right" || !splitPanelContext) return;
|
||||
|
||||
// 자동 데이터 전달이 비활성화된 경우 스킵
|
||||
if (splitPanelContext.disableAutoDataTransfer) {
|
||||
console.log("🔗 [EmbeddedScreen] 자동 데이터 전달 비활성화됨 - 버튼 클릭으로만 전달");
|
||||
if (position !== "right" || !splitPanelContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedData = splitPanelContext.getMappedParentData();
|
||||
if (Object.keys(mappedData).length > 0) {
|
||||
console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
...mappedData,
|
||||
}));
|
||||
|
||||
// 자동 데이터 전달이 비활성화된 경우 스킵
|
||||
if (splitPanelContext.disableAutoDataTransfer) {
|
||||
return;
|
||||
}
|
||||
}, [position, splitPanelContext, splitPanelContext?.selectedLeftData]);
|
||||
|
||||
// 🆕 값 비교로 실제 변경 여부 확인 (불필요한 리렌더링 방지)
|
||||
const currentDataStr = JSON.stringify(selectedLeftData || {});
|
||||
if (prevSelectedLeftDataRef.current === currentDataStr) {
|
||||
return; // 실제 값이 같으면 스킵
|
||||
}
|
||||
prevSelectedLeftDataRef.current = currentDataStr;
|
||||
|
||||
// 🆕 현재 화면의 모든 컴포넌트에서 columnName 수집
|
||||
const allColumnNames = layout.filter((comp) => comp.columnName).map((comp) => comp.columnName as string);
|
||||
|
||||
// 🆕 모든 필드를 빈 값으로 초기화한 후, selectedLeftData로 덮어쓰기
|
||||
const initializedFormData: Record<string, any> = {};
|
||||
|
||||
// 먼저 모든 컬럼을 빈 문자열로 초기화
|
||||
allColumnNames.forEach((colName) => {
|
||||
initializedFormData[colName] = "";
|
||||
});
|
||||
|
||||
// selectedLeftData가 있으면 해당 값으로 덮어쓰기
|
||||
if (selectedLeftData && Object.keys(selectedLeftData).length > 0) {
|
||||
Object.keys(selectedLeftData).forEach((key) => {
|
||||
// null/undefined는 빈 문자열로, 나머지는 그대로
|
||||
initializedFormData[key] = selectedLeftData[key] ?? "";
|
||||
});
|
||||
}
|
||||
|
||||
setFormData(initializedFormData);
|
||||
setFormDataVersion((v) => v + 1); // 🆕 버전 증가로 컴포넌트 강제 리렌더링
|
||||
}, [position, splitPanelContext, selectedLeftData, layout]);
|
||||
|
||||
// 선택 변경 이벤트 전파
|
||||
useEffect(() => {
|
||||
|
|
@ -128,13 +152,6 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
|
||||
// 화면 정보 로드 (screenApi.getScreen은 직접 ScreenDefinition 객체를 반환)
|
||||
const screenData = await screenApi.getScreen(embedding.childScreenId);
|
||||
console.log("📋 [EmbeddedScreen] 화면 정보 API 응답:", {
|
||||
screenId: embedding.childScreenId,
|
||||
hasData: !!screenData,
|
||||
tableName: screenData?.tableName,
|
||||
screenName: screenData?.name || screenData?.screenName,
|
||||
position,
|
||||
});
|
||||
if (screenData) {
|
||||
setScreenInfo(screenData);
|
||||
} else {
|
||||
|
|
@ -377,15 +394,15 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
<p className="text-muted-foreground text-sm">화면에 컴포넌트가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
minHeight: contentBounds.height + 20, // 여유 공간 추가
|
||||
}}
|
||||
>
|
||||
{layout.map((component) => {
|
||||
const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
|
||||
|
||||
|
||||
// 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정
|
||||
// 부모 컨테이너의 100%를 기준으로 계산
|
||||
const componentStyle: React.CSSProperties = {
|
||||
|
|
@ -397,13 +414,9 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
// 컴포넌트가 오른쪽 경계를 넘어가면 너비 조정
|
||||
maxWidth: `calc(100% - ${compPosition.x || 0}px)`,
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
className="absolute"
|
||||
style={componentStyle}
|
||||
>
|
||||
<div key={`${component.id}-${formDataVersion}`} className="absolute" style={componentStyle}>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isInteractive={true}
|
||||
|
|
|
|||
|
|
@ -27,29 +27,12 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp
|
|||
// config에서 splitRatio 추출 (기본값 50)
|
||||
const configSplitRatio = config?.splitRatio ?? 50;
|
||||
|
||||
console.log("🎯 [ScreenSplitPanel] 렌더링됨!", {
|
||||
screenId,
|
||||
config,
|
||||
leftScreenId: config?.leftScreenId,
|
||||
rightScreenId: config?.rightScreenId,
|
||||
configSplitRatio,
|
||||
parentDataMapping: config?.parentDataMapping,
|
||||
configKeys: config ? Object.keys(config) : [],
|
||||
});
|
||||
|
||||
// 🆕 initialFormData 별도 로그 (명확한 확인)
|
||||
console.log("📝 [ScreenSplitPanel] initialFormData 확인:", {
|
||||
hasInitialFormData: !!initialFormData,
|
||||
initialFormDataKeys: initialFormData ? Object.keys(initialFormData) : [],
|
||||
initialFormData: initialFormData,
|
||||
});
|
||||
|
||||
// 드래그로 조절 가능한 splitRatio 상태
|
||||
const [splitRatio, setSplitRatio] = useState(configSplitRatio);
|
||||
|
||||
// config.splitRatio가 변경되면 동기화 (설정 패널에서 변경 시)
|
||||
React.useEffect(() => {
|
||||
console.log("📐 [ScreenSplitPanel] splitRatio 동기화:", { configSplitRatio, currentSplitRatio: splitRatio });
|
||||
setSplitRatio(configSplitRatio);
|
||||
}, [configSplitRatio]);
|
||||
|
||||
|
|
|
|||
|
|
@ -26,12 +26,56 @@ interface EditModalState {
|
|||
onSave?: () => void;
|
||||
groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"])
|
||||
tableName?: string; // 🆕 테이블명 (그룹 조회용)
|
||||
buttonConfig?: any; // 🆕 버튼 설정 (제어로직 실행용)
|
||||
buttonContext?: any; // 🆕 버튼 컨텍스트 (screenId, userId 등)
|
||||
saveButtonConfig?: {
|
||||
enableDataflowControl?: boolean;
|
||||
dataflowConfig?: any;
|
||||
dataflowTiming?: string;
|
||||
}; // 🆕 모달 내부 저장 버튼의 제어로직 설정
|
||||
}
|
||||
|
||||
interface EditModalProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 내부에서 저장 버튼 찾기 (재귀적으로 탐색)
|
||||
* action.type이 "save"인 button-primary 컴포넌트를 찾음
|
||||
*/
|
||||
const findSaveButtonInComponents = (components: any[]): any | null => {
|
||||
if (!components || !Array.isArray(components)) return null;
|
||||
|
||||
for (const comp of components) {
|
||||
// button-primary이고 action.type이 save인 경우
|
||||
if (
|
||||
comp.componentType === "button-primary" &&
|
||||
comp.componentConfig?.action?.type === "save"
|
||||
) {
|
||||
return comp;
|
||||
}
|
||||
|
||||
// conditional-container의 sections 내부 탐색
|
||||
if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) {
|
||||
for (const section of comp.componentConfig.sections) {
|
||||
if (section.screenId) {
|
||||
// 조건부 컨테이너의 내부 화면은 별도로 로드해야 함
|
||||
// 여기서는 null 반환하고, loadSaveButtonConfig에서 처리
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 자식 컴포넌트가 있으면 재귀 탐색
|
||||
if (comp.children && Array.isArray(comp.children)) {
|
||||
const found = findSaveButtonInComponents(comp.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
const { user } = useAuth();
|
||||
const [modalState, setModalState] = useState<EditModalState>({
|
||||
|
|
@ -44,6 +88,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
onSave: undefined,
|
||||
groupByColumns: undefined,
|
||||
tableName: undefined,
|
||||
buttonConfig: undefined,
|
||||
buttonContext: undefined,
|
||||
saveButtonConfig: undefined,
|
||||
});
|
||||
|
||||
const [screenData, setScreenData] = useState<{
|
||||
|
|
@ -62,7 +109,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 폼 데이터 상태 (편집 데이터로 초기화됨)
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [originalData, setOriginalData] = useState<Record<string, any>>({});
|
||||
|
||||
|
||||
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
|
||||
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
|
||||
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
|
||||
|
|
@ -115,10 +162,88 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
};
|
||||
};
|
||||
|
||||
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
|
||||
const loadSaveButtonConfig = async (targetScreenId: number): Promise<{
|
||||
enableDataflowControl?: boolean;
|
||||
dataflowConfig?: any;
|
||||
dataflowTiming?: string;
|
||||
} | null> => {
|
||||
try {
|
||||
// 1. 대상 화면의 레이아웃 조회
|
||||
const layoutData = await screenApi.getLayout(targetScreenId);
|
||||
|
||||
if (!layoutData?.components) {
|
||||
console.log("[EditModal] 레이아웃 컴포넌트 없음:", targetScreenId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 저장 버튼 찾기
|
||||
let saveButton = findSaveButtonInComponents(layoutData.components);
|
||||
|
||||
// 3. conditional-container가 있는 경우 내부 화면도 탐색
|
||||
if (!saveButton) {
|
||||
for (const comp of layoutData.components) {
|
||||
if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) {
|
||||
for (const section of comp.componentConfig.sections) {
|
||||
if (section.screenId) {
|
||||
try {
|
||||
const innerLayoutData = await screenApi.getLayout(section.screenId);
|
||||
saveButton = findSaveButtonInComponents(innerLayoutData?.components || []);
|
||||
if (saveButton) {
|
||||
console.log("[EditModal] 조건부 컨테이너 내부에서 저장 버튼 발견:", {
|
||||
sectionScreenId: section.screenId,
|
||||
sectionLabel: section.label,
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (innerError) {
|
||||
console.warn("[EditModal] 내부 화면 레이아웃 조회 실패:", section.screenId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (saveButton) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!saveButton) {
|
||||
console.log("[EditModal] 저장 버튼을 찾을 수 없음:", targetScreenId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. webTypeConfig에서 제어로직 설정 추출
|
||||
const webTypeConfig = saveButton.webTypeConfig;
|
||||
if (webTypeConfig?.enableDataflowControl) {
|
||||
const config = {
|
||||
enableDataflowControl: webTypeConfig.enableDataflowControl,
|
||||
dataflowConfig: webTypeConfig.dataflowConfig,
|
||||
dataflowTiming: webTypeConfig.dataflowConfig?.flowConfig?.executionTiming || "after",
|
||||
};
|
||||
console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config);
|
||||
return config;
|
||||
}
|
||||
|
||||
console.log("[EditModal] 저장 버튼에 제어로직 설정 없음");
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn("[EditModal] 저장 버튼 설정 조회 실패:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 전역 모달 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleOpenEditModal = (event: CustomEvent) => {
|
||||
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } = event.detail;
|
||||
const handleOpenEditModal = async (event: CustomEvent) => {
|
||||
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext } = event.detail;
|
||||
|
||||
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
|
||||
let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined;
|
||||
if (screenId) {
|
||||
const config = await loadSaveButtonConfig(screenId);
|
||||
if (config) {
|
||||
saveButtonConfig = config;
|
||||
}
|
||||
}
|
||||
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
|
|
@ -130,14 +255,17 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
onSave,
|
||||
groupByColumns, // 🆕 그룹핑 컬럼
|
||||
tableName, // 🆕 테이블명
|
||||
buttonConfig, // 🆕 버튼 설정
|
||||
buttonContext, // 🆕 버튼 컨텍스트
|
||||
saveButtonConfig, // 🆕 모달 내부 저장 버튼의 제어로직 설정
|
||||
});
|
||||
|
||||
// 편집 데이터로 폼 데이터 초기화
|
||||
setFormData(editData || {});
|
||||
// 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드)
|
||||
// originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨
|
||||
setOriginalData(isCreateMode ? {} : (editData || {}));
|
||||
|
||||
setOriginalData(isCreateMode ? {} : editData || {});
|
||||
|
||||
if (isCreateMode) {
|
||||
console.log("[EditModal] 생성 모드로 열림, 초기값:", editData);
|
||||
}
|
||||
|
|
@ -170,13 +298,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
useEffect(() => {
|
||||
if (modalState.isOpen && modalState.screenId) {
|
||||
loadScreenData(modalState.screenId);
|
||||
|
||||
|
||||
// 🆕 그룹 데이터 조회 (groupByColumns가 있는 경우)
|
||||
if (modalState.groupByColumns && modalState.groupByColumns.length > 0 && modalState.tableName) {
|
||||
loadGroupData();
|
||||
}
|
||||
}
|
||||
}, [modalState.isOpen, modalState.screenId]);
|
||||
}, [modalState.isOpen, modalState.screenId, modalState.groupByColumns, modalState.tableName]);
|
||||
|
||||
// 🆕 그룹 데이터 조회 함수
|
||||
const loadGroupData = async () => {
|
||||
|
|
@ -225,7 +353,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const dataArray = Array.isArray(response) ? response : response?.data || [];
|
||||
|
||||
if (dataArray.length > 0) {
|
||||
console.log("✅ 그룹 데이터 조회 성공:", dataArray);
|
||||
console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건");
|
||||
setGroupData(dataArray);
|
||||
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
|
||||
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
|
||||
|
|
@ -308,7 +436,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// universal-form-modal 등에서 자체 저장 완료 후 호출된 경우 스킵
|
||||
if (saveData?._saveCompleted) {
|
||||
console.log("[EditModal] 자체 저장 완료된 컴포넌트에서 호출됨 - 저장 스킵");
|
||||
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
if (modalState.onSave) {
|
||||
try {
|
||||
|
|
@ -317,7 +445,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
console.error("onSave 콜백 에러:", callbackError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
|
|
@ -342,13 +470,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 🆕 날짜 필드 정규화 함수 (YYYY-MM-DD 형식으로 변환)
|
||||
const normalizeDateField = (value: any): string | null => {
|
||||
if (!value) return null;
|
||||
|
||||
|
||||
// ISO 8601 형식 (2025-11-26T00:00:00.000Z) 또는 Date 객체
|
||||
if (value instanceof Date || typeof value === "string") {
|
||||
try {
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
|
||||
|
||||
// YYYY-MM-DD 형식으로 변환
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
|
|
@ -359,7 +487,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -380,7 +508,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const insertData: Record<string, any> = { ...currentData };
|
||||
console.log("📦 [신규 품목] 복사 직후 insertData:", insertData);
|
||||
console.log("📋 [신규 품목] insertData 키 목록:", Object.keys(insertData));
|
||||
|
||||
|
||||
delete insertData.id; // id는 자동 생성되므로 제거
|
||||
|
||||
// 🆕 날짜 필드 정규화 (YYYY-MM-DD 형식으로 변환)
|
||||
|
|
@ -464,9 +592,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
for (const currentData of groupData) {
|
||||
if (currentData.id) {
|
||||
// id 기반 매칭 (인덱스 기반 X)
|
||||
const originalItemData = originalGroupData.find(
|
||||
(orig) => orig.id === currentData.id
|
||||
);
|
||||
const originalItemData = originalGroupData.find((orig) => orig.id === currentData.id);
|
||||
|
||||
if (!originalItemData) {
|
||||
console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`);
|
||||
|
|
@ -476,13 +602,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 🆕 값 정규화 함수 (타입 통일)
|
||||
const normalizeValue = (val: any, fieldName?: string): any => {
|
||||
if (val === null || val === undefined || val === "") return null;
|
||||
|
||||
|
||||
// 날짜 필드인 경우 YYYY-MM-DD 형식으로 정규화
|
||||
if (fieldName && dateFields.includes(fieldName)) {
|
||||
const normalizedDate = normalizeDateField(val);
|
||||
return normalizedDate;
|
||||
}
|
||||
|
||||
|
||||
if (typeof val === "string" && !isNaN(Number(val))) {
|
||||
// 숫자로 변환 가능한 문자열은 숫자로
|
||||
return Number(val);
|
||||
|
|
@ -539,9 +665,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
// 3️⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목)
|
||||
const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean));
|
||||
const deletedItems = originalGroupData.filter(
|
||||
(orig) => orig.id && !currentIds.has(orig.id)
|
||||
);
|
||||
const deletedItems = originalGroupData.filter((orig) => orig.id && !currentIds.has(orig.id));
|
||||
|
||||
for (const deletedItem of deletedItems) {
|
||||
console.log("🗑️ 품목 삭제:", deletedItem);
|
||||
|
|
@ -549,7 +673,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
try {
|
||||
const response = await dynamicFormApi.deleteFormDataFromTable(
|
||||
deletedItem.id,
|
||||
screenData.screenInfo.tableName
|
||||
screenData.screenInfo.tableName,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
|
|
@ -581,6 +705,46 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
|
||||
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||
try {
|
||||
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
|
||||
|
||||
console.log("[EditModal] 그룹 저장 완료 후 제어로직 실행 시도", {
|
||||
hasSaveButtonConfig: !!modalState.saveButtonConfig,
|
||||
hasButtonConfig: !!modalState.buttonConfig,
|
||||
controlConfig,
|
||||
});
|
||||
|
||||
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
|
||||
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||
|
||||
// buttonActions의 executeAfterSaveControl 동적 import
|
||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||
|
||||
// 제어로직 실행
|
||||
await ButtonActionExecutor.executeAfterSaveControl(
|
||||
controlConfig,
|
||||
{
|
||||
formData: modalState.editData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
}
|
||||
);
|
||||
|
||||
console.log("✅ [EditModal] 제어로직 실행 완료");
|
||||
} else {
|
||||
console.log("ℹ️ [EditModal] 저장 후 실행할 제어로직 없음");
|
||||
}
|
||||
} catch (controlError) {
|
||||
console.error("❌ [EditModal] 제어로직 실행 오류:", controlError);
|
||||
// 제어로직 오류는 저장 성공을 방해하지 않음 (경고만 표시)
|
||||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} else {
|
||||
toast.info("변경된 내용이 없습니다.");
|
||||
|
|
@ -592,11 +756,11 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
// originalData가 비어있으면 INSERT, 있으면 UPDATE
|
||||
const isCreateMode = Object.keys(originalData).length === 0;
|
||||
|
||||
|
||||
if (isCreateMode) {
|
||||
// INSERT 모드
|
||||
console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData);
|
||||
|
||||
|
||||
const response = await dynamicFormApi.saveFormData({
|
||||
screenId: modalState.screenId!,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
|
|
@ -615,6 +779,37 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
|
||||
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||
try {
|
||||
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
|
||||
|
||||
console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig });
|
||||
|
||||
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
|
||||
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||
|
||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||
|
||||
await ButtonActionExecutor.executeAfterSaveControl(
|
||||
controlConfig,
|
||||
{
|
||||
formData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
}
|
||||
);
|
||||
|
||||
console.log("✅ [EditModal] 제어로직 실행 완료");
|
||||
}
|
||||
} catch (controlError) {
|
||||
console.error("❌ [EditModal] 제어로직 실행 오류:", controlError);
|
||||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} else {
|
||||
throw new Error(response.message || "생성에 실패했습니다.");
|
||||
|
|
@ -657,6 +852,37 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
|
||||
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||
try {
|
||||
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
|
||||
|
||||
console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig });
|
||||
|
||||
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
|
||||
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||
|
||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||
|
||||
await ButtonActionExecutor.executeAfterSaveControl(
|
||||
controlConfig,
|
||||
{
|
||||
formData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
}
|
||||
);
|
||||
|
||||
console.log("✅ [EditModal] 제어로직 실행 완료");
|
||||
}
|
||||
} catch (controlError) {
|
||||
console.error("❌ [EditModal] 제어로직 실행 오류:", controlError);
|
||||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} else {
|
||||
throw new Error(response.message || "수정에 실패했습니다.");
|
||||
|
|
@ -701,10 +927,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
return (
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none`}
|
||||
style={modalStyle.style}
|
||||
>
|
||||
<DialogContent className={`${modalStyle.className} ${className || ""} max-w-none`} style={modalStyle.style}>
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
|
||||
|
|
@ -717,7 +940,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -751,15 +974,20 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
},
|
||||
};
|
||||
|
||||
// 🔍 디버깅: 컴포넌트 렌더링 시점의 groupData 확인
|
||||
if (component.id === screenData.components[0]?.id) {
|
||||
console.log("🔍 [EditModal] InteractiveScreenViewerDynamic props:", {
|
||||
componentId: component.id,
|
||||
groupDataLength: groupData.length,
|
||||
groupData: groupData,
|
||||
formData: groupData.length > 0 ? groupData[0] : formData,
|
||||
});
|
||||
}
|
||||
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 추가
|
||||
const enrichedFormData = {
|
||||
|
|
@ -767,7 +995,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
tableName: screenData.screenInfo?.tableName, // 테이블명 추가
|
||||
screenId: modalState.screenId, // 화면 ID 추가
|
||||
};
|
||||
|
||||
|
||||
// 🔍 디버깅: enrichedFormData 확인
|
||||
console.log("🔑 [EditModal] enrichedFormData 생성:", {
|
||||
"screenData.screenInfo": screenData.screenInfo,
|
||||
|
|
@ -782,6 +1010,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={enrichedFormData}
|
||||
originalData={originalData} // 🆕 원본 데이터 전달 (수정 모드에서 UniversalFormModal 초기화용)
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
// 🆕 그룹 데이터가 있으면 처리
|
||||
if (groupData.length > 0) {
|
||||
|
|
@ -794,24 +1023,26 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
prev.map((item) => ({
|
||||
...item,
|
||||
[fieldName]: value,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
onSave={handleSave}
|
||||
// 🆕 UniversalFormModal이 있으면 onSave 전달 안 함 (자체 저장 로직 사용)
|
||||
// ModalRepeaterTable만 있으면 기존대로 onSave 전달 (호환성 유지)
|
||||
onSave={hasUniversalFormModal ? undefined : handleSave}
|
||||
isInModal={true}
|
||||
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
|
||||
groupedData={groupData.length > 0 ? groupData : undefined}
|
||||
groupedData={groupedDataProp}
|
||||
// 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
|
||||
disabledFields={["order_no", "partner_id"]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -267,7 +267,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
{/* 컨텐츠 */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={autoHeight ? "flex-1" : "flex-1 overflow-auto"}
|
||||
className={autoHeight ? "flex-1 w-full overflow-hidden" : "flex-1 w-full overflow-y-auto overflow-x-hidden"}
|
||||
style={
|
||||
autoHeight
|
||||
? {}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
|||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||
|
||||
|
|
@ -184,6 +185,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
const { user } = useAuth(); // 사용자 정보 가져오기
|
||||
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
|
||||
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||
const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용)
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치
|
||||
|
||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -308,6 +311,41 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
};
|
||||
}, [currentPage, searchValues, loadData, component.tableName]);
|
||||
|
||||
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
|
||||
const [relatedButtonFilter, setRelatedButtonFilter] = useState<{
|
||||
filterColumn: string;
|
||||
filterValue: any;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRelatedButtonSelect = (event: CustomEvent) => {
|
||||
const { targetTable, filterColumn, filterValue } = event.detail || {};
|
||||
|
||||
// 이 테이블이 대상 테이블인지 확인
|
||||
if (targetTable === component.tableName) {
|
||||
console.log("📌 [InteractiveDataTable] RelatedDataButtons 필터 적용:", {
|
||||
tableName: component.tableName,
|
||||
filterColumn,
|
||||
filterValue,
|
||||
});
|
||||
setRelatedButtonFilter({ filterColumn, filterValue });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("related-button-select" as any, handleRelatedButtonSelect);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect);
|
||||
};
|
||||
}, [component.tableName]);
|
||||
|
||||
// relatedButtonFilter 변경 시 데이터 다시 로드
|
||||
useEffect(() => {
|
||||
if (relatedButtonFilter) {
|
||||
loadData(1, searchValues);
|
||||
}
|
||||
}, [relatedButtonFilter]);
|
||||
|
||||
// 카테고리 타입 컬럼의 값 매핑 로드
|
||||
useEffect(() => {
|
||||
const loadCategoryMappings = async () => {
|
||||
|
|
@ -702,10 +740,17 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// 🆕 RelatedDataButtons 필터 적용
|
||||
let relatedButtonFilterValues: Record<string, any> = {};
|
||||
if (relatedButtonFilter) {
|
||||
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
|
||||
}
|
||||
|
||||
// 검색 파라미터와 연결 필터 병합
|
||||
const mergedSearchParams = {
|
||||
...searchParams,
|
||||
...linkedFilterValues,
|
||||
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
|
||||
};
|
||||
|
||||
console.log("🔍 데이터 조회 시작:", {
|
||||
|
|
@ -713,6 +758,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
page,
|
||||
pageSize,
|
||||
linkedFilterValues,
|
||||
relatedButtonFilterValues,
|
||||
mergedSearchParams,
|
||||
});
|
||||
|
||||
|
|
@ -819,7 +865,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData], // 🆕 autoFilter, 연결필터 추가
|
||||
[component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData, relatedButtonFilter], // 🆕 autoFilter, 연결필터, RelatedDataButtons 필터 추가
|
||||
);
|
||||
|
||||
// 현재 사용자 정보 로드
|
||||
|
|
@ -947,7 +993,18 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
|
||||
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||
if (isSelected && data[rowIndex]) {
|
||||
splitPanelContext.setSelectedLeftData(data[rowIndex]);
|
||||
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
|
||||
} else if (!isSelected) {
|
||||
splitPanelContext.setSelectedLeftData(null);
|
||||
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
|
||||
}
|
||||
}
|
||||
}, [data, splitPanelContext, splitPanelPosition]);
|
||||
|
||||
// 전체 선택/해제 핸들러
|
||||
const handleSelectAll = useCallback(
|
||||
|
|
@ -2144,12 +2201,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
const mapping = categoryMappings[column.columnName];
|
||||
const categoryData = mapping?.[String(value)];
|
||||
|
||||
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
|
||||
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값만 텍스트로 표시
|
||||
const displayLabel = categoryData?.label || String(value);
|
||||
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
|
||||
const displayColor = categoryData?.color;
|
||||
|
||||
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
|
||||
if (displayColor === "none") {
|
||||
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||||
if (!displayColor || displayColor === "none" || !categoryData) {
|
||||
return <span className="text-sm">{displayLabel}</span>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ import { cn } from "@/lib/utils";
|
|||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||
|
||||
/**
|
||||
* 🔗 연쇄 드롭다운 래퍼 컴포넌트
|
||||
|
|
@ -2101,113 +2103,117 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
: component;
|
||||
|
||||
return (
|
||||
<TableOptionsProvider>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 테이블 옵션 툴바 */}
|
||||
<TableOptionsToolbar />
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className="h-full flex-1" style={{ width: '100%' }}>
|
||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||
{shouldShowLabel && (
|
||||
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{labelText}
|
||||
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
||||
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||
{showValidationPanel && enhancedValidation && (
|
||||
<div className="absolute bottom-4 right-4 z-50">
|
||||
<FormValidationIndicator
|
||||
validationState={enhancedValidation.validationState}
|
||||
saveState={enhancedValidation.saveState}
|
||||
onSave={async () => {
|
||||
const success = await enhancedValidation.saveForm();
|
||||
if (success) {
|
||||
toast.success("데이터가 성공적으로 저장되었습니다!");
|
||||
}
|
||||
}}
|
||||
canSave={enhancedValidation.canSave}
|
||||
compact={true}
|
||||
showDetails={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모달 화면 */}
|
||||
<Dialog open={!!popupScreen} onOpenChange={() => {
|
||||
setPopupScreen(null);
|
||||
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
|
||||
}}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
|
||||
<DialogHeader className="px-6 pt-4 pb-2">
|
||||
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<SplitPanelProvider>
|
||||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 테이블 옵션 툴바 */}
|
||||
<TableOptionsToolbar />
|
||||
|
||||
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
|
||||
{popupLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">화면을 불러오는 중...</div>
|
||||
</div>
|
||||
) : popupLayout.length > 0 ? (
|
||||
<div className="relative bg-background border rounded" style={{
|
||||
width: popupScreenResolution ? `${popupScreenResolution.width}px` : "100%",
|
||||
height: popupScreenResolution ? `${popupScreenResolution.height}px` : "400px",
|
||||
minHeight: "400px",
|
||||
position: "relative",
|
||||
overflow: "hidden"
|
||||
}}>
|
||||
{/* 팝업에서도 실제 위치와 크기로 렌더링 */}
|
||||
{popupLayout.map((popupComponent) => (
|
||||
<div
|
||||
key={popupComponent.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${popupComponent.position.x}px`,
|
||||
top: `${popupComponent.position.y}px`,
|
||||
width: popupComponent.style?.width || `${popupComponent.size.width}px`,
|
||||
height: popupComponent.style?.height || `${popupComponent.size.height}px`,
|
||||
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
|
||||
}}
|
||||
>
|
||||
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
|
||||
<InteractiveScreenViewer
|
||||
component={popupComponent}
|
||||
allComponents={popupLayout}
|
||||
hideLabel={false}
|
||||
screenInfo={popupScreenInfo || undefined}
|
||||
formData={popupFormData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("💾 팝업 formData 업데이트:", {
|
||||
fieldName,
|
||||
value,
|
||||
valueType: typeof value,
|
||||
prevFormData: popupFormData
|
||||
});
|
||||
|
||||
setPopupFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">화면 데이터가 없습니다.</div>
|
||||
</div>
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className="h-full flex-1" style={{ width: '100%' }}>
|
||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||
{shouldShowLabel && (
|
||||
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{labelText}
|
||||
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
||||
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableOptionsProvider>
|
||||
</div>
|
||||
|
||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||
{showValidationPanel && enhancedValidation && (
|
||||
<div className="absolute bottom-4 right-4 z-50">
|
||||
<FormValidationIndicator
|
||||
validationState={enhancedValidation.validationState}
|
||||
saveState={enhancedValidation.saveState}
|
||||
onSave={async () => {
|
||||
const success = await enhancedValidation.saveForm();
|
||||
if (success) {
|
||||
toast.success("데이터가 성공적으로 저장되었습니다!");
|
||||
}
|
||||
}}
|
||||
canSave={enhancedValidation.canSave}
|
||||
compact={true}
|
||||
showDetails={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모달 화면 */}
|
||||
<Dialog open={!!popupScreen} onOpenChange={() => {
|
||||
setPopupScreen(null);
|
||||
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
|
||||
}}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
|
||||
<DialogHeader className="px-6 pt-4 pb-2">
|
||||
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
|
||||
{popupLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">화면을 불러오는 중...</div>
|
||||
</div>
|
||||
) : popupLayout.length > 0 ? (
|
||||
<div className="relative bg-background border rounded" style={{
|
||||
width: popupScreenResolution ? `${popupScreenResolution.width}px` : "100%",
|
||||
height: popupScreenResolution ? `${popupScreenResolution.height}px` : "400px",
|
||||
minHeight: "400px",
|
||||
position: "relative",
|
||||
overflow: "hidden"
|
||||
}}>
|
||||
{/* 팝업에서도 실제 위치와 크기로 렌더링 */}
|
||||
{popupLayout.map((popupComponent) => (
|
||||
<div
|
||||
key={popupComponent.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${popupComponent.position.x}px`,
|
||||
top: `${popupComponent.position.y}px`,
|
||||
width: popupComponent.style?.width || `${popupComponent.size.width}px`,
|
||||
height: popupComponent.style?.height || `${popupComponent.size.height}px`,
|
||||
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
|
||||
}}
|
||||
>
|
||||
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
|
||||
<InteractiveScreenViewer
|
||||
component={popupComponent}
|
||||
allComponents={popupLayout}
|
||||
hideLabel={false}
|
||||
screenInfo={popupScreenInfo || undefined}
|
||||
formData={popupFormData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("💾 팝업 formData 업데이트:", {
|
||||
fieldName,
|
||||
value,
|
||||
valueType: typeof value,
|
||||
prevFormData: popupFormData
|
||||
});
|
||||
|
||||
setPopupFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">화면 데이터가 없습니다.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableOptionsProvider>
|
||||
</ActiveTabProvider>
|
||||
</SplitPanelProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -39,22 +39,25 @@ interface InteractiveScreenViewerProps {
|
|||
id: number;
|
||||
tableName?: string;
|
||||
};
|
||||
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
|
||||
menuObjid?: number; // 메뉴 OBJID (코드 스코프용)
|
||||
onSave?: () => Promise<void>;
|
||||
onRefresh?: () => void;
|
||||
onFlowRefresh?: () => void;
|
||||
// 🆕 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용)
|
||||
// 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용)
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
companyCode?: string;
|
||||
// 🆕 그룹 데이터 (EditModal에서 전달)
|
||||
// 그룹 데이터 (EditModal에서 전달)
|
||||
groupedData?: Record<string, any>[];
|
||||
// 🆕 비활성화할 필드 목록 (EditModal에서 전달)
|
||||
// 비활성화할 필드 목록 (EditModal에서 전달)
|
||||
disabledFields?: string[];
|
||||
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
|
||||
// EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
|
||||
isInModal?: boolean;
|
||||
// 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
||||
// 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
||||
originalData?: Record<string, any> | null;
|
||||
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
|
||||
parentTabId?: string; // 부모 탭 ID
|
||||
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
||||
}
|
||||
|
||||
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
||||
|
|
@ -74,7 +77,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
groupedData,
|
||||
disabledFields = [],
|
||||
isInModal = false,
|
||||
originalData, // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
||||
originalData,
|
||||
parentTabId,
|
||||
parentTabsComponentId,
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { userName: authUserName, user: authUser } = useAuth();
|
||||
|
|
@ -359,43 +364,43 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
component={comp}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
originalData={originalData || undefined} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||
originalData={originalData || undefined}
|
||||
onFormDataChange={handleFormDataChange}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||
userId={user?.userId} // ✅ 사용자 ID 전달
|
||||
userName={user?.userName} // ✅ 사용자 이름 전달
|
||||
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
||||
onSave={onSave} // 🆕 EditModal의 handleSave 콜백 전달
|
||||
allComponents={allComponents} // 🆕 같은 화면의 모든 컴포넌트 전달 (TableList 자동 감지용)
|
||||
menuObjid={menuObjid}
|
||||
userId={user?.userId}
|
||||
userName={user?.userName}
|
||||
companyCode={user?.companyCode}
|
||||
onSave={onSave}
|
||||
allComponents={allComponents}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(selectedRows, selectedData) => {
|
||||
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);
|
||||
console.log("테이블에서 선택된 행 데이터:", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
// 🆕 그룹 데이터 전달 (EditModal → ModalRepeaterTable)
|
||||
groupedData={groupedData}
|
||||
// 🆕 비활성화 필드 전달 (EditModal → 각 컴포넌트)
|
||||
disabledFields={disabledFields}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
onFlowSelectedDataChange={(selectedData, stepId) => {
|
||||
console.log("🔍 플로우에서 선택된 데이터:", { selectedData, stepId });
|
||||
console.log("플로우에서 선택된 데이터:", { selectedData, stepId });
|
||||
setFlowSelectedData(selectedData);
|
||||
setFlowSelectedStepId(stepId);
|
||||
}}
|
||||
onRefresh={
|
||||
onRefresh ||
|
||||
(() => {
|
||||
// 부모로부터 전달받은 onRefresh 또는 기본 동작
|
||||
console.log("🔄 InteractiveScreenViewerDynamic onRefresh 호출");
|
||||
console.log("InteractiveScreenViewerDynamic onRefresh 호출");
|
||||
})
|
||||
}
|
||||
onFlowRefresh={onFlowRefresh}
|
||||
onClose={() => {
|
||||
// buttonActions.ts가 이미 처리함
|
||||
}}
|
||||
// 탭 관련 정보 전달
|
||||
parentTabId={parentTabId}
|
||||
parentTabsComponentId={parentTabsComponentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -584,6 +589,219 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 즉시 저장(quickInsert) 액션 핸들러
|
||||
const handleQuickInsertAction = async () => {
|
||||
// componentConfig에서 quickInsertConfig 가져오기
|
||||
const quickInsertConfig = (comp as any).componentConfig?.action?.quickInsertConfig;
|
||||
|
||||
if (!quickInsertConfig?.targetTable) {
|
||||
toast.error("대상 테이블이 설정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 대상 테이블의 컬럼 목록 조회 (자동 매핑용)
|
||||
let targetTableColumns: string[] = [];
|
||||
try {
|
||||
const { default: apiClient } = await import("@/lib/api/client");
|
||||
const columnsResponse = await apiClient.get(
|
||||
`/table-management/tables/${quickInsertConfig.targetTable}/columns`
|
||||
);
|
||||
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
||||
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
|
||||
targetTableColumns = columnsData.map((col: any) => col.columnName || col.column_name || col.name);
|
||||
console.log("📍 대상 테이블 컬럼 목록:", targetTableColumns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("대상 테이블 컬럼 조회 실패:", error);
|
||||
}
|
||||
|
||||
// 2. 컬럼 매핑에서 값 수집
|
||||
const insertData: Record<string, any> = {};
|
||||
const columnMappings = quickInsertConfig.columnMappings || [];
|
||||
|
||||
for (const mapping of columnMappings) {
|
||||
let value: any;
|
||||
|
||||
switch (mapping.sourceType) {
|
||||
case "component":
|
||||
// 같은 화면의 컴포넌트에서 값 가져오기
|
||||
// 방법1: sourceColumnName 사용
|
||||
if (mapping.sourceColumnName && formData[mapping.sourceColumnName] !== undefined) {
|
||||
value = formData[mapping.sourceColumnName];
|
||||
console.log(`📍 컴포넌트 값 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`);
|
||||
}
|
||||
// 방법2: sourceComponentId로 컴포넌트 찾아서 columnName 사용
|
||||
else if (mapping.sourceComponentId) {
|
||||
const sourceComp = allComponents.find((c: any) => c.id === mapping.sourceComponentId);
|
||||
if (sourceComp) {
|
||||
const fieldName = (sourceComp as any).columnName || sourceComp.id;
|
||||
value = formData[fieldName];
|
||||
console.log(`📍 컴포넌트 값 (컴포넌트 조회): ${fieldName} = ${value}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "leftPanel":
|
||||
// 분할 패널 좌측 선택 데이터에서 값 가져오기
|
||||
if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) {
|
||||
value = splitPanelContext.selectedLeftData[mapping.sourceColumn];
|
||||
}
|
||||
break;
|
||||
|
||||
case "fixed":
|
||||
value = mapping.fixedValue;
|
||||
break;
|
||||
|
||||
case "currentUser":
|
||||
if (mapping.userField) {
|
||||
switch (mapping.userField) {
|
||||
case "userId":
|
||||
value = user?.userId;
|
||||
break;
|
||||
case "userName":
|
||||
value = userName;
|
||||
break;
|
||||
case "companyCode":
|
||||
value = user?.companyCode;
|
||||
break;
|
||||
case "deptCode":
|
||||
value = authUser?.deptCode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
insertData[mapping.targetColumn] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 좌측 패널 선택 데이터에서 자동 매핑 (컬럼명이 같고 대상 테이블에 있는 경우)
|
||||
if (splitPanelContext?.selectedLeftData && targetTableColumns.length > 0) {
|
||||
const leftData = splitPanelContext.selectedLeftData;
|
||||
console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
|
||||
|
||||
for (const [key, val] of Object.entries(leftData)) {
|
||||
// 이미 매핑된 컬럼은 스킵
|
||||
if (insertData[key] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 대상 테이블에 해당 컬럼이 없으면 스킵
|
||||
if (!targetTableColumns.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 시스템 컬럼 제외
|
||||
const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name'];
|
||||
if (systemColumns.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// _label, _name 으로 끝나는 표시용 컬럼 제외
|
||||
if (key.endsWith('_label') || key.endsWith('_name')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 값이 있으면 자동 추가
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
insertData[key] = val;
|
||||
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🚀 quickInsert 최종 데이터:", insertData);
|
||||
|
||||
// 4. 필수값 검증
|
||||
if (Object.keys(insertData).length === 0) {
|
||||
toast.error("저장할 데이터가 없습니다. 값을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 중복 체크 (설정된 경우)
|
||||
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
|
||||
try {
|
||||
const { default: apiClient } = await import("@/lib/api/client");
|
||||
|
||||
// 중복 체크를 위한 검색 조건 구성
|
||||
const searchConditions: Record<string, any> = {};
|
||||
for (const col of quickInsertConfig.duplicateCheck.columns) {
|
||||
if (insertData[col] !== undefined) {
|
||||
searchConditions[col] = { value: insertData[col], operator: "equals" };
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📍 중복 체크 조건:", searchConditions);
|
||||
|
||||
// 기존 데이터 조회
|
||||
const checkResponse = await apiClient.post(
|
||||
`/table-management/tables/${quickInsertConfig.targetTable}/data`,
|
||||
{
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
search: searchConditions,
|
||||
}
|
||||
);
|
||||
|
||||
console.log("📍 중복 체크 응답:", checkResponse.data);
|
||||
|
||||
// data 배열이 있고 길이가 0보다 크면 중복
|
||||
const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || [];
|
||||
if (Array.isArray(existingData) && existingData.length > 0) {
|
||||
toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("중복 체크 오류:", error);
|
||||
// 중복 체크 실패 시 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
// 6. API 호출
|
||||
try {
|
||||
const { default: apiClient } = await import("@/lib/api/client");
|
||||
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
|
||||
insertData
|
||||
);
|
||||
|
||||
if (response.data?.success) {
|
||||
// 7. 성공 후 동작
|
||||
if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) {
|
||||
toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다.");
|
||||
}
|
||||
|
||||
// 데이터 새로고침 (테이블리스트, 카드 디스플레이)
|
||||
if (quickInsertConfig.afterInsert?.refreshData !== false) {
|
||||
console.log("📍 데이터 새로고침 이벤트 발송");
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
window.dispatchEvent(new CustomEvent("refreshCardDisplay"));
|
||||
}
|
||||
}
|
||||
|
||||
// 지정된 컴포넌트 초기화
|
||||
if (quickInsertConfig.afterInsert?.clearComponents?.length > 0) {
|
||||
for (const componentId of quickInsertConfig.afterInsert.clearComponents) {
|
||||
const targetComp = allComponents.find((c: any) => c.id === componentId);
|
||||
if (targetComp) {
|
||||
const fieldName = (targetComp as any).columnName || targetComp.id;
|
||||
onFormDataChange?.(fieldName, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast.error(response.data?.message || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("quickInsert 오류:", error);
|
||||
toast.error(error.response?.data?.message || error.message || "저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
const actionType = config?.actionType || "save";
|
||||
|
|
@ -604,6 +822,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
case "custom":
|
||||
await handleCustomAction();
|
||||
break;
|
||||
case "quickInsert":
|
||||
await handleQuickInsertAction();
|
||||
break;
|
||||
default:
|
||||
// console.log("🔘 기본 버튼 클릭");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -14,6 +14,7 @@ import { FileUpload } from "./widgets/FileUpload";
|
|||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
|
||||
import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate";
|
||||
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||
import {
|
||||
Database,
|
||||
Type,
|
||||
|
|
@ -110,8 +111,8 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
|||
};
|
||||
|
||||
// 동적 웹 타입 위젯 렌더링 컴포넌트
|
||||
const WidgetRenderer: React.FC<{
|
||||
component: ComponentData;
|
||||
const WidgetRenderer: React.FC<{
|
||||
component: ComponentData;
|
||||
isDesignMode?: boolean;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
|
|
@ -253,22 +254,23 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
// 플로우 위젯의 실제 높이 측정
|
||||
useEffect(() => {
|
||||
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
||||
|
||||
const isFlowWidget =
|
||||
type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
||||
|
||||
if (isFlowWidget && contentRef.current) {
|
||||
const measureHeight = () => {
|
||||
if (contentRef.current) {
|
||||
// getBoundingClientRect()로 실제 렌더링된 높이 측정
|
||||
const rect = contentRef.current.getBoundingClientRect();
|
||||
const measured = rect.height;
|
||||
|
||||
|
||||
// scrollHeight도 함께 확인하여 더 큰 값 사용
|
||||
const scrollHeight = contentRef.current.scrollHeight;
|
||||
const rawHeight = Math.max(measured, scrollHeight);
|
||||
|
||||
|
||||
// 40px 단위로 올림
|
||||
const finalHeight = Math.ceil(rawHeight / 40) * 40;
|
||||
|
||||
|
||||
if (finalHeight > 0 && Math.abs(finalHeight - (actualHeight || 0)) > 10) {
|
||||
setActualHeight(finalHeight);
|
||||
}
|
||||
|
|
@ -400,12 +402,118 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
}, [component.id, fileUpdateTrigger]);
|
||||
|
||||
// 컴포넌트 스타일 계산
|
||||
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
||||
const isFlowWidget =
|
||||
type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
||||
const isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper";
|
||||
|
||||
|
||||
const positionX = position?.x || 0;
|
||||
const positionY = position?.y || 0;
|
||||
|
||||
// 🆕 분할 패널 리사이즈 Context
|
||||
const { getAdjustedX, getOverlappingSplitPanel } = useSplitPanel();
|
||||
|
||||
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상)
|
||||
const componentType = (component as any).componentType || "";
|
||||
const componentId = (component as any).componentId || "";
|
||||
const widgetType = (component as any).widgetType || "";
|
||||
|
||||
const isButtonComponent =
|
||||
(type === "widget" && widgetType === "button") ||
|
||||
(type === "component" &&
|
||||
(["button-primary", "button-secondary"].includes(componentType) ||
|
||||
["button-primary", "button-secondary"].includes(componentId)));
|
||||
|
||||
// 디버깅: 모든 컴포넌트의 타입 정보 출력 (버튼 관련만)
|
||||
if (componentType.includes("button") || componentId.includes("button") || widgetType.includes("button")) {
|
||||
console.log("🔘 [RealtimePreview] 버튼 컴포넌트 발견:", {
|
||||
id: component.id,
|
||||
type,
|
||||
componentType,
|
||||
componentId,
|
||||
widgetType,
|
||||
isButtonComponent,
|
||||
positionX,
|
||||
positionY,
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 분할 패널 위 버튼 위치 자동 조정
|
||||
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = useMemo(() => {
|
||||
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
|
||||
const isSplitPanelComponent =
|
||||
type === "component" &&
|
||||
["split-panel-layout", "split-panel-layout2"].includes((component as any).componentType || "");
|
||||
|
||||
if (!isButtonComponent || isSplitPanelComponent) {
|
||||
return { adjustedPositionX: positionX, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
|
||||
const componentWidth = size?.width || 100;
|
||||
const componentHeight = size?.height || 40;
|
||||
|
||||
// 분할 패널 위에 있는지 확인
|
||||
const overlap = getOverlappingSplitPanel(positionX, positionY, componentWidth, componentHeight);
|
||||
|
||||
// 디버깅: 버튼이 분할 패널 위에 있는지 확인
|
||||
if (isButtonComponent) {
|
||||
console.log("🔍 [RealtimePreview] 버튼 분할 패널 감지:", {
|
||||
componentId: component.id,
|
||||
componentType: (component as any).componentType,
|
||||
positionX,
|
||||
positionY,
|
||||
componentWidth,
|
||||
componentHeight,
|
||||
hasOverlap: !!overlap,
|
||||
isInLeftPanel: overlap?.isInLeftPanel,
|
||||
panelInfo: overlap
|
||||
? {
|
||||
panelId: overlap.panelId,
|
||||
panelX: overlap.panel.x,
|
||||
panelY: overlap.panel.y,
|
||||
panelWidth: overlap.panel.width,
|
||||
leftWidthPercent: overlap.panel.leftWidthPercent,
|
||||
initialLeftWidthPercent: overlap.panel.initialLeftWidthPercent,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (!overlap || !overlap.isInLeftPanel) {
|
||||
// 분할 패널 위에 없거나 우측 패널 위에 있음
|
||||
return {
|
||||
adjustedPositionX: positionX,
|
||||
isOnSplitPanel: !!overlap,
|
||||
isDraggingSplitPanel: overlap?.panel.isDragging ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// 좌측 패널 위에 있음 - 위치 조정
|
||||
const adjusted = getAdjustedX(positionX, positionY, componentWidth, componentHeight);
|
||||
|
||||
console.log("✅ [RealtimePreview] 버튼 위치 조정 적용:", {
|
||||
componentId: component.id,
|
||||
originalX: positionX,
|
||||
adjustedX: adjusted,
|
||||
delta: adjusted - positionX,
|
||||
});
|
||||
|
||||
return {
|
||||
adjustedPositionX: adjusted,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: overlap.panel.isDragging,
|
||||
};
|
||||
}, [
|
||||
positionX,
|
||||
positionY,
|
||||
size?.width,
|
||||
size?.height,
|
||||
isButtonComponent,
|
||||
type,
|
||||
component,
|
||||
getAdjustedX,
|
||||
getOverlappingSplitPanel,
|
||||
]);
|
||||
|
||||
// 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
|
||||
const getWidth = () => {
|
||||
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
|
||||
|
|
@ -437,23 +545,27 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
...style, // 먼저 적용하고
|
||||
left: positionX,
|
||||
left: adjustedPositionX, // 🆕 분할 패널 위 버튼은 조정된 X 좌표 사용
|
||||
top: positionY,
|
||||
width: getWidth(), // 우선순위에 따른 너비
|
||||
height: getHeight(), // 우선순위에 따른 높이
|
||||
zIndex: position?.z || 1,
|
||||
// right 속성 강제 제거
|
||||
right: undefined,
|
||||
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
|
||||
transition:
|
||||
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
||||
};
|
||||
|
||||
// 선택된 컴포넌트 스타일
|
||||
// Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거
|
||||
const selectionStyle = isSelected && !isSectionPaper
|
||||
? {
|
||||
outline: "2px solid rgb(59, 130, 246)",
|
||||
outlineOffset: "2px",
|
||||
}
|
||||
: {};
|
||||
const selectionStyle =
|
||||
isSelected && !isSectionPaper
|
||||
? {
|
||||
outline: "2px solid rgb(59, 130, 246)",
|
||||
outlineOffset: "2px",
|
||||
}
|
||||
: {};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// 컴포넌트 영역 내에서만 클릭 이벤트 처리
|
||||
|
|
@ -481,10 +593,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* 컴포넌트 타입별 렌더링 */}
|
||||
<div
|
||||
ref={isFlowWidget ? contentRef : undefined}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<div ref={isFlowWidget ? contentRef : undefined} className="h-full w-full">
|
||||
{/* 영역 타입 */}
|
||||
{type === "area" && renderArea(component, children)}
|
||||
|
||||
|
|
@ -549,16 +658,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
return (
|
||||
<div className="h-auto w-full">
|
||||
<FlowWidget
|
||||
component={flowComponent as any}
|
||||
onSelectedDataChange={onFlowSelectedDataChange}
|
||||
/>
|
||||
<FlowWidget component={flowComponent as any} onSelectedDataChange={onFlowSelectedDataChange} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 탭 컴포넌트 타입 */}
|
||||
{(type === "tabs" || (type === "component" && ((component as any).componentType === "tabs-widget" || (component as any).componentId === "tabs-widget"))) &&
|
||||
{(type === "tabs" ||
|
||||
(type === "component" &&
|
||||
((component as any).componentType === "tabs-widget" ||
|
||||
(component as any).componentId === "tabs-widget"))) &&
|
||||
(() => {
|
||||
console.log("🎯 탭 컴포넌트 조건 충족:", {
|
||||
type,
|
||||
|
|
@ -590,9 +699,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
<Badge key={tab.id} variant="outline" className="text-xs">
|
||||
{tab.label || `탭 ${index + 1}`}
|
||||
{tab.screenName && (
|
||||
<span className="ml-1 text-[10px] text-gray-400">
|
||||
({tab.screenName})
|
||||
</span>
|
||||
<span className="ml-1 text-[10px] text-gray-400">({tab.screenName})</span>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
|
|
@ -632,28 +739,29 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
)}
|
||||
|
||||
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
|
||||
{type === "component" && (() => {
|
||||
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
|
||||
return (
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
isDesignMode={isDesignMode}
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...restProps}
|
||||
>
|
||||
{children}
|
||||
</DynamicComponentRenderer>
|
||||
);
|
||||
})()}
|
||||
{type === "component" &&
|
||||
(() => {
|
||||
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
|
||||
return (
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
isDesignMode={isDesignMode}
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...restProps}
|
||||
>
|
||||
{children}
|
||||
</DynamicComponentRenderer>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||
{type === "widget" && !isFileComponent(component) && (
|
||||
<div className="h-full w-full">
|
||||
<WidgetRenderer
|
||||
component={component}
|
||||
<WidgetRenderer
|
||||
component={component}
|
||||
isDesignMode={isDesignMode}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import {
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Building,
|
||||
File,
|
||||
} from "lucide-react";
|
||||
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||
|
||||
// 컴포넌트 렌더러들 자동 등록
|
||||
import "@/lib/registry/components";
|
||||
|
|
@ -60,7 +61,7 @@ interface RealtimePreviewProps {
|
|||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
columnOrder?: string[];
|
||||
|
||||
|
||||
// 🆕 조건부 컨테이너 높이 변화 콜백
|
||||
onHeightChange?: (componentId: string, newHeight: number) => void;
|
||||
}
|
||||
|
|
@ -262,14 +263,145 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
}
|
||||
: component;
|
||||
|
||||
// 🆕 분할 패널 리사이즈 Context
|
||||
const splitPanelContext = useSplitPanel();
|
||||
|
||||
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상)
|
||||
const componentType = (component as any).componentType || "";
|
||||
const componentId = (component as any).componentId || "";
|
||||
const widgetType = (component as any).widgetType || "";
|
||||
|
||||
const isButtonComponent =
|
||||
(type === "widget" && widgetType === "button") ||
|
||||
(type === "component" &&
|
||||
(["button-primary", "button-secondary"].includes(componentType) ||
|
||||
["button-primary", "button-secondary"].includes(componentId)));
|
||||
|
||||
// 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점)
|
||||
const initialPanelRatioRef = React.useRef<number | null>(null);
|
||||
const initialPanelIdRef = React.useRef<string | null>(null);
|
||||
// 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지)
|
||||
const isInLeftPanelRef = React.useRef<boolean | null>(null);
|
||||
|
||||
// 🆕 분할 패널 위 버튼 위치 자동 조정
|
||||
const calculateButtonPosition = () => {
|
||||
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
|
||||
const isSplitPanelComponent =
|
||||
type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType);
|
||||
|
||||
if (!isButtonComponent || isSplitPanelComponent) {
|
||||
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
|
||||
const componentWidth = size?.width || 100;
|
||||
const componentHeight = size?.height || 40;
|
||||
|
||||
// 분할 패널 위에 있는지 확인 (원래 위치 기준)
|
||||
const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight);
|
||||
|
||||
// 분할 패널 위에 없으면 기준점 초기화
|
||||
if (!overlap) {
|
||||
if (initialPanelIdRef.current !== null) {
|
||||
initialPanelRatioRef.current = null;
|
||||
initialPanelIdRef.current = null;
|
||||
isInLeftPanelRef.current = null;
|
||||
}
|
||||
return {
|
||||
adjustedPositionX: position.x,
|
||||
isOnSplitPanel: false,
|
||||
isDraggingSplitPanel: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { panel } = overlap;
|
||||
|
||||
// 🆕 초기 기준 비율 및 좌측 패널 소속 여부 설정 (처음 한 번만)
|
||||
if (initialPanelIdRef.current !== overlap.panelId) {
|
||||
initialPanelRatioRef.current = panel.leftWidthPercent;
|
||||
initialPanelIdRef.current = overlap.panelId;
|
||||
|
||||
// 초기 배치 시 좌측 패널에 있는지 확인 (초기 비율 기준으로 계산)
|
||||
// 현재 비율이 아닌, 버튼 원래 위치가 초기 좌측 패널 영역 안에 있었는지 판단
|
||||
const initialLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
||||
const componentCenterX = position.x + componentWidth / 2;
|
||||
const relativeX = componentCenterX - panel.x;
|
||||
const wasInLeftPanel = relativeX < initialLeftPanelWidth;
|
||||
|
||||
isInLeftPanelRef.current = wasInLeftPanel;
|
||||
console.log("📌 [버튼 기준점 설정]:", {
|
||||
componentId: component.id,
|
||||
panelId: overlap.panelId,
|
||||
initialRatio: panel.leftWidthPercent,
|
||||
isInLeftPanel: wasInLeftPanel,
|
||||
buttonCenterX: componentCenterX,
|
||||
leftPanelWidth: initialLeftPanelWidth,
|
||||
});
|
||||
}
|
||||
|
||||
// 좌측 패널 소속이 아니면 조정하지 않음 (초기 배치 기준)
|
||||
if (!isInLeftPanelRef.current) {
|
||||
return {
|
||||
adjustedPositionX: position.x,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: panel.isDragging,
|
||||
};
|
||||
}
|
||||
|
||||
// 초기 기준 비율 (버튼이 처음 배치될 때의 비율)
|
||||
const baseRatio = initialPanelRatioRef.current ?? panel.leftWidthPercent;
|
||||
|
||||
// 기준 비율 대비 현재 비율로 분할선 위치 계산
|
||||
const baseDividerX = panel.x + (panel.width * baseRatio) / 100; // 초기 분할선 위치
|
||||
const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100; // 현재 분할선 위치
|
||||
|
||||
// 분할선 이동량 (px)
|
||||
const dividerDelta = currentDividerX - baseDividerX;
|
||||
|
||||
// 변화가 없으면 원래 위치 반환
|
||||
if (Math.abs(dividerDelta) < 1) {
|
||||
return {
|
||||
adjustedPositionX: position.x,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: panel.isDragging,
|
||||
};
|
||||
}
|
||||
|
||||
// 🆕 버튼도 분할선과 같은 양만큼 이동
|
||||
// 분할선이 왼쪽으로 100px 이동하면, 버튼도 왼쪽으로 100px 이동
|
||||
const adjustedX = position.x + dividerDelta;
|
||||
|
||||
console.log("📍 [버튼 위치 조정]:", {
|
||||
componentId: component.id,
|
||||
originalX: position.x,
|
||||
adjustedX,
|
||||
dividerDelta,
|
||||
baseRatio,
|
||||
currentRatio: panel.leftWidthPercent,
|
||||
baseDividerX,
|
||||
currentDividerX,
|
||||
isDragging: panel.isDragging,
|
||||
});
|
||||
|
||||
return {
|
||||
adjustedPositionX: adjustedX,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: panel.isDragging,
|
||||
};
|
||||
};
|
||||
|
||||
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateButtonPosition();
|
||||
|
||||
const baseStyle = {
|
||||
left: `${position.x}px`,
|
||||
left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용
|
||||
top: `${position.y}px`,
|
||||
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
|
||||
width: getWidth(), // getWidth() 우선 (table-list 등 특수 케이스)
|
||||
height: getHeight(), // getHeight() 우선 (flow-widget 등 특수 케이스)
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||
right: undefined,
|
||||
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
|
||||
transition:
|
||||
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
||||
};
|
||||
|
||||
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
|
||||
|
|
|
|||
|
|
@ -101,6 +101,46 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
};
|
||||
}, [onClose]);
|
||||
|
||||
// 필수 항목 검증
|
||||
const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => {
|
||||
const missingFields: string[] = [];
|
||||
|
||||
components.forEach((component) => {
|
||||
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
|
||||
const isRequired =
|
||||
component.required === true ||
|
||||
component.style?.required === true ||
|
||||
component.componentConfig?.required === true;
|
||||
|
||||
const columnName = component.columnName || component.style?.columnName;
|
||||
const label = component.label || component.style?.label || columnName;
|
||||
|
||||
console.log("🔍 필수 항목 검증:", {
|
||||
componentId: component.id,
|
||||
columnName,
|
||||
label,
|
||||
isRequired,
|
||||
"component.required": component.required,
|
||||
"style.required": component.style?.required,
|
||||
"componentConfig.required": component.componentConfig?.required,
|
||||
value: formData[columnName || ""],
|
||||
});
|
||||
|
||||
if (isRequired && columnName) {
|
||||
const value = formData[columnName];
|
||||
// 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열)
|
||||
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
|
||||
missingFields.push(label || columnName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: missingFields.length === 0,
|
||||
missingFields,
|
||||
};
|
||||
};
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!screenData || !screenId) return;
|
||||
|
|
@ -111,6 +151,13 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// ✅ 필수 항목 검증
|
||||
const validation = validateRequiredFields();
|
||||
if (!validation.isValid) {
|
||||
toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -958,6 +958,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
|
||||
codeCategory: col.codeCategory || col.code_category,
|
||||
codeValue: col.codeValue || col.code_value,
|
||||
// 엔티티 타입용 참조 테이블 정보
|
||||
referenceTable: col.referenceTable || col.reference_table,
|
||||
referenceColumn: col.referenceColumn || col.reference_column,
|
||||
displayColumn: col.displayColumn || col.display_column,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||
|
||||
interface SplitPanelAwareWrapperProps {
|
||||
children: React.ReactNode;
|
||||
componentX: number;
|
||||
componentY: number;
|
||||
componentWidth: number;
|
||||
componentHeight: number;
|
||||
componentType?: string;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 드래그 리사이즈에 따라 컴포넌트 위치를 자동 조정하는 래퍼
|
||||
*
|
||||
* 동작 방식:
|
||||
* 1. 컴포넌트가 분할 패널의 좌측 영역 위에 있는지 감지
|
||||
* 2. 좌측 영역 위에 있으면, 드래그 핸들 이동량만큼 X 좌표를 조정
|
||||
* 3. 우측 영역이나 분할 패널 외부에 있으면 원래 위치 유지
|
||||
*/
|
||||
export const SplitPanelAwareWrapper: React.FC<SplitPanelAwareWrapperProps> = ({
|
||||
children,
|
||||
componentX,
|
||||
componentY,
|
||||
componentWidth,
|
||||
componentHeight,
|
||||
componentType,
|
||||
style,
|
||||
className,
|
||||
}) => {
|
||||
const { getAdjustedX, getOverlappingSplitPanel } = useSplitPanel();
|
||||
|
||||
// 분할 패널 위에 있는지 확인 및 조정된 X 좌표 계산
|
||||
const { adjustedX, isInLeftPanel, isDragging } = useMemo(() => {
|
||||
const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
|
||||
|
||||
if (!overlap) {
|
||||
// 분할 패널 위에 없음
|
||||
return { adjustedX: componentX, isInLeftPanel: false, isDragging: false };
|
||||
}
|
||||
|
||||
if (!overlap.isInLeftPanel) {
|
||||
// 우측 패널 위에 있음 - 원래 위치 유지
|
||||
return { adjustedX: componentX, isInLeftPanel: false, isDragging: overlap.panel.isDragging };
|
||||
}
|
||||
|
||||
// 좌측 패널 위에 있음 - 위치 조정
|
||||
const adjusted = getAdjustedX(componentX, componentY, componentWidth, componentHeight);
|
||||
|
||||
return {
|
||||
adjustedX: adjusted,
|
||||
isInLeftPanel: true,
|
||||
isDragging: overlap.panel.isDragging,
|
||||
};
|
||||
}, [componentX, componentY, componentWidth, componentHeight, getAdjustedX, getOverlappingSplitPanel]);
|
||||
|
||||
// 조정된 스타일
|
||||
const adjustedStyle: React.CSSProperties = {
|
||||
...style,
|
||||
position: "absolute",
|
||||
left: `${adjustedX}px`,
|
||||
top: `${componentY}px`,
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
// 드래그 중에는 트랜지션 없이 즉시 이동, 드래그 끝나면 부드럽게
|
||||
transition: isDragging ? "none" : "left 0.1s ease-out",
|
||||
};
|
||||
|
||||
// 디버그 로깅 (개발 중에만)
|
||||
// if (isInLeftPanel) {
|
||||
// console.log(`📍 [SplitPanelAwareWrapper] 위치 조정:`, {
|
||||
// componentType,
|
||||
// originalX: componentX,
|
||||
// adjustedX,
|
||||
// delta: adjustedX - componentX,
|
||||
// isInLeftPanel,
|
||||
// isDragging,
|
||||
// });
|
||||
// }
|
||||
|
||||
return (
|
||||
<div style={adjustedStyle} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SplitPanelAwareWrapper;
|
||||
|
|
@ -16,6 +16,7 @@ import { apiClient } from "@/lib/api/client";
|
|||
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
|
||||
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
||||
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
||||
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
|
||||
|
||||
// 🆕 제목 블록 타입
|
||||
interface TitleBlock {
|
||||
|
|
@ -333,22 +334,72 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
const loadModalMappingColumns = async () => {
|
||||
// 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지
|
||||
// allComponents에서 split-panel-layout 또는 table-list 찾기
|
||||
let sourceTableName: string | null = null;
|
||||
|
||||
console.log("[openModalWithData] 컬럼 로드 시작:", {
|
||||
allComponentsCount: allComponents.length,
|
||||
currentTableName,
|
||||
targetScreenId: config.action?.targetScreenId,
|
||||
});
|
||||
|
||||
// 모든 컴포넌트 타입 로그
|
||||
allComponents.forEach((comp, idx) => {
|
||||
const compType = comp.componentType || (comp as any).componentConfig?.type;
|
||||
console.log(` [${idx}] componentType: ${compType}, tableName: ${(comp as any).componentConfig?.tableName || (comp as any).componentConfig?.leftPanel?.tableName || 'N/A'}`);
|
||||
});
|
||||
|
||||
for (const comp of allComponents) {
|
||||
const compType = comp.componentType || (comp as any).componentConfig?.type;
|
||||
const compConfig = (comp as any).componentConfig || {};
|
||||
|
||||
// 분할 패널 타입들 (다양한 경로에서 테이블명 추출)
|
||||
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
|
||||
// 분할 패널의 좌측 테이블명
|
||||
sourceTableName = (comp as any).componentConfig?.leftPanel?.tableName ||
|
||||
(comp as any).componentConfig?.leftTableName;
|
||||
break;
|
||||
sourceTableName = compConfig?.leftPanel?.tableName ||
|
||||
compConfig?.leftTableName ||
|
||||
compConfig?.tableName;
|
||||
if (sourceTableName) {
|
||||
console.log(`✅ [openModalWithData] split-panel-layout에서 소스 테이블 감지: ${sourceTableName}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// split-panel-layout2 타입 (새로운 분할 패널)
|
||||
if (compType === "split-panel-layout2") {
|
||||
sourceTableName = compConfig?.leftPanel?.tableName ||
|
||||
compConfig?.tableName ||
|
||||
compConfig?.leftTableName;
|
||||
if (sourceTableName) {
|
||||
console.log(`✅ [openModalWithData] split-panel-layout2에서 소스 테이블 감지: ${sourceTableName}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 리스트 타입
|
||||
if (compType === "table-list") {
|
||||
sourceTableName = (comp as any).componentConfig?.tableName;
|
||||
sourceTableName = compConfig?.tableName;
|
||||
if (sourceTableName) {
|
||||
console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 모든 컴포넌트에서 tableName 찾기 (폴백)
|
||||
if (!sourceTableName && compConfig?.tableName) {
|
||||
sourceTableName = compConfig.tableName;
|
||||
console.log(`✅ [openModalWithData] ${compType}에서 소스 테이블 감지 (폴백): ${sourceTableName}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 여전히 없으면 currentTableName 사용 (화면 레벨 테이블명)
|
||||
if (!sourceTableName && currentTableName) {
|
||||
sourceTableName = currentTableName;
|
||||
console.log(`✅ [openModalWithData] currentTableName에서 소스 테이블 사용: ${sourceTableName}`);
|
||||
}
|
||||
|
||||
if (!sourceTableName) {
|
||||
console.warn("[openModalWithData] 소스 테이블을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 소스 테이블 컬럼 로드
|
||||
if (sourceTableName) {
|
||||
|
|
@ -361,11 +412,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
name: col.name || col.columnName || col.column_name,
|
||||
label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name,
|
||||
}));
|
||||
setModalSourceColumns(columns);
|
||||
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드:`, columns.length);
|
||||
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드 완료:`, columns.length);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -379,8 +430,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
try {
|
||||
// 타겟 화면 정보 가져오기
|
||||
const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`);
|
||||
console.log("[openModalWithData] 타겟 화면 응답:", screenResponse.data);
|
||||
|
||||
if (screenResponse.data.success && screenResponse.data.data) {
|
||||
const targetTableName = screenResponse.data.data.tableName;
|
||||
console.log("[openModalWithData] 타겟 화면 테이블명:", targetTableName);
|
||||
|
||||
if (targetTableName) {
|
||||
const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`);
|
||||
if (columnResponse.data.success) {
|
||||
|
|
@ -390,23 +445,27 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
name: col.name || col.columnName || col.column_name,
|
||||
label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name,
|
||||
}));
|
||||
setModalTargetColumns(columns);
|
||||
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드:`, columns.length);
|
||||
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드 완료:`, columns.length);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("[openModalWithData] 타겟 화면에 테이블명이 없습니다.");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("타겟 화면 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
} else {
|
||||
console.warn("[openModalWithData] 타겟 화면 ID가 없습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
loadModalMappingColumns();
|
||||
}, [config.action?.type, config.action?.targetScreenId, allComponents]);
|
||||
}, [config.action?.type, config.action?.targetScreenId, allComponents, currentTableName]);
|
||||
|
||||
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
|
||||
useEffect(() => {
|
||||
|
|
@ -584,9 +643,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="edit">편집</SelectItem>
|
||||
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||
<SelectItem value="transferData">📦 데이터 전달</SelectItem>
|
||||
<SelectItem value="openModalWithData">데이터 전달 + 모달 열기 🆕</SelectItem>
|
||||
<SelectItem value="transferData">데이터 전달</SelectItem>
|
||||
<SelectItem value="openModalWithData">데이터 전달 + 모달 열기</SelectItem>
|
||||
<SelectItem value="openRelatedModal">연관 데이터 버튼 모달 열기</SelectItem>
|
||||
<SelectItem value="modal">모달 열기</SelectItem>
|
||||
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
||||
<SelectItem value="control">제어 흐름</SelectItem>
|
||||
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
||||
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
||||
|
|
@ -1158,11 +1219,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded-md border bg-background p-2">
|
||||
{/* 소스 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
<div key={index} className="rounded-md border bg-background p-3 space-y-2">
|
||||
{/* 소스 필드 선택 (Combobox) - 세로 배치 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">소스 컬럼</Label>
|
||||
<Popover
|
||||
open={modalSourcePopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
|
|
@ -1171,15 +1233,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{mapping.sourceField
|
||||
? modalSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
|
||||
: "소스 컬럼 선택"}
|
||||
<span className="truncate">
|
||||
{mapping.sourceField
|
||||
? modalSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
|
||||
: "소스 컬럼 선택"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
|
|
@ -1187,7 +1251,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
value={modalSourceSearch[index] || ""}
|
||||
onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalSourceColumns.map((col) => (
|
||||
|
|
@ -1208,9 +1272,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
mapping.sourceField === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
<span className="truncate">{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="ml-1 text-muted-foreground">({col.name})</span>
|
||||
<span className="ml-1 text-muted-foreground truncate">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
|
@ -1221,10 +1285,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</Popover>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
{/* 화살표 표시 */}
|
||||
<div className="flex justify-center">
|
||||
<span className="text-xs text-muted-foreground">↓</span>
|
||||
</div>
|
||||
|
||||
{/* 타겟 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
{/* 타겟 필드 선택 (Combobox) - 세로 배치 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">타겟 컬럼</Label>
|
||||
<Popover
|
||||
open={modalTargetPopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
|
|
@ -1233,15 +1301,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{mapping.targetField
|
||||
? modalTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
|
||||
: "타겟 컬럼 선택"}
|
||||
<span className="truncate">
|
||||
{mapping.targetField
|
||||
? modalTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
|
||||
: "타겟 컬럼 선택"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
|
|
@ -1249,7 +1319,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
value={modalTargetSearch[index] || ""}
|
||||
onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalTargetColumns.map((col) => (
|
||||
|
|
@ -1270,9 +1340,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
mapping.targetField === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
<span className="truncate">{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="ml-1 text-muted-foreground">({col.name})</span>
|
||||
<span className="ml-1 text-muted-foreground truncate">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
|
@ -1284,19 +1354,22 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
const mappings = [...(config.action?.fieldMappings || [])];
|
||||
mappings.splice(index, 1);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
const mappings = [...(config.action?.fieldMappings || [])];
|
||||
mappings.splice(index, 1);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -2998,6 +3071,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 🆕 즉시 저장(quickInsert) 액션 설정 */}
|
||||
{component.componentConfig?.action?.type === "quickInsert" && (
|
||||
<QuickInsertConfigSection
|
||||
component={component}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
allComponents={allComponents}
|
||||
currentTableName={currentTableName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 제어 기능 섹션 */}
|
||||
<div className="mt-8 border-t border-border pt-6">
|
||||
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||
|
|
|
|||
|
|
@ -6,18 +6,10 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Database, Search, Plus, Trash2 } from "lucide-react";
|
||||
import { Database, Search, Info } from "lucide-react";
|
||||
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, EntityTypeConfig } from "@/types/screen";
|
||||
|
||||
interface EntityField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
visible: boolean;
|
||||
}
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||
component,
|
||||
|
|
@ -27,16 +19,31 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
const widget = component as WidgetComponent;
|
||||
const config = (widget.webTypeConfig as EntityTypeConfig) || {};
|
||||
|
||||
// 로컬 상태
|
||||
// 테이블 타입 관리에서 설정된 참조 테이블 정보
|
||||
const [referenceInfo, setReferenceInfo] = useState<{
|
||||
referenceTable: string;
|
||||
referenceColumn: string;
|
||||
displayColumn: string;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}>({
|
||||
referenceTable: "",
|
||||
referenceColumn: "",
|
||||
displayColumn: "",
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 로컬 상태 (UI 관련 설정만)
|
||||
const [localConfig, setLocalConfig] = useState<EntityTypeConfig>({
|
||||
entityType: config.entityType || "",
|
||||
displayFields: config.displayFields || [],
|
||||
searchFields: config.searchFields || [],
|
||||
valueField: config.valueField || "id",
|
||||
labelField: config.labelField || "name",
|
||||
valueField: config.valueField || "",
|
||||
labelField: config.labelField || "",
|
||||
multiple: config.multiple || false,
|
||||
searchable: config.searchable !== false, // 기본값 true
|
||||
placeholder: config.placeholder || "엔티티를 선택하세요",
|
||||
searchable: config.searchable !== false,
|
||||
placeholder: config.placeholder || "항목을 선택하세요",
|
||||
emptyMessage: config.emptyMessage || "검색 결과가 없습니다",
|
||||
pageSize: config.pageSize || 20,
|
||||
minSearchLength: config.minSearchLength || 1,
|
||||
|
|
@ -47,10 +54,95 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
filters: config.filters || {},
|
||||
});
|
||||
|
||||
// 새 필드 추가용 상태
|
||||
const [newFieldName, setNewFieldName] = useState("");
|
||||
const [newFieldLabel, setNewFieldLabel] = useState("");
|
||||
const [newFieldType, setNewFieldType] = useState("string");
|
||||
// 테이블 타입 관리에서 설정된 참조 테이블 정보 로드
|
||||
useEffect(() => {
|
||||
const loadReferenceInfo = async () => {
|
||||
// 컴포넌트의 테이블명과 컬럼명이 있는 경우에만 조회
|
||||
const tableName = widget.tableName;
|
||||
const columnName = widget.columnName;
|
||||
|
||||
if (!tableName || !columnName) {
|
||||
setReferenceInfo({
|
||||
referenceTable: "",
|
||||
referenceColumn: "",
|
||||
displayColumn: "",
|
||||
isLoading: false,
|
||||
error: "테이블 또는 컬럼 정보가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 테이블 타입 관리에서 컬럼 정보 조회
|
||||
const columns = await tableTypeApi.getColumns(tableName);
|
||||
const columnInfo = columns.find((col: any) =>
|
||||
(col.columnName || col.column_name) === columnName
|
||||
);
|
||||
|
||||
if (columnInfo) {
|
||||
const refTable = columnInfo.referenceTable || columnInfo.reference_table || "";
|
||||
const refColumn = columnInfo.referenceColumn || columnInfo.reference_column || "";
|
||||
const dispColumn = columnInfo.displayColumn || columnInfo.display_column || "";
|
||||
|
||||
// detailSettings에서도 정보 확인 (JSON 파싱)
|
||||
let detailSettings: any = {};
|
||||
if (columnInfo.detailSettings) {
|
||||
try {
|
||||
if (typeof columnInfo.detailSettings === 'string') {
|
||||
detailSettings = JSON.parse(columnInfo.detailSettings);
|
||||
} else {
|
||||
detailSettings = columnInfo.detailSettings;
|
||||
}
|
||||
} catch {
|
||||
// JSON 파싱 실패 시 무시
|
||||
}
|
||||
}
|
||||
|
||||
const finalRefTable = refTable || detailSettings.referenceTable || "";
|
||||
const finalRefColumn = refColumn || detailSettings.referenceColumn || "";
|
||||
const finalDispColumn = dispColumn || detailSettings.displayColumn || "";
|
||||
|
||||
setReferenceInfo({
|
||||
referenceTable: finalRefTable,
|
||||
referenceColumn: finalRefColumn,
|
||||
displayColumn: finalDispColumn,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// webTypeConfig에 참조 테이블 정보 자동 설정
|
||||
if (finalRefTable) {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
valueField: finalRefColumn || "id",
|
||||
labelField: finalDispColumn || "name",
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
}
|
||||
} else {
|
||||
setReferenceInfo({
|
||||
referenceTable: "",
|
||||
referenceColumn: "",
|
||||
displayColumn: "",
|
||||
isLoading: false,
|
||||
error: "컬럼 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("참조 테이블 정보 로드 실패:", error);
|
||||
setReferenceInfo({
|
||||
referenceTable: "",
|
||||
referenceColumn: "",
|
||||
displayColumn: "",
|
||||
isLoading: false,
|
||||
error: "참조 테이블 정보 로드 실패",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadReferenceInfo();
|
||||
}, [widget.tableName, widget.columnName]);
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
|
|
@ -59,11 +151,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
entityType: currentConfig.entityType || "",
|
||||
displayFields: currentConfig.displayFields || [],
|
||||
searchFields: currentConfig.searchFields || [],
|
||||
valueField: currentConfig.valueField || "id",
|
||||
labelField: currentConfig.labelField || "name",
|
||||
valueField: currentConfig.valueField || referenceInfo.referenceColumn || "",
|
||||
labelField: currentConfig.labelField || referenceInfo.displayColumn || "",
|
||||
multiple: currentConfig.multiple || false,
|
||||
searchable: currentConfig.searchable !== false,
|
||||
placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
|
||||
placeholder: currentConfig.placeholder || "항목을 선택하세요",
|
||||
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
|
||||
pageSize: currentConfig.pageSize || 20,
|
||||
minSearchLength: currentConfig.minSearchLength || 1,
|
||||
|
|
@ -73,7 +165,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
apiEndpoint: currentConfig.apiEndpoint || "",
|
||||
filters: currentConfig.filters || {},
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
}, [widget.webTypeConfig, referenceInfo.referenceColumn, referenceInfo.displayColumn]);
|
||||
|
||||
// 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등)
|
||||
const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
|
||||
|
|
@ -92,89 +184,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onUpdateProperty("webTypeConfig", localConfig);
|
||||
};
|
||||
|
||||
// 필드 추가
|
||||
const addDisplayField = () => {
|
||||
if (!newFieldName.trim() || !newFieldLabel.trim()) return;
|
||||
|
||||
const newField: EntityField = {
|
||||
name: newFieldName.trim(),
|
||||
label: newFieldLabel.trim(),
|
||||
type: newFieldType,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
const newFields = [...localConfig.displayFields, newField];
|
||||
updateConfig("displayFields", newFields);
|
||||
setNewFieldName("");
|
||||
setNewFieldLabel("");
|
||||
setNewFieldType("string");
|
||||
};
|
||||
|
||||
// 필드 제거
|
||||
const removeDisplayField = (index: number) => {
|
||||
const newFields = localConfig.displayFields.filter((_, i) => i !== index);
|
||||
updateConfig("displayFields", newFields);
|
||||
};
|
||||
|
||||
// 필드 업데이트 (입력 중) - 로컬 상태만 업데이트
|
||||
const updateDisplayField = (index: number, field: keyof EntityField, value: any) => {
|
||||
const newFields = [...localConfig.displayFields];
|
||||
newFields[index] = { ...newFields[index], [field]: value };
|
||||
setLocalConfig({ ...localConfig, displayFields: newFields });
|
||||
};
|
||||
|
||||
// 필드 업데이트 완료 (onBlur) - 부모에게 전달
|
||||
const handleFieldBlur = () => {
|
||||
onUpdateProperty("webTypeConfig", localConfig);
|
||||
};
|
||||
|
||||
// 검색 필드 토글
|
||||
const toggleSearchField = (fieldName: string) => {
|
||||
const currentSearchFields = localConfig.searchFields || [];
|
||||
const newSearchFields = currentSearchFields.includes(fieldName)
|
||||
? currentSearchFields.filter((f) => f !== fieldName)
|
||||
: [...currentSearchFields, fieldName];
|
||||
updateConfig("searchFields", newSearchFields);
|
||||
};
|
||||
|
||||
// 기본 엔티티 타입들
|
||||
const commonEntityTypes = [
|
||||
{ value: "user", label: "사용자", fields: ["id", "name", "email", "department"] },
|
||||
{ value: "department", label: "부서", fields: ["id", "name", "code", "parentId"] },
|
||||
{ value: "product", label: "제품", fields: ["id", "name", "code", "category", "price"] },
|
||||
{ value: "customer", label: "고객", fields: ["id", "name", "company", "contact"] },
|
||||
{ value: "project", label: "프로젝트", fields: ["id", "name", "status", "manager", "startDate"] },
|
||||
];
|
||||
|
||||
// 기본 엔티티 타입 적용
|
||||
const applyEntityType = (entityType: string) => {
|
||||
const entityConfig = commonEntityTypes.find((e) => e.value === entityType);
|
||||
if (!entityConfig) return;
|
||||
|
||||
updateConfig("entityType", entityType);
|
||||
updateConfig("apiEndpoint", `/api/entities/${entityType}`);
|
||||
|
||||
const defaultFields: EntityField[] = entityConfig.fields.map((field) => ({
|
||||
name: field,
|
||||
label: field.charAt(0).toUpperCase() + field.slice(1),
|
||||
type: field.includes("Date") ? "date" : field.includes("price") || field.includes("Id") ? "number" : "string",
|
||||
visible: true,
|
||||
}));
|
||||
|
||||
updateConfig("displayFields", defaultFields);
|
||||
updateConfig("searchFields", [entityConfig.fields[1] || "name"]); // 두 번째 필드를 기본 검색 필드로
|
||||
};
|
||||
|
||||
// 필드 타입 옵션
|
||||
const fieldTypes = [
|
||||
{ value: "string", label: "문자열" },
|
||||
{ value: "number", label: "숫자" },
|
||||
{ value: "date", label: "날짜" },
|
||||
{ value: "boolean", label: "불린" },
|
||||
{ value: "email", label: "이메일" },
|
||||
{ value: "url", label: "URL" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -182,214 +191,97 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Database className="h-4 w-4" />
|
||||
엔티티 설정
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">데이터베이스 엔티티 선택 필드의 설정을 관리합니다.</CardDescription>
|
||||
<CardDescription className="text-xs">
|
||||
데이터베이스 엔티티 선택 필드의 설정을 관리합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기본 설정 */}
|
||||
{/* 참조 테이블 정보 (테이블 타입 관리에서 설정된 값 - 읽기 전용) */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 설정</h4>
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
참조 테이블 정보
|
||||
<span className="bg-muted text-muted-foreground px-1.5 py-0.5 rounded text-[10px]">
|
||||
테이블 타입 관리에서 설정
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entityType" className="text-xs">
|
||||
엔티티 타입
|
||||
</Label>
|
||||
<Input
|
||||
id="entityType"
|
||||
value={localConfig.entityType || ""}
|
||||
onChange={(e) => updateConfigLocal("entityType", e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder="user, product, department..."
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">기본 엔티티 타입</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{commonEntityTypes.map((entity) => (
|
||||
<Button
|
||||
key={entity.value}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => applyEntityType(entity.value)}
|
||||
className="text-xs"
|
||||
>
|
||||
{entity.label}
|
||||
</Button>
|
||||
))}
|
||||
{referenceInfo.isLoading ? (
|
||||
<div className="bg-muted/50 rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">참조 테이블 정보 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiEndpoint" className="text-xs">
|
||||
API 엔드포인트
|
||||
</Label>
|
||||
<Input
|
||||
id="apiEndpoint"
|
||||
value={localConfig.apiEndpoint || ""}
|
||||
onChange={(e) => updateConfigLocal("apiEndpoint", e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder="/api/entities/user"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">필드 매핑</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="valueField" className="text-xs">
|
||||
값 필드
|
||||
</Label>
|
||||
<Input
|
||||
id="valueField"
|
||||
value={localConfig.valueField || ""}
|
||||
onChange={(e) => updateConfigLocal("valueField", e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder="id"
|
||||
className="text-xs"
|
||||
/>
|
||||
) : referenceInfo.error ? (
|
||||
<div className="bg-destructive/10 rounded-md border border-destructive/20 p-3">
|
||||
<p className="text-xs text-destructive flex items-center gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
{referenceInfo.error}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
테이블 타입 관리에서 이 컬럼의 참조 테이블을 먼저 설정해주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="labelField" className="text-xs">
|
||||
라벨 필드
|
||||
</Label>
|
||||
<Input
|
||||
id="labelField"
|
||||
value={localConfig.labelField || ""}
|
||||
onChange={(e) => updateConfigLocal("labelField", e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder="name"
|
||||
className="text-xs"
|
||||
/>
|
||||
) : !referenceInfo.referenceTable ? (
|
||||
<div className="bg-amber-500/10 rounded-md border border-amber-500/20 p-3">
|
||||
<p className="text-xs text-amber-700 flex items-center gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
참조 테이블이 설정되지 않았습니다.
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
테이블 타입 관리에서 이 컬럼의 참조 테이블을 먼저 설정해주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 표시 필드 관리 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">표시 필드</h4>
|
||||
|
||||
{/* 새 필드 추가 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">필드 추가</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newFieldName}
|
||||
onChange={(e) => setNewFieldName(e.target.value)}
|
||||
placeholder="필드명"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={newFieldLabel}
|
||||
onChange={(e) => setNewFieldLabel(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Select value={newFieldType} onValueChange={setNewFieldType}>
|
||||
<SelectTrigger className="w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={addDisplayField}
|
||||
disabled={!newFieldName.trim() || !newFieldLabel.trim()}
|
||||
className="text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 현재 필드 목록 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">표시 필드 ({localConfig.displayFields.length}개)</Label>
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
{localConfig.displayFields.map((field, index) => (
|
||||
<div key={`${field.name}-${index}`} className="flex items-center gap-2 rounded border p-2">
|
||||
<Switch
|
||||
checked={field.visible}
|
||||
onCheckedChange={(checked) => {
|
||||
const newFields = [...localConfig.displayFields];
|
||||
newFields[index] = { ...newFields[index], visible: checked };
|
||||
const newConfig = { ...localConfig, displayFields: newFields };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={field.name}
|
||||
onChange={(e) => updateDisplayField(index, "name", e.target.value)}
|
||||
onBlur={handleFieldBlur}
|
||||
placeholder="필드명"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateDisplayField(index, "label", e.target.value)}
|
||||
onBlur={handleFieldBlur}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Select
|
||||
value={field.type}
|
||||
onValueChange={(value) => {
|
||||
const newFields = [...localConfig.displayFields];
|
||||
newFields[index] = { ...newFields[index], type: value };
|
||||
const newConfig = { ...localConfig, displayFields: newFields };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={localConfig.searchFields.includes(field.name) ? "default" : "outline"}
|
||||
onClick={() => toggleSearchField(field.name)}
|
||||
className="p-1 text-xs"
|
||||
title={localConfig.searchFields.includes(field.name) ? "검색 필드에서 제거" : "검색 필드로 추가"}
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => removeDisplayField(index)}
|
||||
className="p-1 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
) : (
|
||||
<div className="bg-muted/50 rounded-md border p-3 space-y-2">
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">참조 테이블:</span>
|
||||
<div className="font-medium">{referenceInfo.referenceTable}</div>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<span className="text-muted-foreground">참조 컬럼:</span>
|
||||
<div className="font-medium">{referenceInfo.referenceColumn || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">표시 컬럼:</span>
|
||||
<div className="font-medium">{referenceInfo.displayColumn || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
이 정보는 테이블 타입 관리에서 변경할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 검색 설정 */}
|
||||
{/* UI 모드 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">검색 설정</h4>
|
||||
<h4 className="text-sm font-medium">UI 설정</h4>
|
||||
|
||||
{/* UI 모드 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="uiMode" className="text-xs">
|
||||
UI 모드
|
||||
</Label>
|
||||
<Select
|
||||
value={(localConfig as any).uiMode || "combo"}
|
||||
onValueChange={(value) => updateConfig("uiMode" as any, value)}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder="모드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="select">드롭다운 (Select)</SelectItem>
|
||||
<SelectItem value="modal">모달 팝업 (Modal)</SelectItem>
|
||||
<SelectItem value="combo">입력 + 모달 버튼 (Combo)</SelectItem>
|
||||
<SelectItem value="autocomplete">자동완성 (Autocomplete)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{(localConfig as any).uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."}
|
||||
{(localConfig as any).uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."}
|
||||
{((localConfig as any).uiMode === "combo" || !(localConfig as any).uiMode) && "입력 필드와 검색 버튼이 함께 표시됩니다."}
|
||||
{(localConfig as any).uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder" className="text-xs">
|
||||
|
|
@ -400,7 +292,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfigLocal("placeholder", e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder="엔티티를 선택하세요"
|
||||
placeholder="항목을 선택하세요"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -418,6 +310,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">검색 설정</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -456,7 +353,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Label htmlFor="searchable" className="text-xs">
|
||||
검색 가능
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">엔티티를 검색할 수 있습니다.</p>
|
||||
<p className="text-muted-foreground text-xs">항목을 검색할 수 있습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="searchable"
|
||||
|
|
@ -470,7 +367,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Label htmlFor="multiple" className="text-xs">
|
||||
다중 선택
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">여러 엔티티를 선택할 수 있습니다.</p>
|
||||
<p className="text-muted-foreground text-xs">여러 항목을 선택할 수 있습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="multiple"
|
||||
|
|
@ -480,33 +377,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">추가 필터</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filters" className="text-xs">
|
||||
JSON 필터
|
||||
</Label>
|
||||
<Textarea
|
||||
id="filters"
|
||||
value={JSON.stringify(localConfig.filters || {}, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
updateConfig("filters", parsed);
|
||||
} catch {
|
||||
// 유효하지 않은 JSON은 무시
|
||||
}
|
||||
}}
|
||||
placeholder='{"status": "active", "department": "IT"}'
|
||||
className="font-mono text-xs"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">API 요청에 추가될 필터 조건을 JSON 형태로 입력하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">상태 설정</h4>
|
||||
|
|
@ -516,7 +386,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Label htmlFor="required" className="text-xs">
|
||||
필수 선택
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">엔티티가 반드시 선택되어야 합니다.</p>
|
||||
<p className="text-muted-foreground text-xs">반드시 항목을 선택해야 합니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="required"
|
||||
|
|
@ -530,7 +400,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Label htmlFor="readonly" className="text-xs">
|
||||
읽기 전용
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">엔티티를 변경할 수 없습니다.</p>
|
||||
<p className="text-muted-foreground text-xs">값을 변경할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="readonly"
|
||||
|
|
@ -547,31 +417,18 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 rounded border bg-white p-2">
|
||||
<Database className="h-4 w-4 text-gray-400" />
|
||||
<span className="flex-1 text-xs text-muted-foreground">{localConfig.placeholder || "엔티티를 선택하세요"}</span>
|
||||
<span className="flex-1 text-xs text-muted-foreground">
|
||||
{localConfig.placeholder || "항목을 선택하세요"}
|
||||
</span>
|
||||
{localConfig.searchable && <Search className="h-4 w-4 text-gray-400" />}
|
||||
</div>
|
||||
|
||||
{localConfig.displayFields.length > 0 && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<div className="font-medium">표시 필드:</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{localConfig.displayFields
|
||||
.filter((f) => f.visible)
|
||||
.map((field, index) => (
|
||||
<span key={index} className="rounded bg-gray-100 px-2 py-1">
|
||||
{field.label}
|
||||
{localConfig.searchFields.includes(field.name) && " 🔍"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground text-xs">
|
||||
타입: {localConfig.entityType || "미정"}• 값 필드: {localConfig.valueField}• 라벨 필드:{" "}
|
||||
{localConfig.labelField}
|
||||
{localConfig.multiple && " • 다중선택"}
|
||||
{localConfig.required && " • 필수"}
|
||||
<div>테이블: {referenceInfo.referenceTable || "미설정"}</div>
|
||||
<div>값 필드: {localConfig.valueField || referenceInfo.referenceColumn || "-"}</div>
|
||||
<div>표시 필드: {localConfig.labelField || referenceInfo.displayColumn || "-"}</div>
|
||||
{localConfig.multiple && <span> / 다중선택</span>}
|
||||
{localConfig.required && <span> / 필수</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -582,5 +439,3 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
};
|
||||
|
||||
EntityConfigPanel.displayName = "EntityConfigPanel";
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Settings, Clock, Info, Workflow } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Settings, Clock, Info, Workflow, Plus, Trash2, GripVertical, ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { getNodeFlows, NodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
||||
|
|
@ -14,11 +17,22 @@ interface ImprovedButtonControlConfigPanelProps {
|
|||
onUpdateProperty: (path: string, value: any) => void;
|
||||
}
|
||||
|
||||
// 다중 제어 설정 인터페이스
|
||||
interface FlowControlConfig {
|
||||
id: string;
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 단순화된 버튼 제어 설정 패널
|
||||
* 🔥 다중 제어 지원 버튼 설정 패널
|
||||
*
|
||||
* 노드 플로우 실행만 지원:
|
||||
* - 플로우 선택 및 실행 타이밍 설정
|
||||
* 기능:
|
||||
* - 여러 개의 노드 플로우 선택 및 순서 지정
|
||||
* - 각 플로우별 실행 타이밍 설정
|
||||
* - 드래그앤드롭 또는 버튼으로 순서 변경
|
||||
*/
|
||||
export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlConfigPanelProps> = ({
|
||||
component,
|
||||
|
|
@ -27,6 +41,9 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
const config = component.webTypeConfig || {};
|
||||
const dataflowConfig = config.dataflowConfig || {};
|
||||
|
||||
// 다중 제어 설정 (배열)
|
||||
const flowControls: FlowControlConfig[] = dataflowConfig.flowControls || [];
|
||||
|
||||
// 🔥 State 관리
|
||||
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -58,24 +75,118 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
};
|
||||
|
||||
/**
|
||||
* 🔥 플로우 선택 핸들러
|
||||
* 🔥 제어 추가
|
||||
*/
|
||||
const handleFlowSelect = (flowId: string) => {
|
||||
const selectedFlow = flows.find((f) => f.flowId.toString() === flowId);
|
||||
if (selectedFlow) {
|
||||
// 전체 dataflowConfig 업데이트 (selectedDiagramId 포함)
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig", {
|
||||
...dataflowConfig,
|
||||
selectedDiagramId: selectedFlow.flowId, // 백엔드에서 사용
|
||||
selectedRelationshipId: null, // 노드 플로우는 관계 ID 불필요
|
||||
flowConfig: {
|
||||
flowId: selectedFlow.flowId,
|
||||
flowName: selectedFlow.flowName,
|
||||
executionTiming: "before", // 기본값
|
||||
contextData: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
const handleAddControl = useCallback(() => {
|
||||
const newControl: FlowControlConfig = {
|
||||
id: `control_${Date.now()}`,
|
||||
flowId: 0,
|
||||
flowName: "",
|
||||
executionTiming: "after",
|
||||
order: flowControls.length + 1,
|
||||
};
|
||||
|
||||
const updatedControls = [...flowControls, newControl];
|
||||
updateFlowControls(updatedControls);
|
||||
}, [flowControls]);
|
||||
|
||||
/**
|
||||
* 🔥 제어 삭제
|
||||
*/
|
||||
const handleRemoveControl = useCallback(
|
||||
(controlId: string) => {
|
||||
const updatedControls = flowControls
|
||||
.filter((c) => c.id !== controlId)
|
||||
.map((c, index) => ({ ...c, order: index + 1 }));
|
||||
updateFlowControls(updatedControls);
|
||||
},
|
||||
[flowControls],
|
||||
);
|
||||
|
||||
/**
|
||||
* 🔥 제어 플로우 선택
|
||||
*/
|
||||
const handleFlowSelect = useCallback(
|
||||
(controlId: string, flowId: string) => {
|
||||
const selectedFlow = flows.find((f) => f.flowId.toString() === flowId);
|
||||
if (selectedFlow) {
|
||||
const updatedControls = flowControls.map((c) =>
|
||||
c.id === controlId ? { ...c, flowId: selectedFlow.flowId, flowName: selectedFlow.flowName } : c,
|
||||
);
|
||||
updateFlowControls(updatedControls);
|
||||
}
|
||||
},
|
||||
[flows, flowControls],
|
||||
);
|
||||
|
||||
/**
|
||||
* 🔥 실행 타이밍 변경
|
||||
*/
|
||||
const handleTimingChange = useCallback(
|
||||
(controlId: string, timing: "before" | "after" | "replace") => {
|
||||
const updatedControls = flowControls.map((c) => (c.id === controlId ? { ...c, executionTiming: timing } : c));
|
||||
updateFlowControls(updatedControls);
|
||||
},
|
||||
[flowControls],
|
||||
);
|
||||
|
||||
/**
|
||||
* 🔥 순서 위로 이동
|
||||
*/
|
||||
const handleMoveUp = useCallback(
|
||||
(controlId: string) => {
|
||||
const index = flowControls.findIndex((c) => c.id === controlId);
|
||||
if (index > 0) {
|
||||
const updatedControls = [...flowControls];
|
||||
[updatedControls[index - 1], updatedControls[index]] = [updatedControls[index], updatedControls[index - 1]];
|
||||
// 순서 번호 재정렬
|
||||
updatedControls.forEach((c, i) => (c.order = i + 1));
|
||||
updateFlowControls(updatedControls);
|
||||
}
|
||||
},
|
||||
[flowControls],
|
||||
);
|
||||
|
||||
/**
|
||||
* 🔥 순서 아래로 이동
|
||||
*/
|
||||
const handleMoveDown = useCallback(
|
||||
(controlId: string) => {
|
||||
const index = flowControls.findIndex((c) => c.id === controlId);
|
||||
if (index < flowControls.length - 1) {
|
||||
const updatedControls = [...flowControls];
|
||||
[updatedControls[index], updatedControls[index + 1]] = [updatedControls[index + 1], updatedControls[index]];
|
||||
// 순서 번호 재정렬
|
||||
updatedControls.forEach((c, i) => (c.order = i + 1));
|
||||
updateFlowControls(updatedControls);
|
||||
}
|
||||
},
|
||||
[flowControls],
|
||||
);
|
||||
|
||||
/**
|
||||
* 🔥 제어 목록 업데이트 (백엔드 호환성 유지)
|
||||
*/
|
||||
const updateFlowControls = (controls: FlowControlConfig[]) => {
|
||||
// 첫 번째 제어를 기존 형식으로도 저장 (하위 호환성)
|
||||
const firstValidControl = controls.find((c) => c.flowId > 0);
|
||||
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig", {
|
||||
...dataflowConfig,
|
||||
// 기존 형식 (하위 호환성)
|
||||
selectedDiagramId: firstValidControl?.flowId || null,
|
||||
selectedRelationshipId: null,
|
||||
flowConfig: firstValidControl
|
||||
? {
|
||||
flowId: firstValidControl.flowId,
|
||||
flowName: firstValidControl.flowName,
|
||||
executionTiming: firstValidControl.executionTiming,
|
||||
contextData: {},
|
||||
}
|
||||
: null,
|
||||
// 새로운 다중 제어 형식
|
||||
flowControls: controls,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -98,32 +209,57 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
{/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */}
|
||||
{config.enableDataflowControl && (
|
||||
<div className="space-y-4">
|
||||
<FlowSelector
|
||||
flows={flows}
|
||||
selectedFlowId={dataflowConfig.flowConfig?.flowId}
|
||||
onSelect={handleFlowSelect}
|
||||
loading={loading}
|
||||
/>
|
||||
{/* 제어 목록 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Workflow className="h-4 w-4 text-green-600" />
|
||||
<Label>제어 목록 (순서대로 실행)</Label>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleAddControl} className="h-8">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
제어 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dataflowConfig.flowConfig && (
|
||||
<div className="space-y-4">
|
||||
<Separator />
|
||||
<ExecutionTimingSelector
|
||||
value={dataflowConfig.flowConfig.executionTiming}
|
||||
onChange={(timing) =>
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig.flowConfig.executionTiming", timing)
|
||||
}
|
||||
/>
|
||||
{/* 제어 목록 */}
|
||||
{flowControls.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-6 text-center">
|
||||
<Workflow className="mx-auto h-8 w-8 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-500">등록된 제어가 없습니다</p>
|
||||
<Button variant="outline" size="sm" onClick={handleAddControl} className="mt-3">
|
||||
<Plus className="mr-1 h-3 w-3" />첫 번째 제어 추가
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{flowControls.map((control, index) => (
|
||||
<FlowControlItem
|
||||
key={control.id}
|
||||
control={control}
|
||||
flows={flows}
|
||||
loading={loading}
|
||||
isFirst={index === 0}
|
||||
isLast={index === flowControls.length - 1}
|
||||
onFlowSelect={(flowId) => handleFlowSelect(control.id, flowId)}
|
||||
onTimingChange={(timing) => handleTimingChange(control.id, timing)}
|
||||
onMoveUp={() => handleMoveUp(control.id)}
|
||||
onMoveDown={() => handleMoveDown(control.id)}
|
||||
onRemove={() => handleRemoveControl(control.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded bg-green-50 p-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Info className="mt-0.5 h-4 w-4 text-green-600" />
|
||||
<div className="text-xs text-green-800">
|
||||
<p className="font-medium">노드 플로우 실행 정보:</p>
|
||||
<p className="mt-1">선택한 플로우의 모든 노드가 순차적/병렬로 실행됩니다.</p>
|
||||
<p className="mt-1">• 독립 트랜잭션: 각 액션은 독립적으로 커밋/롤백</p>
|
||||
<p>• 연쇄 중단: 부모 노드 실패 시 자식 노드 스킵</p>
|
||||
</div>
|
||||
{/* 안내 메시지 */}
|
||||
{flowControls.length > 0 && (
|
||||
<div className="rounded bg-blue-50 p-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Info className="mt-0.5 h-4 w-4 text-blue-600" />
|
||||
<div className="text-xs text-blue-800">
|
||||
<p className="font-medium">다중 제어 실행 정보:</p>
|
||||
<p className="mt-1">• 제어는 위에서 아래 순서대로 순차 실행됩니다</p>
|
||||
<p>• 각 제어는 독립 트랜잭션으로 처리됩니다</p>
|
||||
<p>• 이전 제어 실패 시 다음 제어는 실행되지 않습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -135,90 +271,89 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
};
|
||||
|
||||
/**
|
||||
* 🔥 플로우 선택 컴포넌트
|
||||
* 🔥 개별 제어 아이템 컴포넌트
|
||||
*/
|
||||
const FlowSelector: React.FC<{
|
||||
const FlowControlItem: React.FC<{
|
||||
control: FlowControlConfig;
|
||||
flows: NodeFlow[];
|
||||
selectedFlowId?: number;
|
||||
onSelect: (flowId: string) => void;
|
||||
loading: boolean;
|
||||
}> = ({ flows, selectedFlowId, onSelect, loading }) => {
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onFlowSelect: (flowId: string) => void;
|
||||
onTimingChange: (timing: "before" | "after" | "replace") => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onRemove: () => void;
|
||||
}> = ({ control, flows, loading, isFirst, isLast, onFlowSelect, onTimingChange, onMoveUp, onMoveDown, onRemove }) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Workflow className="h-4 w-4 text-green-600" />
|
||||
<Label>실행할 노드 플로우 선택</Label>
|
||||
</div>
|
||||
<Card className="p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
{/* 순서 표시 및 이동 버튼 */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Badge variant="secondary" className="h-6 w-6 justify-center rounded-full p-0 text-xs">
|
||||
{control.order}
|
||||
</Badge>
|
||||
<div className="flex flex-col">
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onMoveUp} disabled={isFirst}>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onMoveDown} disabled={isLast}>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={selectedFlowId?.toString() || ""} onValueChange={onSelect}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="플로우를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">플로우 목록을 불러오는 중...</div>
|
||||
) : flows.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
<p>사용 가능한 플로우가 없습니다</p>
|
||||
<p className="mt-2 text-xs">노드 편집기에서 플로우를 먼저 생성하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
flows.map((flow) => (
|
||||
<SelectItem key={flow.flowId} value={flow.flowId.toString()}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{flow.flowName}</span>
|
||||
{flow.flowDescription && (
|
||||
<span className="text-muted-foreground text-xs">{flow.flowDescription}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 플로우 선택 및 설정 */}
|
||||
<div className="flex-1 space-y-2">
|
||||
{/* 플로우 선택 */}
|
||||
<Select value={control.flowId > 0 ? control.flowId.toString() : ""} onValueChange={onFlowSelect}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="플로우를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loading ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||||
) : flows.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">플로우가 없습니다</div>
|
||||
) : (
|
||||
flows.map((flow) => (
|
||||
<SelectItem key={flow.flowId} value={flow.flowId.toString()}>
|
||||
<span className="text-xs">{flow.flowName}</span>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 실행 타이밍 */}
|
||||
<Select value={control.executionTiming} onValueChange={onTimingChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="before">
|
||||
<span className="text-xs">Before (사전 실행)</span>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
<SelectItem value="after">
|
||||
<span className="text-xs">After (사후 실행)</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="replace">
|
||||
<span className="text-xs">Replace (대체 실행)</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
/**
|
||||
* 🔥 실행 타이밍 선택 컴포넌트
|
||||
*/
|
||||
const ExecutionTimingSelector: React.FC<{
|
||||
value: string;
|
||||
onChange: (timing: "before" | "after" | "replace") => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-orange-600" />
|
||||
<Label>실행 타이밍</Label>
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="실행 타이밍을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="before">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">Before (사전 실행)</span>
|
||||
<span className="text-muted-foreground text-xs">버튼 액션 실행 전에 플로우를 실행합니다</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="after">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">After (사후 실행)</span>
|
||||
<span className="text-muted-foreground text-xs">버튼 액션 실행 후에 플로우를 실행합니다</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="replace">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">Replace (대체 실행)</span>
|
||||
<span className="text-muted-foreground text-xs">버튼 액션 대신 플로우만 실행합니다</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,658 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Check, ChevronsUpDown, Plus, X, Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { QuickInsertConfig, QuickInsertColumnMapping } from "@/types/screen-management";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface QuickInsertConfigSectionProps {
|
||||
component: ComponentData;
|
||||
onUpdateProperty: (path: string, value: any) => void;
|
||||
allComponents?: ComponentData[];
|
||||
currentTableName?: string;
|
||||
}
|
||||
|
||||
interface TableOption {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ColumnOption {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const QuickInsertConfigSection: React.FC<QuickInsertConfigSectionProps> = ({
|
||||
component,
|
||||
onUpdateProperty,
|
||||
allComponents = [],
|
||||
currentTableName,
|
||||
}) => {
|
||||
// 현재 설정 가져오기
|
||||
const config: QuickInsertConfig = component.componentConfig?.action?.quickInsertConfig || {
|
||||
targetTable: "",
|
||||
columnMappings: [],
|
||||
afterInsert: {
|
||||
refreshData: true,
|
||||
clearComponents: [],
|
||||
showSuccessMessage: true,
|
||||
successMessage: "저장되었습니다.",
|
||||
},
|
||||
duplicateCheck: {
|
||||
enabled: false,
|
||||
columns: [],
|
||||
errorMessage: "이미 존재하는 데이터입니다.",
|
||||
},
|
||||
};
|
||||
|
||||
// 테이블 목록 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [tablePopoverOpen, setTablePopoverOpen] = useState(false);
|
||||
const [tableSearch, setTableSearch] = useState("");
|
||||
|
||||
// 대상 테이블 컬럼 목록 상태
|
||||
const [targetColumns, setTargetColumns] = useState<ColumnOption[]>([]);
|
||||
const [targetColumnsLoading, setTargetColumnsLoading] = useState(false);
|
||||
|
||||
// 매핑별 Popover 상태
|
||||
const [targetColumnPopoverOpen, setTargetColumnPopoverOpen] = useState<Record<number, boolean>>({});
|
||||
const [targetColumnSearch, setTargetColumnSearch] = useState<Record<number, string>>({});
|
||||
const [sourceComponentPopoverOpen, setSourceComponentPopoverOpen] = useState<Record<number, boolean>>({});
|
||||
const [sourceComponentSearch, setSourceComponentSearch] = useState<Record<number, string>>({});
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setTablesLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/tables");
|
||||
if (response.data?.success && response.data?.data) {
|
||||
setTables(
|
||||
response.data.data.map((t: any) => ({
|
||||
name: t.tableName,
|
||||
label: t.displayName || t.tableName,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setTablesLoading(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 대상 테이블 선택 시 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadTargetColumns = async () => {
|
||||
if (!config.targetTable) {
|
||||
setTargetColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setTargetColumnsLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${config.targetTable}/columns`);
|
||||
if (response.data?.success && response.data?.data) {
|
||||
// columns가 배열인지 확인 (data.columns 또는 data 직접)
|
||||
const columns = response.data.data.columns || response.data.data;
|
||||
setTargetColumns(
|
||||
(Array.isArray(columns) ? columns : []).map((col: any) => ({
|
||||
name: col.columnName || col.column_name,
|
||||
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
setTargetColumns([]);
|
||||
} finally {
|
||||
setTargetColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
loadTargetColumns();
|
||||
}, [config.targetTable]);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<QuickInsertConfig>) => {
|
||||
const newConfig = { ...config, ...updates };
|
||||
onUpdateProperty("componentConfig.action.quickInsertConfig", newConfig);
|
||||
},
|
||||
[config, onUpdateProperty]
|
||||
);
|
||||
|
||||
// 컬럼 매핑 추가
|
||||
const addMapping = () => {
|
||||
const newMapping: QuickInsertColumnMapping = {
|
||||
targetColumn: "",
|
||||
sourceType: "component",
|
||||
sourceComponentId: "",
|
||||
};
|
||||
updateConfig({
|
||||
columnMappings: [...(config.columnMappings || []), newMapping],
|
||||
});
|
||||
};
|
||||
|
||||
// 컬럼 매핑 삭제
|
||||
const removeMapping = (index: number) => {
|
||||
const newMappings = [...(config.columnMappings || [])];
|
||||
newMappings.splice(index, 1);
|
||||
updateConfig({ columnMappings: newMappings });
|
||||
};
|
||||
|
||||
// 컬럼 매핑 업데이트
|
||||
const updateMapping = (index: number, updates: Partial<QuickInsertColumnMapping>) => {
|
||||
const newMappings = [...(config.columnMappings || [])];
|
||||
newMappings[index] = { ...newMappings[index], ...updates };
|
||||
updateConfig({ columnMappings: newMappings });
|
||||
};
|
||||
|
||||
// 필터링된 테이블 목록
|
||||
const filteredTables = tables.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(tableSearch.toLowerCase()) ||
|
||||
t.label.toLowerCase().includes(tableSearch.toLowerCase())
|
||||
);
|
||||
|
||||
// 컴포넌트 목록 (entity 타입 우선)
|
||||
const availableComponents = allComponents.filter((comp: any) => {
|
||||
// entity 타입 또는 select 타입 컴포넌트 필터링
|
||||
const widgetType = comp.widgetType || comp.componentType || "";
|
||||
return widgetType === "entity" || widgetType === "select" || widgetType === "text";
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-green-50 p-4 dark:bg-green-950/20">
|
||||
<h4 className="text-sm font-medium text-foreground">즉시 저장 설정</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
화면에서 선택한 데이터를 버튼 클릭 시 특정 테이블에 즉시 저장합니다.
|
||||
</p>
|
||||
|
||||
{/* 대상 테이블 선택 */}
|
||||
<div>
|
||||
<Label>대상 테이블 *</Label>
|
||||
<Popover open={tablePopoverOpen} onOpenChange={setTablePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablePopoverOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
{config.targetTable
|
||||
? tables.find((t) => t.name === config.targetTable)?.label || config.targetTable
|
||||
: "테이블을 선택하세요..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="테이블 검색..."
|
||||
value={tableSearch}
|
||||
onValueChange={setTableSearch}
|
||||
className="text-xs"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={`${table.label} ${table.name}`}
|
||||
onSelect={() => {
|
||||
updateConfig({ targetTable: table.name, columnMappings: [] });
|
||||
setTablePopoverOpen(false);
|
||||
setTableSearch("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", config.targetTable === table.name ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{table.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 매핑 */}
|
||||
{config.targetTable && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>컬럼 매핑</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addMapping} className="h-6 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(config.columnMappings || []).length === 0 ? (
|
||||
<div className="rounded border-2 border-dashed py-4 text-center text-xs text-muted-foreground">
|
||||
컬럼 매핑을 추가하세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(config.columnMappings || []).map((mapping, index) => (
|
||||
<Card key={index} className="p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">매핑 #{index + 1}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeMapping(index)}
|
||||
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 대상 컬럼 */}
|
||||
<div>
|
||||
<Label className="text-xs">대상 컬럼 (저장할 컬럼)</Label>
|
||||
<Popover
|
||||
open={targetColumnPopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setTargetColumnPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
disabled={targetColumnsLoading}
|
||||
>
|
||||
{mapping.targetColumn
|
||||
? targetColumns.find((c) => c.name === mapping.targetColumn)?.label || mapping.targetColumn
|
||||
: "컬럼 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
value={targetColumnSearch[index] || ""}
|
||||
onValueChange={(v) => setTargetColumnSearch((prev) => ({ ...prev, [index]: v }))}
|
||||
className="text-xs"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{targetColumns
|
||||
.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes((targetColumnSearch[index] || "").toLowerCase()) ||
|
||||
c.label.toLowerCase().includes((targetColumnSearch[index] || "").toLowerCase())
|
||||
)
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
updateMapping(index, { targetColumn: col.name });
|
||||
setTargetColumnPopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||
setTargetColumnSearch((prev) => ({ ...prev, [index]: "" }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mapping.targetColumn === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{col.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{col.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 값 소스 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">값 소스</Label>
|
||||
<Select
|
||||
value={mapping.sourceType}
|
||||
onValueChange={(value: "component" | "leftPanel" | "fixed" | "currentUser") => {
|
||||
updateMapping(index, {
|
||||
sourceType: value,
|
||||
sourceComponentId: undefined,
|
||||
sourceColumn: undefined,
|
||||
fixedValue: undefined,
|
||||
userField: undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="component" className="text-xs">
|
||||
컴포넌트 선택값
|
||||
</SelectItem>
|
||||
<SelectItem value="leftPanel" className="text-xs">
|
||||
좌측 패널 선택 데이터
|
||||
</SelectItem>
|
||||
<SelectItem value="fixed" className="text-xs">
|
||||
고정값
|
||||
</SelectItem>
|
||||
<SelectItem value="currentUser" className="text-xs">
|
||||
현재 사용자 정보
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 소스 타입별 추가 설정 */}
|
||||
{mapping.sourceType === "component" && (
|
||||
<div>
|
||||
<Label className="text-xs">소스 컴포넌트</Label>
|
||||
<Popover
|
||||
open={sourceComponentPopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setSourceComponentPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
||||
{mapping.sourceComponentId
|
||||
? (() => {
|
||||
const comp = allComponents.find((c: any) => c.id === mapping.sourceComponentId);
|
||||
return comp?.label || comp?.columnName || mapping.sourceComponentId;
|
||||
})()
|
||||
: "컴포넌트 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컴포넌트 검색..."
|
||||
value={sourceComponentSearch[index] || ""}
|
||||
onValueChange={(v) => setSourceComponentSearch((prev) => ({ ...prev, [index]: v }))}
|
||||
className="text-xs"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">컴포넌트를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableComponents
|
||||
.filter((comp: any) => {
|
||||
const search = (sourceComponentSearch[index] || "").toLowerCase();
|
||||
const label = (comp.label || "").toLowerCase();
|
||||
const colName = (comp.columnName || "").toLowerCase();
|
||||
return label.includes(search) || colName.includes(search);
|
||||
})
|
||||
.map((comp: any) => (
|
||||
<CommandItem
|
||||
key={comp.id}
|
||||
value={comp.id}
|
||||
onSelect={() => {
|
||||
// sourceComponentId와 함께 sourceColumnName도 저장 (formData 접근용)
|
||||
updateMapping(index, {
|
||||
sourceComponentId: comp.id,
|
||||
sourceColumnName: comp.columnName || undefined,
|
||||
});
|
||||
setSourceComponentPopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||
setSourceComponentSearch((prev) => ({ ...prev, [index]: "" }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mapping.sourceComponentId === comp.id ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{comp.label || comp.columnName || comp.id}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{comp.widgetType || comp.componentType}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapping.sourceType === "leftPanel" && (
|
||||
<div>
|
||||
<Label className="text-xs">좌측 패널 컬럼명</Label>
|
||||
<Input
|
||||
placeholder="예: process_code"
|
||||
value={mapping.sourceColumn || ""}
|
||||
onChange={(e) => updateMapping(index, { sourceColumn: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
분할 패널 좌측에서 선택된 데이터의 컬럼명을 입력하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapping.sourceType === "fixed" && (
|
||||
<div>
|
||||
<Label className="text-xs">고정값</Label>
|
||||
<Input
|
||||
placeholder="고정값 입력"
|
||||
value={mapping.fixedValue || ""}
|
||||
onChange={(e) => updateMapping(index, { fixedValue: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapping.sourceType === "currentUser" && (
|
||||
<div>
|
||||
<Label className="text-xs">사용자 정보 필드</Label>
|
||||
<Select
|
||||
value={mapping.userField || ""}
|
||||
onValueChange={(value: "userId" | "userName" | "companyCode" | "deptCode") => {
|
||||
updateMapping(index, { userField: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="필드 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="userId" className="text-xs">
|
||||
사용자 ID
|
||||
</SelectItem>
|
||||
<SelectItem value="userName" className="text-xs">
|
||||
사용자 이름
|
||||
</SelectItem>
|
||||
<SelectItem value="companyCode" className="text-xs">
|
||||
회사 코드
|
||||
</SelectItem>
|
||||
<SelectItem value="deptCode" className="text-xs">
|
||||
부서 코드
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 저장 후 동작 설정 */}
|
||||
{config.targetTable && (
|
||||
<div className="space-y-3 rounded border bg-background p-3">
|
||||
<Label className="text-xs font-medium">저장 후 동작</Label>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-normal">데이터 새로고침</Label>
|
||||
<Switch
|
||||
checked={config.afterInsert?.refreshData ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig({
|
||||
afterInsert: { ...config.afterInsert, refreshData: checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground -mt-2">
|
||||
테이블리스트, 카드 디스플레이 컴포넌트를 새로고침합니다
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-normal">성공 메시지 표시</Label>
|
||||
<Switch
|
||||
checked={config.afterInsert?.showSuccessMessage ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig({
|
||||
afterInsert: { ...config.afterInsert, showSuccessMessage: checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.afterInsert?.showSuccessMessage && (
|
||||
<div>
|
||||
<Label className="text-xs">성공 메시지</Label>
|
||||
<Input
|
||||
placeholder="저장되었습니다."
|
||||
value={config.afterInsert?.successMessage || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig({
|
||||
afterInsert: { ...config.afterInsert, successMessage: e.target.value },
|
||||
});
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 중복 체크 설정 */}
|
||||
{config.targetTable && (
|
||||
<div className="space-y-3 rounded border bg-background p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">중복 체크</Label>
|
||||
<Switch
|
||||
checked={config.duplicateCheck?.enabled ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig({
|
||||
duplicateCheck: { ...config.duplicateCheck, enabled: checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.duplicateCheck?.enabled && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">중복 체크 컬럼</Label>
|
||||
<div className="mt-1 max-h-40 overflow-y-auto rounded border bg-background p-2">
|
||||
{targetColumns.length === 0 ? (
|
||||
<p className="text-[10px] text-muted-foreground">컬럼을 불러오는 중...</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{targetColumns.map((col) => {
|
||||
const isChecked = (config.duplicateCheck?.columns || []).includes(col.name);
|
||||
return (
|
||||
<div
|
||||
key={col.name}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-muted"
|
||||
onClick={() => {
|
||||
const currentColumns = config.duplicateCheck?.columns || [];
|
||||
const newColumns = isChecked
|
||||
? currentColumns.filter((c) => c !== col.name)
|
||||
: [...currentColumns, col.name];
|
||||
updateConfig({
|
||||
duplicateCheck: { ...config.duplicateCheck, columns: newColumns },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => {}}
|
||||
className="h-3 w-3 flex-shrink-0"
|
||||
/>
|
||||
<span className="flex-1 text-xs whitespace-nowrap">
|
||||
{col.label}{col.label !== col.name && ` (${col.name})`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
선택한 컬럼들의 조합으로 중복 여부를 체크합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">중복 시 에러 메시지</Label>
|
||||
<Input
|
||||
placeholder="이미 존재하는 데이터입니다."
|
||||
value={config.duplicateCheck?.errorMessage || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig({
|
||||
duplicateCheck: { ...config.duplicateCheck, errorMessage: e.target.value },
|
||||
});
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사용 안내 */}
|
||||
<div className="rounded-md bg-green-100 p-3 dark:bg-green-900/30">
|
||||
<p className="text-xs text-green-900 dark:text-green-100">
|
||||
<strong>사용 방법:</strong>
|
||||
<br />
|
||||
1. 저장할 대상 테이블을 선택합니다
|
||||
<br />
|
||||
2. 컬럼 매핑을 추가하여 각 컬럼에 어떤 값을 저장할지 설정합니다
|
||||
<br />
|
||||
3. 버튼 클릭 시 설정된 값들이 대상 테이블에 즉시 저장됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickInsertConfigSection;
|
||||
|
||||
|
|
@ -46,6 +46,7 @@ interface DetailSettingsPanelProps {
|
|||
currentTableName?: string; // 현재 화면의 테이블명
|
||||
tables?: TableInfo[]; // 전체 테이블 목록
|
||||
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
|
||||
components?: ComponentData[]; // 현재 화면의 모든 컴포넌트 (연쇄관계 부모 필드 선택용)
|
||||
}
|
||||
|
||||
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||
|
|
@ -55,6 +56,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
currentTableName,
|
||||
tables = [], // 기본값 빈 배열
|
||||
currentScreenCompanyCode,
|
||||
components = [], // 기본값 빈 배열
|
||||
}) => {
|
||||
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
|
||||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
|
|
@ -878,7 +880,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" key={selectedComponent.id}>
|
||||
<div className="space-y-4 w-full min-w-0" key={selectedComponent.id}>
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
|
|
@ -998,7 +1000,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 설정 패널 영역 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">{renderComponentConfigPanel()}</div>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 w-full">{renderComponentConfigPanel()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1156,8 +1158,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 컴포넌트 설정 패널 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 pb-6 w-full min-w-0">
|
||||
<div className="space-y-6 w-full min-w-0">
|
||||
{/* DynamicComponentConfigPanel */}
|
||||
<DynamicComponentConfigPanel
|
||||
componentId={componentId}
|
||||
|
|
@ -1396,8 +1398,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 상세 설정 영역 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-6">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 w-full min-w-0">
|
||||
<div className="space-y-6 w-full min-w-0">
|
||||
{console.log("🔍 [DetailSettingsPanel] widget 타입:", selectedComponent?.type, "autoFill:", widget.autoFill)}
|
||||
{/* 🆕 자동 입력 섹션 */}
|
||||
<div className="space-y-4 rounded-md border border-red-500 bg-yellow-50 p-4">
|
||||
|
|
|
|||
|
|
@ -943,6 +943,18 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<Label className="text-xs">읽기전용</Label>
|
||||
</div>
|
||||
)}
|
||||
{/* 숨김 옵션 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true}
|
||||
onCheckedChange={(checked) => {
|
||||
handleUpdate("hidden", checked);
|
||||
handleUpdate("componentConfig.hidden", checked);
|
||||
}}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label className="text-xs">숨김</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -30,6 +30,7 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
|
||||
const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
|
||||
|
||||
// 테이블 정보 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -42,6 +43,8 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
order: 0,
|
||||
}))
|
||||
);
|
||||
// 현재 틀고정 컬럼 수 로드
|
||||
setFrozenColumnCount(table.frozenColumnCount ?? 0);
|
||||
}
|
||||
}, [table]);
|
||||
|
||||
|
|
@ -94,6 +97,11 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
table.onColumnOrderChange(newOrder);
|
||||
}
|
||||
|
||||
// 틀고정 컬럼 수 변경 콜백 호출
|
||||
if (table?.onFrozenColumnCountChange) {
|
||||
table.onFrozenColumnCountChange(frozenColumnCount);
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -107,9 +115,18 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
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;
|
||||
|
||||
return (
|
||||
|
|
@ -126,11 +143,34 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
</DialogHeader>
|
||||
|
||||
<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="text-xs text-muted-foreground sm:text-sm">
|
||||
{visibleCount}/{localColumns.length}개 컬럼 표시 중
|
||||
{/* 상태 표시 및 틀고정 설정 */}
|
||||
<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">
|
||||
{visibleCount}/{localColumns.length}개 컬럼 표시 중
|
||||
</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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -148,6 +188,12 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
const columnMeta = table?.columns.find(
|
||||
(c) => c.columnName === col.columnName
|
||||
);
|
||||
// 표시 가능한 컬럼 중 몇 번째인지 계산 (틀고정 표시용)
|
||||
const visibleIndex = localColumns
|
||||
.slice(0, index + 1)
|
||||
.filter((c) => c.visible).length;
|
||||
const isFrozen = col.visible && visibleIndex <= frozenColumnCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.columnName}
|
||||
|
|
@ -155,7 +201,11 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
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" />
|
||||
|
|
@ -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" />
|
||||
) : (
|
||||
<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="text-xs font-medium sm:text-sm">
|
||||
{columnMeta?.columnLabel}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium sm:text-sm">
|
||||
{columnMeta?.columnLabel}
|
||||
</span>
|
||||
{isFrozen && (
|
||||
<span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium">
|
||||
(고정)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
||||
{col.columnName}
|
||||
|
|
@ -217,7 +276,7 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
onClick={onClose}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
|
|
|
|||
|
|
@ -7,15 +7,18 @@ import { X, Loader2 } from "lucide-react";
|
|||
import type { TabsComponent, TabItem } from "@/types/screen-management";
|
||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useActiveTab } from "@/contexts/ActiveTabContext";
|
||||
|
||||
interface TabsWidgetProps {
|
||||
component: TabsComponent;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
menuObjid?: number; // 🆕 부모 화면의 메뉴 OBJID
|
||||
menuObjid?: number; // 부모 화면의 메뉴 OBJID
|
||||
}
|
||||
|
||||
export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) {
|
||||
// ActiveTab context 사용
|
||||
const { setActiveTab, removeTabsComponent } = useActiveTab();
|
||||
const {
|
||||
tabs = [],
|
||||
defaultTab,
|
||||
|
|
@ -25,12 +28,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
persistSelection = false,
|
||||
} = component;
|
||||
|
||||
console.log("🎨 TabsWidget 렌더링:", {
|
||||
componentId: component.id,
|
||||
tabs,
|
||||
tabsLength: tabs.length,
|
||||
component,
|
||||
});
|
||||
|
||||
const storageKey = `tabs-${component.id}-selected`;
|
||||
|
||||
|
|
@ -57,25 +54,35 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
setVisibleTabs(tabs.filter((tab) => !tab.disabled));
|
||||
}, [tabs]);
|
||||
|
||||
// 선택된 탭 변경 시 localStorage에 저장
|
||||
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
|
||||
useEffect(() => {
|
||||
if (persistSelection && typeof window !== "undefined") {
|
||||
localStorage.setItem(storageKey, selectedTab);
|
||||
}
|
||||
}, [selectedTab, persistSelection, storageKey]);
|
||||
|
||||
// ActiveTab Context에 현재 활성 탭 정보 등록
|
||||
const currentTabInfo = visibleTabs.find(t => t.id === selectedTab);
|
||||
if (currentTabInfo) {
|
||||
setActiveTab(component.id, {
|
||||
tabId: selectedTab,
|
||||
tabsComponentId: component.id,
|
||||
screenId: currentTabInfo.screenId,
|
||||
label: currentTabInfo.label,
|
||||
});
|
||||
}
|
||||
}, [selectedTab, persistSelection, storageKey, component.id, visibleTabs, setActiveTab]);
|
||||
|
||||
// 컴포넌트 언마운트 시 ActiveTab Context에서 제거
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
removeTabsComponent(component.id);
|
||||
};
|
||||
}, [component.id, removeTabsComponent]);
|
||||
|
||||
// 초기 로드 시 선택된 탭의 화면 불러오기
|
||||
useEffect(() => {
|
||||
const currentTab = visibleTabs.find((t) => t.id === selectedTab);
|
||||
console.log("🔄 초기 탭 로드:", {
|
||||
selectedTab,
|
||||
currentTab,
|
||||
hasScreenId: !!currentTab?.screenId,
|
||||
screenId: currentTab?.screenId,
|
||||
});
|
||||
|
||||
if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) {
|
||||
console.log("📥 초기 화면 로딩 시작:", currentTab.screenId);
|
||||
loadScreenLayout(currentTab.screenId);
|
||||
}
|
||||
}, [selectedTab, visibleTabs]);
|
||||
|
|
@ -83,26 +90,20 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
// 화면 레이아웃 로드
|
||||
const loadScreenLayout = async (screenId: number) => {
|
||||
if (screenLayouts[screenId]) {
|
||||
console.log("✅ 이미 로드된 화면:", screenId);
|
||||
return; // 이미 로드됨
|
||||
}
|
||||
|
||||
console.log("📥 화면 레이아웃 로딩 시작:", screenId);
|
||||
setLoadingScreens((prev) => ({ ...prev, [screenId]: true }));
|
||||
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`);
|
||||
console.log("📦 API 응답:", { screenId, success: response.data.success, hasData: !!response.data.data });
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
console.log("✅ 화면 레이아웃 로드 완료:", screenId);
|
||||
setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data }));
|
||||
} else {
|
||||
console.error("❌ 화면 레이아웃 로드 실패 - success false");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 화면 레이아웃 로드 실패 ${screenId}:`, error);
|
||||
console.error(`화면 레이아웃 로드 실패 ${screenId}:`, error);
|
||||
} finally {
|
||||
setLoadingScreens((prev) => ({ ...prev, [screenId]: false }));
|
||||
}
|
||||
|
|
@ -110,10 +111,9 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
|
||||
// 탭 변경 핸들러
|
||||
const handleTabChange = (tabId: string) => {
|
||||
console.log("🔄 탭 변경:", tabId);
|
||||
setSelectedTab(tabId);
|
||||
|
||||
// 🆕 마운트된 탭 목록에 추가 (한 번 마운트되면 유지)
|
||||
// 마운트된 탭 목록에 추가 (한 번 마운트되면 유지)
|
||||
setMountedTabs(prev => {
|
||||
if (prev.has(tabId)) return prev;
|
||||
const newSet = new Set(prev);
|
||||
|
|
@ -123,10 +123,7 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
|
||||
// 해당 탭의 화면 로드
|
||||
const tab = visibleTabs.find((t) => t.id === tabId);
|
||||
console.log("🔍 선택된 탭 정보:", { tab, hasScreenId: !!tab?.screenId, screenId: tab?.screenId });
|
||||
|
||||
if (tab && tab.screenId && !screenLayouts[tab.screenId]) {
|
||||
console.log("📥 탭 변경 시 화면 로딩:", tab.screenId);
|
||||
loadScreenLayout(tab.screenId);
|
||||
}
|
||||
};
|
||||
|
|
@ -157,7 +154,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
};
|
||||
|
||||
if (visibleTabs.length === 0) {
|
||||
console.log("⚠️ 보이는 탭이 없음");
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<p className="text-muted-foreground text-sm">탭이 없습니다</p>
|
||||
|
|
@ -165,13 +161,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
);
|
||||
}
|
||||
|
||||
console.log("🎨 TabsWidget 최종 렌더링:", {
|
||||
visibleTabsCount: visibleTabs.length,
|
||||
selectedTab,
|
||||
screenLayoutsKeys: Object.keys(screenLayouts),
|
||||
loadingScreensKeys: Object.keys(loadingScreens),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col pt-4" style={style}>
|
||||
<Tabs
|
||||
|
|
@ -233,14 +222,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
const layoutData = screenLayouts[tab.screenId];
|
||||
const { components = [], screenResolution } = layoutData;
|
||||
|
||||
// 비활성 탭은 로그 생략
|
||||
if (isActive) {
|
||||
console.log("🎯 렌더링할 화면 데이터:", {
|
||||
screenId: tab.screenId,
|
||||
componentsCount: components.length,
|
||||
screenResolution,
|
||||
});
|
||||
}
|
||||
|
||||
const designWidth = screenResolution?.width || 1920;
|
||||
const designHeight = screenResolution?.height || 1080;
|
||||
|
|
@ -260,16 +241,18 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
{components.map((component: any) => (
|
||||
{components.map((comp: any) => (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={component}
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
allComponents={components}
|
||||
screenInfo={{
|
||||
id: tab.screenId,
|
||||
tableName: layoutData.tableName,
|
||||
}}
|
||||
menuObjid={menuObjid}
|
||||
parentTabId={tab.id}
|
||||
parentTabsComponentId={component.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater";
|
||||
import {
|
||||
RepeaterFieldGroupConfig,
|
||||
RepeaterData,
|
||||
RepeaterItemData,
|
||||
RepeaterFieldDefinition,
|
||||
CalculationFormula,
|
||||
} from "@/types/repeater";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
||||
|
|
@ -46,7 +52,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
const breakpoint = previewBreakpoint || globalBreakpoint;
|
||||
|
||||
// 카테고리 매핑 데이터 (값 -> {label, color})
|
||||
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color: string }>>>({});
|
||||
const [categoryMappings, setCategoryMappings] = useState<
|
||||
Record<string, Record<string, { label: string; color: string }>>
|
||||
>({});
|
||||
|
||||
// 설정 기본값
|
||||
const {
|
||||
|
|
@ -78,10 +86,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
// 접힌 상태 관리 (각 항목별)
|
||||
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
|
||||
|
||||
|
||||
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
|
||||
const initialCalcDoneRef = useRef(false);
|
||||
|
||||
|
||||
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
|
||||
const deletedItemIdsRef = useRef<string[]>([]);
|
||||
|
||||
|
|
@ -98,47 +106,60 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
|
||||
useEffect(() => {
|
||||
if (value.length > 0) {
|
||||
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
|
||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
||||
|
||||
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
|
||||
const updatedValue = value.map(item => {
|
||||
const updatedItem = { ...item };
|
||||
let hasChange = false;
|
||||
|
||||
calculatedFields.forEach(calcField => {
|
||||
const calculatedValue = calculateValue(calcField.formula, updatedItem);
|
||||
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
|
||||
updatedItem[calcField.name] = calculatedValue;
|
||||
hasChange = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
|
||||
if (updatedItem.id) {
|
||||
updatedItem._existingRecord = true;
|
||||
}
|
||||
|
||||
return hasChange ? updatedItem : item;
|
||||
});
|
||||
|
||||
setItems(updatedValue);
|
||||
initialCalcDoneRef.current = true;
|
||||
|
||||
// 계산된 값이 있으면 onChange 호출 (초기 1회만)
|
||||
const dataWithMeta = config.targetTable
|
||||
? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||
: updatedValue;
|
||||
onChange?.(dataWithMeta);
|
||||
// 🆕 빈 배열도 처리 (FK 기반 필터링 시 데이터가 없을 수 있음)
|
||||
if (value.length === 0) {
|
||||
// minItems가 설정되어 있으면 빈 항목 생성, 아니면 빈 배열로 초기화
|
||||
if (minItems > 0) {
|
||||
const emptyItems = Array(minItems)
|
||||
.fill(null)
|
||||
.map(() => createEmptyItem());
|
||||
setItems(emptyItems);
|
||||
} else {
|
||||
// 🆕 기존 레코드 플래그 추가
|
||||
const valueWithFlag = value.map(item => ({
|
||||
...item,
|
||||
_existingRecord: !!item.id,
|
||||
}));
|
||||
setItems(valueWithFlag);
|
||||
setItems([]);
|
||||
}
|
||||
initialCalcDoneRef.current = false; // 다음 데이터 로드 시 계산식 재실행
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
|
||||
const calculatedFields = fields.filter((f) => f.type === "calculated");
|
||||
|
||||
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
|
||||
const updatedValue = value.map((item) => {
|
||||
const updatedItem = { ...item };
|
||||
let hasChange = false;
|
||||
|
||||
calculatedFields.forEach((calcField) => {
|
||||
const calculatedValue = calculateValue(calcField.formula, updatedItem);
|
||||
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
|
||||
updatedItem[calcField.name] = calculatedValue;
|
||||
hasChange = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
|
||||
if (updatedItem.id) {
|
||||
updatedItem._existingRecord = true;
|
||||
}
|
||||
|
||||
return hasChange ? updatedItem : item;
|
||||
});
|
||||
|
||||
setItems(updatedValue);
|
||||
initialCalcDoneRef.current = true;
|
||||
|
||||
// 계산된 값이 있으면 onChange 호출 (초기 1회만)
|
||||
const dataWithMeta = config.targetTable
|
||||
? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||
: updatedValue;
|
||||
onChange?.(dataWithMeta);
|
||||
} else {
|
||||
// 🆕 기존 레코드 플래그 추가
|
||||
const valueWithFlag = value.map((item) => ({
|
||||
...item,
|
||||
_existingRecord: !!item.id,
|
||||
}));
|
||||
setItems(valueWithFlag);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
|
|
@ -161,17 +182,16 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
// 항목 제거
|
||||
const handleRemoveItem = (index: number) => {
|
||||
if (items.length <= minItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 항목이 1개 이하일 때도 삭제 가능 (빈 상태 허용)
|
||||
// minItems 체크 제거 - 모든 항목 삭제 허용
|
||||
|
||||
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
|
||||
const removedItem = items[index];
|
||||
if (removedItem?.id) {
|
||||
console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id);
|
||||
deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id];
|
||||
}
|
||||
|
||||
|
||||
const newItems = items.filter((_, i) => i !== index);
|
||||
setItems(newItems);
|
||||
|
||||
|
|
@ -179,10 +199,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
|
||||
const currentDeletedIds = deletedItemIdsRef.current;
|
||||
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
|
||||
|
||||
|
||||
const dataWithMeta = config.targetTable
|
||||
? newItems.map((item, idx) => ({
|
||||
...item,
|
||||
? newItems.map((item, idx) => ({
|
||||
...item,
|
||||
_targetTable: config.targetTable,
|
||||
// 첫 번째 항목에만 삭제 ID 목록 포함
|
||||
...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
||||
|
|
@ -205,16 +225,16 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
...newItems[itemIndex],
|
||||
[fieldName]: value,
|
||||
};
|
||||
|
||||
|
||||
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
|
||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
||||
calculatedFields.forEach(calcField => {
|
||||
const calculatedFields = fields.filter((f) => f.type === "calculated");
|
||||
calculatedFields.forEach((calcField) => {
|
||||
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
|
||||
if (calculatedValue !== null) {
|
||||
newItems[itemIndex][calcField.name] = calculatedValue;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
setItems(newItems);
|
||||
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
|
||||
itemIndex,
|
||||
|
|
@ -227,8 +247,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
// 🆕 삭제된 항목 ID 목록도 유지
|
||||
const currentDeletedIds = deletedItemIdsRef.current;
|
||||
const dataWithMeta = config.targetTable
|
||||
? newItems.map((item, idx) => ({
|
||||
...item,
|
||||
? newItems.map((item, idx) => ({
|
||||
...item,
|
||||
_targetTable: config.targetTable,
|
||||
// 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만)
|
||||
...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
||||
|
|
@ -288,14 +308,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
*/
|
||||
const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => {
|
||||
if (!formula || !formula.field1) return null;
|
||||
|
||||
|
||||
const value1 = parseFloat(item[formula.field1]) || 0;
|
||||
const value2 = formula.field2
|
||||
? (parseFloat(item[formula.field2]) || 0)
|
||||
: (formula.constantValue ?? 0);
|
||||
|
||||
const value2 = formula.field2 ? parseFloat(item[formula.field2]) || 0 : (formula.constantValue ?? 0);
|
||||
|
||||
let result: number;
|
||||
|
||||
|
||||
switch (formula.operator) {
|
||||
case "+":
|
||||
result = value1 + value2;
|
||||
|
|
@ -331,7 +349,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
default:
|
||||
result = value1;
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
|
|
@ -341,42 +359,44 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
* @param format 포맷 설정
|
||||
* @returns 포맷된 문자열
|
||||
*/
|
||||
const formatNumber = (
|
||||
value: number | null,
|
||||
format?: RepeaterFieldDefinition["numberFormat"]
|
||||
): string => {
|
||||
const formatNumber = (value: number | null, format?: RepeaterFieldDefinition["numberFormat"]): string => {
|
||||
if (value === null || isNaN(value)) return "-";
|
||||
|
||||
|
||||
let formattedValue = value;
|
||||
|
||||
|
||||
// 소수점 자릿수 적용
|
||||
if (format?.decimalPlaces !== undefined) {
|
||||
formattedValue = parseFloat(value.toFixed(format.decimalPlaces));
|
||||
}
|
||||
|
||||
|
||||
// 천 단위 구분자
|
||||
let result = format?.useThousandSeparator !== false
|
||||
? formattedValue.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
||||
})
|
||||
: formattedValue.toString();
|
||||
|
||||
let result =
|
||||
format?.useThousandSeparator !== false
|
||||
? formattedValue.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
||||
})
|
||||
: formattedValue.toString();
|
||||
|
||||
// 접두사/접미사 추가
|
||||
if (format?.prefix) result = format.prefix + result;
|
||||
if (format?.suffix) result = result + format.suffix;
|
||||
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 개별 필드 렌더링
|
||||
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
||||
const isReadonly = disabled || readonly || field.readonly;
|
||||
|
||||
|
||||
// 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
|
||||
// "id(를) 입력하세요" 같은 잘못된 기본값 방지
|
||||
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
|
||||
|
||||
const commonProps = {
|
||||
value: value || "",
|
||||
disabled: isReadonly,
|
||||
placeholder: field.placeholder,
|
||||
placeholder: defaultPlaceholder,
|
||||
required: field.required,
|
||||
};
|
||||
|
||||
|
|
@ -385,25 +405,21 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
const item = items[itemIndex];
|
||||
const calculatedValue = calculateValue(field.formula, item);
|
||||
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
|
||||
|
||||
return (
|
||||
<span className="text-sm font-medium text-blue-700 min-w-[80px] inline-block">
|
||||
{formattedValue}
|
||||
</span>
|
||||
);
|
||||
|
||||
return <span className="inline-block min-w-[80px] text-sm font-medium text-blue-700">{formattedValue}</span>;
|
||||
}
|
||||
|
||||
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
|
||||
if (field.type === "category") {
|
||||
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
|
||||
|
||||
|
||||
// field.name을 키로 사용 (테이블 리스트와 동일)
|
||||
const mapping = categoryMappings[field.name];
|
||||
const valueStr = String(value); // 값을 문자열로 변환
|
||||
const categoryData = mapping?.[valueStr];
|
||||
const displayLabel = categoryData?.label || valueStr;
|
||||
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
|
||||
|
||||
const displayColor = categoryData?.color;
|
||||
|
||||
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
|
||||
fieldName: field.name,
|
||||
value: valueStr,
|
||||
|
|
@ -412,12 +428,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
displayLabel,
|
||||
displayColor,
|
||||
});
|
||||
|
||||
// 색상이 "none"이면 일반 텍스트로 표시
|
||||
if (displayColor === "none") {
|
||||
|
||||
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||||
if (!displayColor || displayColor === "none" || !categoryData) {
|
||||
return <span className="text-sm">{displayLabel}</span>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
|
|
@ -436,10 +452,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
if (field.displayMode === "readonly") {
|
||||
// select 타입인 경우 옵션에서 라벨 찾기
|
||||
if (field.type === "select" && value && field.options) {
|
||||
const option = field.options.find(opt => opt.value === value);
|
||||
const option = field.options.find((opt) => opt.value === value);
|
||||
return <span className="text-sm">{option?.label || value}</span>;
|
||||
}
|
||||
|
||||
|
||||
// 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드)
|
||||
const mapping = categoryMappings[field.name];
|
||||
if (mapping && value) {
|
||||
|
|
@ -461,16 +477,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
);
|
||||
}
|
||||
// 색상이 없으면 텍스트로 표시
|
||||
return <span className="text-sm text-foreground">{categoryData.label}</span>;
|
||||
return <span className="text-foreground text-sm">{categoryData.label}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 일반 텍스트
|
||||
return (
|
||||
<span className="text-sm text-foreground">
|
||||
{value || "-"}
|
||||
</span>
|
||||
);
|
||||
return <span className="text-foreground text-sm">{value || "-"}</span>;
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
|
|
@ -500,35 +512,55 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{...commonProps}
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
rows={3}
|
||||
className="resize-none min-w-[100px]"
|
||||
className="min-w-[100px] resize-none"
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
case "date": {
|
||||
// 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환 (타임존 이슈 해결)
|
||||
let dateValue = value || "";
|
||||
if (dateValue && typeof dateValue === "string") {
|
||||
// ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 로컬 시간으로 변환하여 날짜 추출
|
||||
if (dateValue.includes("T")) {
|
||||
const date = new Date(dateValue);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
dateValue = `${year}-${month}-${day}`;
|
||||
} else {
|
||||
dateValue = "";
|
||||
}
|
||||
} else {
|
||||
// 유효한 날짜인지 확인
|
||||
const parsedDate = new Date(dateValue);
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
dateValue = ""; // 유효하지 않은 날짜면 빈 값
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
value={dateValue}
|
||||
type="date"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value || null)}
|
||||
className="min-w-[120px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "number":
|
||||
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
|
||||
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
|
||||
const numValue = parseFloat(value) || 0;
|
||||
const formattedDisplay = formatNumber(numValue, field.numberFormat);
|
||||
|
||||
|
||||
// 읽기 전용이면 포맷팅된 텍스트만 표시
|
||||
if (isReadonly) {
|
||||
return (
|
||||
<span className="text-sm min-w-[80px] inline-block">
|
||||
{formattedDisplay}
|
||||
</span>
|
||||
);
|
||||
return <span className="inline-block min-w-[80px] text-sm">{formattedDisplay}</span>;
|
||||
}
|
||||
|
||||
|
||||
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
|
||||
return (
|
||||
<div className="relative min-w-[80px]">
|
||||
|
|
@ -540,15 +572,11 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
max={field.validation?.max}
|
||||
className="pr-1"
|
||||
/>
|
||||
{value && (
|
||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
||||
{formattedDisplay}
|
||||
</div>
|
||||
)}
|
||||
{value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
|
|
@ -597,31 +625,31 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
||||
useEffect(() => {
|
||||
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
|
||||
const categoryFields = fields.filter(f => f.type === "category");
|
||||
const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text");
|
||||
|
||||
const categoryFields = fields.filter((f) => f.type === "category");
|
||||
const readonlyFields = fields.filter((f) => f.displayMode === "readonly" && f.type === "text");
|
||||
|
||||
if (categoryFields.length === 0 && readonlyFields.length === 0) return;
|
||||
|
||||
const loadCategoryMappings = async () => {
|
||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||
|
||||
|
||||
// 1. 카테고리 타입 필드 매핑 로드
|
||||
for (const field of categoryFields) {
|
||||
const columnName = field.name;
|
||||
|
||||
|
||||
if (categoryMappings[columnName]) continue;
|
||||
|
||||
|
||||
try {
|
||||
const tableName = config.targetTable;
|
||||
if (!tableName) continue;
|
||||
|
||||
|
||||
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
|
||||
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color: string }> = {};
|
||||
|
||||
|
||||
response.data.data.forEach((item: any) => {
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
|
|
@ -629,10 +657,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
color: item.color || "#64748b",
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||
|
||||
setCategoryMappings(prev => ({
|
||||
|
||||
setCategoryMappings((prev) => ({
|
||||
...prev,
|
||||
[columnName]: mapping,
|
||||
}));
|
||||
|
|
@ -641,29 +669,29 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
|
||||
// material, division 등 조인된 테이블의 카테고리 필드
|
||||
const joinedTableFields = ['material', 'division', 'status', 'currency_code'];
|
||||
const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name));
|
||||
|
||||
const joinedTableFields = ["material", "division", "status", "currency_code"];
|
||||
const fieldsToLoadFromJoinedTable = readonlyFields.filter((f) => joinedTableFields.includes(f.name));
|
||||
|
||||
if (fieldsToLoadFromJoinedTable.length > 0) {
|
||||
// item_info 테이블에서 카테고리 매핑 로드
|
||||
const joinedTableName = 'item_info';
|
||||
|
||||
const joinedTableName = "item_info";
|
||||
|
||||
for (const field of fieldsToLoadFromJoinedTable) {
|
||||
const columnName = field.name;
|
||||
|
||||
|
||||
if (categoryMappings[columnName]) continue;
|
||||
|
||||
|
||||
try {
|
||||
console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`);
|
||||
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`);
|
||||
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color: string }> = {};
|
||||
|
||||
|
||||
response.data.data.forEach((item: any) => {
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
|
|
@ -671,10 +699,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
color: item.color || "#64748b",
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||
|
||||
setCategoryMappings(prev => ({
|
||||
|
||||
setCategoryMappings((prev) => ({
|
||||
...prev,
|
||||
[columnName]: mapping,
|
||||
}));
|
||||
|
|
@ -694,9 +722,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
if (fields.length === 0) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-destructive/30 bg-destructive/5 p-8 text-center">
|
||||
<p className="text-sm font-medium text-destructive">필드가 정의되지 않았습니다</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">속성 패널에서 필드를 추가하세요.</p>
|
||||
<div className="border-destructive/30 bg-destructive/5 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center">
|
||||
<p className="text-destructive text-sm font-medium">필드가 정의되지 않았습니다</p>
|
||||
<p className="text-muted-foreground mt-2 text-xs">속성 패널에서 필드를 추가하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -706,8 +734,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30 p-8 text-center">
|
||||
<p className="mb-4 text-sm text-muted-foreground">{emptyMessage}</p>
|
||||
<div className="border-border bg-muted/30 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center">
|
||||
<p className="text-muted-foreground mb-4 text-sm">{emptyMessage}</p>
|
||||
{!readonly && !disabled && items.length < maxItems && (
|
||||
<Button type="button" onClick={handleAddItem} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
|
|
@ -740,7 +768,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{fields.map((field) => (
|
||||
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold">작업</TableHead>
|
||||
|
|
@ -751,7 +779,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
<TableRow
|
||||
key={itemIndex}
|
||||
className={cn(
|
||||
"bg-background transition-colors hover:bg-muted/50",
|
||||
"bg-background hover:bg-muted/50 transition-colors",
|
||||
draggedIndex === itemIndex && "opacity-50",
|
||||
)}
|
||||
draggable={allowReorder && !readonly && !disabled}
|
||||
|
|
@ -762,15 +790,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
>
|
||||
{/* 인덱스 번호 */}
|
||||
{showIndex && (
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">
|
||||
{itemIndex + 1}
|
||||
</TableCell>
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
|
||||
)}
|
||||
|
||||
{/* 드래그 핸들 */}
|
||||
{allowReorder && !readonly && !disabled && (
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||
<GripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
|
||||
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
|
|
@ -783,13 +809,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
{/* 삭제 버튼 */}
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||
{!readonly && !disabled && items.length > minItems && (
|
||||
{!readonly && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveItem(itemIndex)}
|
||||
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
||||
title="항목 제거"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -829,12 +855,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
<div className="flex items-center gap-2">
|
||||
{/* 드래그 핸들 */}
|
||||
{allowReorder && !readonly && !disabled && (
|
||||
<GripVertical className="h-4 w-4 flex-shrink-0 cursor-move text-muted-foreground" />
|
||||
<GripVertical className="text-muted-foreground h-4 w-4 flex-shrink-0 cursor-move" />
|
||||
)}
|
||||
|
||||
{/* 인덱스 번호 */}
|
||||
{showIndex && (
|
||||
<CardTitle className="text-sm font-semibold text-foreground">항목 {itemIndex + 1}</CardTitle>
|
||||
<CardTitle className="text-foreground text-sm font-semibold">항목 {itemIndex + 1}</CardTitle>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -853,13 +879,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
)}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
{!readonly && !disabled && items.length > minItems && (
|
||||
{!readonly && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveItem(itemIndex)}
|
||||
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
||||
title="항목 제거"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -873,9 +899,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
<div className={getFieldsLayoutClass()}>
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className="space-y-1" style={{ width: field.width }}>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
<label className="text-foreground text-sm font-medium">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</label>
|
||||
{renderField(field, itemIndex, item[field.name])}
|
||||
</div>
|
||||
|
|
@ -906,7 +932,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
)}
|
||||
|
||||
{/* 제한 안내 */}
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>현재: {items.length}개 항목</span>
|
||||
<span>
|
||||
(최소: {minItems}, 최대: {maxItems})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType, CalculationOperator, CalculationFormula } from "@/types/repeater";
|
||||
import {
|
||||
RepeaterFieldGroupConfig,
|
||||
RepeaterFieldDefinition,
|
||||
RepeaterFieldType,
|
||||
CalculationOperator,
|
||||
CalculationFormula,
|
||||
} from "@/types/repeater";
|
||||
import { ColumnInfo } from "@/types/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -34,10 +40,10 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
}) => {
|
||||
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
|
||||
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
|
||||
|
||||
|
||||
// 로컬 입력 상태 (각 필드의 라벨, placeholder 등)
|
||||
const [localInputs, setLocalInputs] = useState<Record<number, { label: string; placeholder: string }>>({});
|
||||
|
||||
|
||||
// 설정 입력 필드의 로컬 상태
|
||||
const [localConfigInputs, setLocalConfigInputs] = useState({
|
||||
addButtonText: config.addButtonText || "",
|
||||
|
|
@ -88,13 +94,13 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
};
|
||||
|
||||
// 필드 수정 (입력 중 - 로컬 상태만)
|
||||
const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
|
||||
setLocalInputs(prev => ({
|
||||
const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => {
|
||||
setLocalInputs((prev) => ({
|
||||
...prev,
|
||||
[index]: {
|
||||
...prev[index],
|
||||
[field]: value
|
||||
}
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
@ -106,7 +112,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
newFields[index] = {
|
||||
...newFields[index],
|
||||
label: localInput.label,
|
||||
placeholder: localInput.placeholder
|
||||
placeholder: localInput.placeholder,
|
||||
};
|
||||
handleFieldsChange(newFields);
|
||||
}
|
||||
|
|
@ -218,6 +224,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* 🆕 FK 컬럼 설정 (분할 패널용) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">FK 연결 컬럼 (분할 패널용)</Label>
|
||||
<Select
|
||||
value={(config as any).fkColumn || "__none__"}
|
||||
onValueChange={(value) => handleChange("fkColumn" as any, value === "__none__" ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="FK 컬럼 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">사용 안함 (그룹화 컬럼 사용)</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500">
|
||||
분할 패널에서 좌측 테이블 선택 시 이 컬럼을 기준으로 데이터를 필터링합니다.
|
||||
<br />
|
||||
예: serial_no를 선택하면 좌측에서 선택한 장비의 serial_no에 해당하는 데이터만 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 필드 정의 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">필드 정의</Label>
|
||||
|
|
@ -263,8 +295,9 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
onSelect={() => {
|
||||
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
|
||||
const col = column as any;
|
||||
const fieldType = col.input_type || col.inputType || col.webType || col.widgetType || "text";
|
||||
|
||||
const fieldType =
|
||||
col.input_type || col.inputType || col.webType || col.widgetType || "text";
|
||||
|
||||
console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
|
||||
columnName: column.columnName,
|
||||
input_type: col.input_type,
|
||||
|
|
@ -273,19 +306,19 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
widgetType: col.widgetType,
|
||||
finalType: fieldType,
|
||||
});
|
||||
|
||||
|
||||
updateField(index, {
|
||||
name: column.columnName,
|
||||
label: column.columnLabel || column.columnName,
|
||||
type: fieldType as RepeaterFieldType,
|
||||
});
|
||||
// 로컬 입력 상태도 업데이트
|
||||
setLocalInputs(prev => ({
|
||||
setLocalInputs((prev) => ({
|
||||
...prev,
|
||||
[index]: {
|
||||
label: column.columnLabel || column.columnName,
|
||||
placeholder: prev[index]?.placeholder || ""
|
||||
}
|
||||
placeholder: prev[index]?.placeholder || "",
|
||||
},
|
||||
}));
|
||||
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
|
||||
}}
|
||||
|
|
@ -313,7 +346,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Label className="text-xs">라벨</Label>
|
||||
<Input
|
||||
value={localInputs[index]?.label !== undefined ? localInputs[index].label : field.label}
|
||||
onChange={(e) => updateFieldLocal(index, 'label', e.target.value)}
|
||||
onChange={(e) => updateFieldLocal(index, "label", e.target.value)}
|
||||
onBlur={() => handleFieldBlur(index)}
|
||||
placeholder="필드 라벨"
|
||||
className="h-8 w-full text-xs"
|
||||
|
|
@ -358,8 +391,12 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-xs">Placeholder</Label>
|
||||
<Input
|
||||
value={localInputs[index]?.placeholder !== undefined ? localInputs[index].placeholder : (field.placeholder || "")}
|
||||
onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)}
|
||||
value={
|
||||
localInputs[index]?.placeholder !== undefined
|
||||
? localInputs[index].placeholder
|
||||
: field.placeholder || ""
|
||||
}
|
||||
onChange={(e) => updateFieldLocal(index, "placeholder", e.target.value)}
|
||||
onBlur={() => handleFieldBlur(index)}
|
||||
placeholder="입력 안내"
|
||||
className="h-8 w-full text-xs"
|
||||
|
|
@ -374,15 +411,17 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Calculator className="h-4 w-4 text-blue-600" />
|
||||
<Label className="text-xs font-semibold text-blue-800">계산식 설정</Label>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 필드 1 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">필드 1</Label>
|
||||
<Select
|
||||
value={field.formula?.field1 || ""}
|
||||
onValueChange={(value) => updateField(index, {
|
||||
formula: { ...field.formula, field1: value } as CalculationFormula
|
||||
})}
|
||||
onValueChange={(value) =>
|
||||
updateField(index, {
|
||||
formula: { ...field.formula, field1: value } as CalculationFormula,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
|
|
@ -398,54 +437,75 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">연산자</Label>
|
||||
<Select
|
||||
value={field.formula?.operator || "+"}
|
||||
onValueChange={(value) => updateField(index, {
|
||||
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula
|
||||
})}
|
||||
onValueChange={(value) =>
|
||||
updateField(index, {
|
||||
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
<SelectItem value="+" className="text-xs">+ 더하기</SelectItem>
|
||||
<SelectItem value="-" className="text-xs">- 빼기</SelectItem>
|
||||
<SelectItem value="*" className="text-xs">× 곱하기</SelectItem>
|
||||
<SelectItem value="/" className="text-xs">÷ 나누기</SelectItem>
|
||||
<SelectItem value="%" className="text-xs">% 나머지</SelectItem>
|
||||
<SelectItem value="round" className="text-xs">반올림</SelectItem>
|
||||
<SelectItem value="floor" className="text-xs">내림</SelectItem>
|
||||
<SelectItem value="ceil" className="text-xs">올림</SelectItem>
|
||||
<SelectItem value="+" className="text-xs">
|
||||
+ 더하기
|
||||
</SelectItem>
|
||||
<SelectItem value="-" className="text-xs">
|
||||
- 빼기
|
||||
</SelectItem>
|
||||
<SelectItem value="*" className="text-xs">
|
||||
× 곱하기
|
||||
</SelectItem>
|
||||
<SelectItem value="/" className="text-xs">
|
||||
÷ 나누기
|
||||
</SelectItem>
|
||||
<SelectItem value="%" className="text-xs">
|
||||
% 나머지
|
||||
</SelectItem>
|
||||
<SelectItem value="round" className="text-xs">
|
||||
반올림
|
||||
</SelectItem>
|
||||
<SelectItem value="floor" className="text-xs">
|
||||
내림
|
||||
</SelectItem>
|
||||
<SelectItem value="ceil" className="text-xs">
|
||||
올림
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 두 번째 필드 또는 상수값 */}
|
||||
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">필드 2 / 상수</Label>
|
||||
<Select
|
||||
value={field.formula?.field2 || (field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")}
|
||||
value={
|
||||
field.formula?.field2 ||
|
||||
(field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value.startsWith("__const__")) {
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: undefined,
|
||||
constantValue: 0
|
||||
} as CalculationFormula
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: undefined,
|
||||
constantValue: 0,
|
||||
} as CalculationFormula,
|
||||
});
|
||||
} else {
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: value,
|
||||
constantValue: undefined
|
||||
} as CalculationFormula
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: value,
|
||||
constantValue: undefined,
|
||||
} as CalculationFormula,
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
@ -475,14 +535,19 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
min={0}
|
||||
max={10}
|
||||
value={field.formula?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
decimalPlaces: parseInt(e.target.value) || 0,
|
||||
} as CalculationFormula,
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 상수값 입력 필드 */}
|
||||
{field.formula?.constantValue !== undefined && (
|
||||
<div className="space-y-1">
|
||||
|
|
@ -490,15 +555,20 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Input
|
||||
type="number"
|
||||
value={field.formula.constantValue}
|
||||
onChange={(e) => updateField(index, {
|
||||
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
constantValue: parseFloat(e.target.value) || 0,
|
||||
} as CalculationFormula,
|
||||
})
|
||||
}
|
||||
placeholder="숫자 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 숫자 포맷 설정 */}
|
||||
<div className="space-y-2 border-t border-blue-200 pt-2">
|
||||
<Label className="text-[10px] text-blue-700">숫자 표시 형식</Label>
|
||||
|
|
@ -507,9 +577,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Checkbox
|
||||
id={`thousand-sep-${index}`}
|
||||
checked={field.numberFormat?.useThousandSeparator ?? true}
|
||||
onCheckedChange={(checked) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
||||
})}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
||||
천 단위 구분자
|
||||
|
|
@ -519,9 +591,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Label className="text-[10px]">소수점:</Label>
|
||||
<Input
|
||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
|
||||
})
|
||||
}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
|
|
@ -532,31 +606,34 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={field.numberFormat?.prefix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="접두사 (₩)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
<Input
|
||||
value={field.numberFormat?.suffix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="접미사 (원)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 계산식 미리보기 */}
|
||||
<div className="rounded bg-white p-2 text-xs">
|
||||
<span className="text-gray-500">계산식: </span>
|
||||
<code className="font-mono text-blue-700">
|
||||
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} {
|
||||
field.formula?.field2 ||
|
||||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")
|
||||
}
|
||||
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"}{" "}
|
||||
{field.formula?.field2 ||
|
||||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -571,9 +648,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Checkbox
|
||||
id={`number-thousand-sep-${index}`}
|
||||
checked={field.numberFormat?.useThousandSeparator ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
||||
})}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
||||
천 단위 구분자
|
||||
|
|
@ -583,9 +662,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Label className="text-[10px]">소수점:</Label>
|
||||
<Input
|
||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
|
||||
})
|
||||
}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
|
|
@ -596,17 +677,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={field.numberFormat?.prefix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="접두사 (₩)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
<Input
|
||||
value={field.numberFormat?.suffix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="접미사 (원)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
|
|
@ -624,7 +709,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
카테고리 관리에서 설정한 색상으로 배지가 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
"use client";
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* 활성 탭 정보
|
||||
*/
|
||||
export interface ActiveTabInfo {
|
||||
tabId: string; // 탭 고유 ID
|
||||
tabsComponentId: string; // 부모 탭 컴포넌트 ID
|
||||
screenId?: number; // 탭에 연결된 화면 ID
|
||||
label?: string; // 탭 라벨
|
||||
}
|
||||
|
||||
/**
|
||||
* Context 값 타입
|
||||
*/
|
||||
interface ActiveTabContextValue {
|
||||
// 현재 활성 탭 정보 (탭 컴포넌트 ID -> 활성 탭 정보)
|
||||
activeTabs: Map<string, ActiveTabInfo>;
|
||||
|
||||
// 활성 탭 설정
|
||||
setActiveTab: (tabsComponentId: string, tabInfo: ActiveTabInfo) => void;
|
||||
|
||||
// 활성 탭 조회
|
||||
getActiveTab: (tabsComponentId: string) => ActiveTabInfo | undefined;
|
||||
|
||||
// 특정 탭 컴포넌트의 활성 탭 ID 조회
|
||||
getActiveTabId: (tabsComponentId: string) => string | undefined;
|
||||
|
||||
// 전체 활성 탭 ID 목록 (모든 탭 컴포넌트에서)
|
||||
getAllActiveTabIds: () => string[];
|
||||
|
||||
// 탭 컴포넌트 제거 시 정리
|
||||
removeTabsComponent: (tabsComponentId: string) => void;
|
||||
}
|
||||
|
||||
const ActiveTabContext = createContext<ActiveTabContextValue | undefined>(undefined);
|
||||
|
||||
export const ActiveTabProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [activeTabs, setActiveTabs] = useState<Map<string, ActiveTabInfo>>(new Map());
|
||||
|
||||
/**
|
||||
* 활성 탭 설정
|
||||
*/
|
||||
const setActiveTab = useCallback((tabsComponentId: string, tabInfo: ActiveTabInfo) => {
|
||||
setActiveTabs((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(tabsComponentId, tabInfo);
|
||||
return newMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 활성 탭 조회
|
||||
*/
|
||||
const getActiveTab = useCallback(
|
||||
(tabsComponentId: string) => {
|
||||
return activeTabs.get(tabsComponentId);
|
||||
},
|
||||
[activeTabs]
|
||||
);
|
||||
|
||||
/**
|
||||
* 특정 탭 컴포넌트의 활성 탭 ID 조회
|
||||
*/
|
||||
const getActiveTabId = useCallback(
|
||||
(tabsComponentId: string) => {
|
||||
return activeTabs.get(tabsComponentId)?.tabId;
|
||||
},
|
||||
[activeTabs]
|
||||
);
|
||||
|
||||
/**
|
||||
* 전체 활성 탭 ID 목록
|
||||
*/
|
||||
const getAllActiveTabIds = useCallback(() => {
|
||||
return Array.from(activeTabs.values()).map((info) => info.tabId);
|
||||
}, [activeTabs]);
|
||||
|
||||
/**
|
||||
* 탭 컴포넌트 제거 시 정리
|
||||
*/
|
||||
const removeTabsComponent = useCallback((tabsComponentId: string) => {
|
||||
setActiveTabs((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(tabsComponentId);
|
||||
return newMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ActiveTabContext.Provider
|
||||
value={{
|
||||
activeTabs,
|
||||
setActiveTab,
|
||||
getActiveTab,
|
||||
getActiveTabId,
|
||||
getAllActiveTabIds,
|
||||
removeTabsComponent,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ActiveTabContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Context Hook
|
||||
*/
|
||||
export const useActiveTab = () => {
|
||||
const context = useContext(ActiveTabContext);
|
||||
if (!context) {
|
||||
// Context가 없으면 기본값 반환 (탭이 없는 화면에서 사용 시)
|
||||
return {
|
||||
activeTabs: new Map(),
|
||||
setActiveTab: () => {},
|
||||
getActiveTab: () => undefined,
|
||||
getActiveTabId: () => undefined,
|
||||
getAllActiveTabIds: () => [],
|
||||
removeTabsComponent: () => {},
|
||||
};
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional Context Hook (에러 없이 undefined 반환)
|
||||
*/
|
||||
export const useActiveTabOptional = () => {
|
||||
return useContext(ActiveTabContext);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from "react";
|
||||
import { ComponentConfig, ReportDetail, ReportLayout, ReportPage, ReportLayoutConfig } from "@/types/report";
|
||||
import { ComponentConfig, ReportDetail, ReportLayout, ReportPage, ReportLayoutConfig, WatermarkConfig } from "@/types/report";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
|
@ -40,6 +40,7 @@ interface ReportDesignerContextType {
|
|||
reorderPages: (sourceIndex: number, targetIndex: number) => void;
|
||||
selectPage: (pageId: string) => void;
|
||||
updatePageSettings: (pageId: string, settings: Partial<ReportPage>) => void;
|
||||
updateWatermark: (watermark: WatermarkConfig | undefined) => void; // 전체 페이지 공유 워터마크
|
||||
|
||||
// 컴포넌트 (현재 페이지)
|
||||
components: ComponentConfig[]; // currentPage의 components (읽기 전용)
|
||||
|
|
@ -162,8 +163,8 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
// 현재 페이지 계산
|
||||
const currentPage = layoutConfig.pages.find((p) => p.page_id === currentPageId);
|
||||
|
||||
// 현재 페이지의 컴포넌트 (읽기 전용)
|
||||
const components = currentPage?.components || [];
|
||||
// 현재 페이지의 컴포넌트 (읽기 전용) - 배열인지 확인
|
||||
const components = Array.isArray(currentPage?.components) ? currentPage.components : [];
|
||||
|
||||
// currentPageId를 ref로 저장하여 클로저 문제 해결
|
||||
const currentPageIdRef = useRef<string | null>(currentPageId);
|
||||
|
|
@ -803,9 +804,10 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
const horizontalLines: number[] = [];
|
||||
const threshold = 5; // 5px 오차 허용
|
||||
|
||||
// 캔버스를 픽셀로 변환 (1mm = 3.7795px)
|
||||
const canvasWidthPx = canvasWidth * 3.7795;
|
||||
const canvasHeightPx = canvasHeight * 3.7795;
|
||||
// 캔버스를 픽셀로 변환 (고정 스케일 팩터: 1mm = 4px)
|
||||
const MM_TO_PX = 4;
|
||||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||||
const canvasCenterX = canvasWidthPx / 2;
|
||||
const canvasCenterY = canvasHeightPx / 2;
|
||||
|
||||
|
|
@ -987,10 +989,19 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
|
||||
const updatePageSettings = useCallback((pageId: string, settings: Partial<ReportPage>) => {
|
||||
setLayoutConfig((prev) => ({
|
||||
...prev,
|
||||
pages: prev.pages.map((page) => (page.page_id === pageId ? { ...page, ...settings } : page)),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 전체 페이지 공유 워터마크 업데이트
|
||||
const updateWatermark = useCallback((watermark: WatermarkConfig | undefined) => {
|
||||
setLayoutConfig((prev) => ({
|
||||
...prev,
|
||||
watermark,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 리포트 및 레이아웃 로드
|
||||
const loadLayout = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
|
@ -1470,6 +1481,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
reorderPages,
|
||||
selectPage,
|
||||
updatePageSettings,
|
||||
updateWatermark,
|
||||
|
||||
// 컴포넌트 (현재 페이지)
|
||||
components,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef } from "react";
|
||||
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
|
||||
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
|
||||
import { logger } from "@/lib/utils/logger";
|
||||
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
|
|
@ -14,17 +14,21 @@ interface ScreenContextValue {
|
|||
screenId?: number;
|
||||
tableName?: string;
|
||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
||||
|
||||
|
||||
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||
formData: Record<string, any>;
|
||||
updateFormData: (fieldName: string, value: any) => void;
|
||||
|
||||
// 컴포넌트 등록
|
||||
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
|
||||
unregisterDataProvider: (componentId: string) => void;
|
||||
registerDataReceiver: (componentId: string, receiver: DataReceivable) => void;
|
||||
unregisterDataReceiver: (componentId: string) => void;
|
||||
|
||||
|
||||
// 컴포넌트 조회
|
||||
getDataProvider: (componentId: string) => DataProvidable | undefined;
|
||||
getDataReceiver: (componentId: string) => DataReceivable | undefined;
|
||||
|
||||
|
||||
// 모든 컴포넌트 조회
|
||||
getAllDataProviders: () => Map<string, DataProvidable>;
|
||||
getAllDataReceivers: () => Map<string, DataReceivable>;
|
||||
|
|
@ -42,10 +46,31 @@ interface ScreenContextProviderProps {
|
|||
/**
|
||||
* 화면 컨텍스트 프로바이더
|
||||
*/
|
||||
export function ScreenContextProvider({ screenId, tableName, splitPanelPosition, children }: ScreenContextProviderProps) {
|
||||
export function ScreenContextProvider({
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
children,
|
||||
}: ScreenContextProviderProps) {
|
||||
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
|
||||
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
|
||||
|
||||
// 🆕 폼 데이터 상태 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 🆕 폼 데이터 업데이트 함수
|
||||
const updateFormData = useCallback((fieldName: string, value: any) => {
|
||||
setFormData((prev) => {
|
||||
const updated = { ...prev, [fieldName]: value };
|
||||
logger.debug("ScreenContext formData 업데이트", {
|
||||
fieldName,
|
||||
valueType: typeof value,
|
||||
isArray: Array.isArray(value),
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
|
||||
dataProvidersRef.current.set(componentId, provider);
|
||||
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
|
||||
|
|
@ -83,31 +108,38 @@ export function ScreenContextProvider({ screenId, tableName, splitPanelPosition,
|
|||
}, []);
|
||||
|
||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||
const value = React.useMemo<ScreenContextValue>(() => ({
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
}), [
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
]);
|
||||
const value = React.useMemo<ScreenContextValue>(
|
||||
() => ({
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
formData,
|
||||
updateFormData,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
}),
|
||||
[
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
formData,
|
||||
updateFormData,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
],
|
||||
);
|
||||
|
||||
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
|
||||
}
|
||||
|
|
@ -130,4 +162,3 @@ export function useScreenContext() {
|
|||
export function useScreenContextOptional() {
|
||||
return useContext(ScreenContext);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -282,10 +282,6 @@ export function SplitPanelProvider({
|
|||
* 🆕 좌측 선택 데이터 설정
|
||||
*/
|
||||
const handleSetSelectedLeftData = useCallback((data: Record<string, any> | null) => {
|
||||
logger.info(`[SplitPanelContext] 좌측 선택 데이터 설정:`, {
|
||||
hasData: !!data,
|
||||
dataKeys: data ? Object.keys(data) : [],
|
||||
});
|
||||
setSelectedLeftData(data);
|
||||
}, []);
|
||||
|
||||
|
|
@ -323,11 +319,6 @@ export function SplitPanelProvider({
|
|||
}
|
||||
}
|
||||
|
||||
logger.info(`[SplitPanelContext] 매핑된 부모 데이터 (자동+명시적):`, {
|
||||
autoMappedKeys: Object.keys(selectedLeftData),
|
||||
explicitMappings: parentDataMapping.length,
|
||||
finalKeys: Object.keys(mappedData),
|
||||
});
|
||||
return mappedData;
|
||||
}, [selectedLeftData, parentDataMapping]);
|
||||
|
||||
|
|
@ -350,7 +341,6 @@ export function SplitPanelProvider({
|
|||
}
|
||||
}
|
||||
|
||||
logger.info(`[SplitPanelContext] 연결 필터 값:`, filterValues);
|
||||
return filterValues;
|
||||
}, [selectedLeftData, linkedFilters]);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ import React, {
|
|||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
TableRegistration,
|
||||
TableOptionsContextValue,
|
||||
} from "@/types/table-options";
|
||||
import { useActiveTab } from "./ActiveTabContext";
|
||||
|
||||
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(
|
||||
undefined
|
||||
|
|
@ -41,25 +43,24 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
|
|||
|
||||
/**
|
||||
* 테이블 등록 해제
|
||||
* 주의:
|
||||
* 1. selectedTableId를 의존성으로 사용하면 무한 루프 발생 가능
|
||||
* 2. 재등록 시에도 unregister가 호출되므로 selectedTableId를 변경하면 안됨
|
||||
*/
|
||||
const unregisterTable = useCallback(
|
||||
(tableId: string) => {
|
||||
setRegisteredTables((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const removed = newMap.delete(tableId);
|
||||
|
||||
if (removed) {
|
||||
// 선택된 테이블이 제거되면 첫 번째 테이블 선택
|
||||
if (selectedTableId === tableId) {
|
||||
const firstTableId = newMap.keys().next().value;
|
||||
setSelectedTableId(firstTableId || null);
|
||||
}
|
||||
}
|
||||
|
||||
newMap.delete(tableId);
|
||||
return newMap;
|
||||
});
|
||||
|
||||
// 🚫 selectedTableId를 변경하지 않음
|
||||
// 이유: useEffect 재실행 시 cleanup → register 순서로 호출되는데,
|
||||
// cleanup에서 selectedTableId를 null로 만들면 필터 설정이 초기화됨
|
||||
// 다른 테이블이 선택되어야 하면 TableSearchWidget에서 자동 선택함
|
||||
},
|
||||
[selectedTableId]
|
||||
[] // 의존성 없음 - 무한 루프 방지
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -83,18 +84,41 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
|
|||
const updatedTable = { ...table, dataCount: count };
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(tableId, updatedTable);
|
||||
console.log("🔄 [TableOptionsContext] 데이터 건수 업데이트:", {
|
||||
tableId,
|
||||
count,
|
||||
updated: true,
|
||||
});
|
||||
return newMap;
|
||||
}
|
||||
console.warn("⚠️ [TableOptionsContext] 테이블을 찾을 수 없음:", tableId);
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ActiveTab context 사용 (optional - 에러 방지)
|
||||
const activeTabContext = useActiveTab();
|
||||
|
||||
/**
|
||||
* 현재 활성 탭의 테이블만 반환
|
||||
*/
|
||||
const getActiveTabTables = useCallback(() => {
|
||||
const allTables = Array.from(registeredTables.values());
|
||||
const activeTabIds = activeTabContext.getAllActiveTabIds();
|
||||
|
||||
// 활성 탭이 없으면 탭에 속하지 않은 테이블만 반환
|
||||
if (activeTabIds.length === 0) {
|
||||
return allTables.filter(table => !table.parentTabId);
|
||||
}
|
||||
|
||||
// 활성 탭에 속한 테이블 + 탭에 속하지 않은 테이블
|
||||
return allTables.filter(table =>
|
||||
!table.parentTabId || activeTabIds.includes(table.parentTabId)
|
||||
);
|
||||
}, [registeredTables, activeTabContext]);
|
||||
|
||||
/**
|
||||
* 특정 탭의 테이블만 반환
|
||||
*/
|
||||
const getTablesForTab = useCallback((tabId: string) => {
|
||||
const allTables = Array.from(registeredTables.values());
|
||||
return allTables.filter(table => table.parentTabId === tabId);
|
||||
}, [registeredTables]);
|
||||
|
||||
return (
|
||||
<TableOptionsContext.Provider
|
||||
value={{
|
||||
|
|
@ -105,6 +129,8 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
|
|||
updateTableDataCount,
|
||||
selectedTableId,
|
||||
setSelectedTableId,
|
||||
getActiveTabTables,
|
||||
getTablesForTab,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -192,3 +192,7 @@ export function applyAutoFillToFormData(
|
|||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { CascadingDropdownConfig } from "@/types/screen-management";
|
||||
|
||||
|
|
@ -38,12 +38,16 @@ export interface CascadingOption {
|
|||
export interface UseCascadingDropdownProps {
|
||||
/** 🆕 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
|
||||
relationCode?: string;
|
||||
/** 🆕 카테고리 값 연쇄 관계 코드 (카테고리 값 연쇄관계용) */
|
||||
categoryRelationCode?: string;
|
||||
/** 🆕 역할: parent(부모-전체 옵션) 또는 child(자식-필터링된 옵션) */
|
||||
role?: "parent" | "child";
|
||||
/** @deprecated 직접 설정 방식 - relationCode 사용 권장 */
|
||||
config?: CascadingDropdownConfig;
|
||||
/** 부모 필드의 현재 값 (자식 역할일 때 필요) */
|
||||
/** 부모 필드의 현재 값 (자식 역할일 때 필요) - 단일 값 또는 배열(다중 선택) */
|
||||
parentValue?: string | number | null;
|
||||
/** 🆕 다중 부모값 (배열) - parentValue보다 우선 */
|
||||
parentValues?: (string | number)[];
|
||||
/** 초기 옵션 (캐시된 데이터가 있을 경우) */
|
||||
initialOptions?: CascadingOption[];
|
||||
}
|
||||
|
|
@ -71,9 +75,11 @@ const CACHE_TTL = 5 * 60 * 1000; // 5분
|
|||
|
||||
export function useCascadingDropdown({
|
||||
relationCode,
|
||||
categoryRelationCode,
|
||||
role = "child", // 기본값은 자식 역할 (기존 동작 유지)
|
||||
config,
|
||||
parentValue,
|
||||
parentValues,
|
||||
initialOptions = [],
|
||||
}: UseCascadingDropdownProps): UseCascadingDropdownResult {
|
||||
const [options, setOptions] = useState<CascadingOption[]>(initialOptions);
|
||||
|
|
@ -85,25 +91,50 @@ export function useCascadingDropdown({
|
|||
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(() => {
|
||||
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 (role === "parent") {
|
||||
return `relation:${relationCode}:parent:all`;
|
||||
}
|
||||
// 자식 역할: 부모 값별 캐시
|
||||
if (!parentValue) return null;
|
||||
return `relation:${relationCode}:child:${parentValue}`;
|
||||
// 자식 역할: 부모 값별 캐시 (다중 부모값 지원)
|
||||
if (effectiveParentValues.length === 0) return null;
|
||||
const sortedValues = [...effectiveParentValues].sort().join(',');
|
||||
return `relation:${relationCode}:child:${sortedValues}`;
|
||||
}
|
||||
if (config) {
|
||||
if (!parentValue) return null;
|
||||
return `${config.sourceTable}:${config.parentKeyColumn}:${parentValue}`;
|
||||
if (effectiveParentValues.length === 0) return null;
|
||||
const sortedValues = [...effectiveParentValues].sort().join(',');
|
||||
return `${config.sourceTable}:${config.parentKeyColumn}:${sortedValues}`;
|
||||
}
|
||||
return null;
|
||||
}, [relationCode, role, config, parentValue]);
|
||||
}, [categoryRelationCode, relationCode, role, config, effectiveParentValues]);
|
||||
|
||||
// 🆕 부모 역할 옵션 로드 (관계의 parent_table에서 전체 옵션 로드)
|
||||
const loadParentOptions = useCallback(async () => {
|
||||
|
|
@ -158,9 +189,9 @@ export function useCascadingDropdown({
|
|||
}
|
||||
}, [relationCode, getCacheKey]);
|
||||
|
||||
// 자식 역할 옵션 로드 (관계 코드 방식)
|
||||
// 자식 역할 옵션 로드 (관계 코드 방식) - 다중 부모값 지원
|
||||
const loadChildOptions = useCallback(async () => {
|
||||
if (!relationCode || !parentValue) {
|
||||
if (!relationCode || effectiveParentValues.length === 0) {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
|
|
@ -180,8 +211,18 @@ export function useCascadingDropdown({
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
// 관계 코드로 옵션 조회 API 호출 (자식 역할 - 필터링된 옵션)
|
||||
const response = await apiClient.get(`/cascading-relations/options/${relationCode}?parentValue=${encodeURIComponent(String(parentValue))}`);
|
||||
// 다중 부모값 지원: parentValues 파라미터 사용
|
||||
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) {
|
||||
const loadedOptions: CascadingOption[] = response.data.data || [];
|
||||
|
|
@ -195,9 +236,9 @@ export function useCascadingDropdown({
|
|||
});
|
||||
}
|
||||
|
||||
console.log("✅ Child options 로드 완료:", {
|
||||
console.log("✅ Child options 로드 완료 (다중 부모값 지원):", {
|
||||
relationCode,
|
||||
parentValue,
|
||||
parentValues: effectiveParentValues,
|
||||
count: loadedOptions.length,
|
||||
});
|
||||
} else {
|
||||
|
|
@ -210,7 +251,121 @@ export function useCascadingDropdown({
|
|||
} finally {
|
||||
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 () => {
|
||||
|
|
@ -279,7 +434,14 @@ export function useCascadingDropdown({
|
|||
|
||||
// 통합 로드 함수
|
||||
const loadOptions = useCallback(() => {
|
||||
if (relationCode) {
|
||||
// 카테고리 값 연쇄관계 우선
|
||||
if (categoryRelationCode) {
|
||||
if (role === "parent") {
|
||||
loadCategoryParentOptions();
|
||||
} else {
|
||||
loadCategoryChildOptions();
|
||||
}
|
||||
} else if (relationCode) {
|
||||
// 역할에 따라 다른 로드 함수 호출
|
||||
if (role === "parent") {
|
||||
loadParentOptions();
|
||||
|
|
@ -291,7 +453,7 @@ export function useCascadingDropdown({
|
|||
} else {
|
||||
setOptions([]);
|
||||
}
|
||||
}, [relationCode, role, config?.enabled, loadParentOptions, loadChildOptions, loadOptionsByConfig]);
|
||||
}, [categoryRelationCode, relationCode, role, config?.enabled, loadCategoryParentOptions, loadCategoryChildOptions, loadParentOptions, loadChildOptions, loadOptionsByConfig]);
|
||||
|
||||
// 옵션 로드 트리거
|
||||
useEffect(() => {
|
||||
|
|
@ -300,24 +462,28 @@ export function useCascadingDropdown({
|
|||
return;
|
||||
}
|
||||
|
||||
// 부모 역할: 즉시 전체 옵션 로드
|
||||
// 부모 역할: 즉시 전체 옵션 로드 (최초 1회만)
|
||||
if (role === "parent") {
|
||||
loadOptions();
|
||||
return;
|
||||
}
|
||||
|
||||
// 자식 역할: 부모 값이 있을 때만 로드
|
||||
// 부모 값이 변경되었는지 확인
|
||||
const parentChanged = prevParentValueRef.current !== parentValue;
|
||||
prevParentValueRef.current = parentValue;
|
||||
|
||||
if (parentValue) {
|
||||
loadOptions();
|
||||
} else {
|
||||
// 부모 값이 없으면 옵션 초기화
|
||||
setOptions([]);
|
||||
// 부모 값 배열의 변경 감지를 위해 JSON 문자열 비교
|
||||
const prevParentKey = prevParentValueRef.current;
|
||||
|
||||
if (prevParentKey !== parentValuesKey) {
|
||||
prevParentValueRef.current = parentValuesKey as any;
|
||||
|
||||
if (effectiveParentValues.length > 0) {
|
||||
loadOptions();
|
||||
} else {
|
||||
// 부모 값이 없으면 옵션 초기화
|
||||
setOptions([]);
|
||||
}
|
||||
}
|
||||
}, [isEnabled, role, parentValue, loadOptions]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEnabled, role, parentValuesKey]);
|
||||
|
||||
// 옵션 새로고침
|
||||
const refresh = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export interface CascadingRelationUpdateInput extends Partial<CascadingRelationC
|
|||
export interface CascadingOption {
|
||||
value: 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 {
|
||||
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;
|
||||
} catch (error: any) {
|
||||
console.error("연쇄 옵션 조회 실패:", error);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -26,7 +26,14 @@ export const dataApi = {
|
|||
size: number;
|
||||
totalPages: number;
|
||||
}> => {
|
||||
const response = await apiClient.get(`/data/${tableName}`, { params });
|
||||
// filters를 평탄화하여 쿼리 파라미터로 전달 (백엔드 ...filters 형식에 맞춤)
|
||||
const { filters, ...restParams } = params || {};
|
||||
const flattenedParams = {
|
||||
...restParams,
|
||||
...(filters || {}), // filters 객체를 평탄화
|
||||
};
|
||||
|
||||
const response = await apiClient.get(`/data/${tableName}`, { params: flattenedParams });
|
||||
const raw = response.data || {};
|
||||
const items: any[] = (raw.data ?? raw.items ?? raw.rows ?? []) as any[];
|
||||
|
||||
|
|
|
|||
|
|
@ -426,12 +426,29 @@ export class DynamicFormApi {
|
|||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
filters?: Record<string, any>;
|
||||
autoFilter?: {
|
||||
enabled: boolean;
|
||||
filterColumn?: string;
|
||||
userField?: string;
|
||||
};
|
||||
},
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log("📊 테이블 데이터 조회 요청:", { tableName, params });
|
||||
|
||||
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params || {});
|
||||
// autoFilter가 없으면 기본값으로 멀티테넌시 필터 적용
|
||||
// pageSize를 size로 변환 (백엔드 파라미터명 호환)
|
||||
const requestParams = {
|
||||
...params,
|
||||
size: params?.pageSize || params?.size || 100, // 기본값 100
|
||||
autoFilter: params?.autoFilter ?? {
|
||||
enabled: true,
|
||||
filterColumn: "company_code",
|
||||
userField: "companyCode",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, requestParams);
|
||||
|
||||
console.log("✅ 테이블 데이터 조회 성공 (원본):", response.data);
|
||||
console.log("🔍 response.data 상세:", {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue