Merge remote-tracking branch 'upstream/main'

This commit is contained in:
dohyeons 2025-12-22 17:12:31 +09:00
commit b992f13b08
178 changed files with 36003 additions and 8182 deletions

680
PLAN_RENEWAL.md Normal file
View File

@ -0,0 +1,680 @@
# Screen Designer 2.0 리뉴얼 계획: 컴포넌트 통합 및 속성 기반 고도화
## 1. 개요
현재 **68개 이상**으로 파편화된 화면 관리 컴포넌트들을 **9개의 핵심 통합 컴포넌트(Unified Components)**로 재편합니다.
각 컴포넌트는 **속성(Config)** 설정을 통해 다양한 형태(View Mode)와 기능(Behavior)을 수행하도록 설계되어, 유지보수성과 확장성을 극대화합니다.
### 현재 컴포넌트 현황 (AS-IS)
| 카테고리 | 파일 수 | 주요 파일들 |
| :------------- | :-----: | :------------------------------------------------------------------ |
| Widget 타입별 | 14개 | TextWidget, NumberWidget, SelectWidget, DateWidget, EntityWidget 등 |
| Config Panel | 28개 | TextConfigPanel, SelectConfigPanel, DateConfigPanel 등 |
| WebType Config | 11개 | TextTypeConfigPanel, SelectTypeConfigPanel 등 |
| 기타 패널 | 15개+ | PropertiesPanel, DetailSettingsPanel 등 |
---
## 2. 통합 전략: 9 Core Widgets
### A. 입력 위젯 (Input Widgets) - 5종
단순 데이터 입력 필드를 통합합니다.
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) |
| :-------------------- | :------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------- |
| **1. Unified Select** | Select, Radio, Checkbox, Boolean, Code, Entity, Combobox, Toggle | **`mode`**: "dropdown" / "radio" / "check" / "tag"<br>**`source`**: "static" / "code" / "db" / "api"<br>**`dependency`**: { parentField: "..." } |
| **2. Unified Input** | Text, Number, Email, Tel, Password, Color, Search, Integer, Decimal | **`type`**: "text" / "number" / "password"<br>**`format`**: "email", "currency", "biz_no"<br>**`mask`**: "000-0000-0000" |
| **3. Unified Date** | Date, Time, DateTime, DateRange, Month, Year | **`type`**: "date" / "time" / "datetime"<br>**`range`**: true/false |
| **4. Unified Text** | Textarea, RichEditor, Markdown, HTML | **`mode`**: "simple" / "rich" / "code"<br>**`rows`**: number |
| **5. Unified Media** | File, Image, Video, Audio, Attachment | **`type`**: "file" / "image"<br>**`multiple`**: true/false<br>**`preview`**: true/false |
### B. 구조/데이터 위젯 (Structure & Data Widgets) - 4종
레이아웃 배치와 데이터 시각화를 담당합니다.
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | 활용 예시 |
| :-------------------- | :-------------------------------------------------- | :------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------- |
| **6. Unified List** | **Table, Card List, Repeater, DataGrid, List View** | **`viewMode`**: "table" / "card" / "kanban"<br>**`editable`**: true/false | - `viewMode='table'`: 엑셀형 리스트<br>- `viewMode='card'`: **카드 디스플레이**<br>- `editable=true`: **반복 필드 그룹** |
| **7. Unified Layout** | **Row, Col, Split Panel, Grid, Spacer** | **`type`**: "grid" / "split" / "flex"<br>**`columns`**: number | - `type='split'`: **화면 분할 패널**<br>- `type='grid'`: 격자 레이아웃 |
| **8. Unified Group** | Tab, Accordion, FieldSet, Modal, Section | **`type`**: "tab" / "accordion" / "modal" | - 탭이나 아코디언으로 내용 그룹화 |
| **9. Unified Biz** | **Rack Structure**, Calendar, Gantt | **`type`**: "rack" / "calendar" / "gantt" | - `type='rack'`: **랙 구조 설정**<br>- 특수 비즈니스 로직 플러그인 탑재 |
### C. Config Panel 통합 전략 (핵심)
현재 28개의 ConfigPanel을 **1개의 DynamicConfigPanel**로 통합합니다.
| AS-IS | TO-BE | 방식 |
| :-------------------- | :--------------------- | :------------------------------- |
| TextConfigPanel.tsx | | |
| SelectConfigPanel.tsx | **DynamicConfigPanel** | DB의 `sys_input_type` 테이블에서 |
| DateConfigPanel.tsx | (단일 컴포넌트) | JSON Schema를 읽어 |
| NumberConfigPanel.tsx | | 속성 UI를 동적 생성 |
| ... 24개 더 | | |
---
## 3. 구현 시나리오 (속성 기반 변신)
### Case 1: "테이블을 카드 리스트로 변경"
- **AS-IS**: `DataTable` 컴포넌트를 삭제하고 `CardList` 컴포넌트를 새로 추가해야 함.
- **TO-BE**: `UnifiedList`의 속성창에서 **[View Mode]**를 `Table``Card`로 변경하면 즉시 반영.
### Case 2: "단일 선택을 라디오 버튼으로 변경"
- **AS-IS**: `SelectWidget`을 삭제하고 `RadioWidget` 추가.
- **TO-BE**: `UnifiedSelect` 속성창에서 **[Display Mode]**를 `Dropdown``Radio`로 변경.
### Case 3: "입력 폼에 반복 필드(Repeater) 추가"
- **TO-BE**: `UnifiedList` 컴포넌트 배치 후 `editable: true`, `viewMode: "table"` 설정.
---
## 4. 실행 로드맵 (Action Plan)
### Phase 0: 준비 단계 (1주)
통합 작업 전 필수 분석 및 설계를 진행합니다.
- [ ] 기존 컴포넌트 사용 현황 분석 (화면별 위젯 사용 빈도 조사)
- [ ] 데이터 마이그레이션 전략 설계 (`widgetType` → `UnifiedWidget.type` 매핑 정의)
- [ ] `sys_input_type` 테이블 JSON Schema 설계
- [ ] DynamicConfigPanel 프로토타입 설계
### Phase 1: 입력 위젯 통합 (2주)
가장 중복이 많고 효과가 즉각적인 입력 필드부터 통합합니다.
- [ ] **UnifiedInput 구현**: Text, Number, Email, Tel, Password 통합
- [ ] **UnifiedSelect 구현**: Select, Radio, Checkbox, Boolean 통합
- [ ] **UnifiedDate 구현**: Date, DateTime, Time 통합
- [ ] 기존 위젯과 **병행 운영** (deprecated 마킹, 삭제하지 않음)
### Phase 2: Config Panel 통합 (2주)
28개의 ConfigPanel을 단일 동적 패널로 통합합니다.
- [ ] **DynamicConfigPanel 구현**: DB 스키마 기반 속성 UI 자동 생성
- [ ] `sys_input_type` 테이블에 위젯별 JSON Schema 정의 저장
- [ ] 기존 ConfigPanel과 **병행 운영** (삭제하지 않음)
### Phase 3: 데이터/레이아웃 위젯 통합 (2주)
프로젝트의 데이터를 보여주는 핵심 뷰를 통합합니다.
- [ ] **UnifiedList 구현**: Table, Card, Repeater 통합 렌더러 개발
- [ ] **UnifiedLayout 구현**: Split Panel, Grid, Flex 통합
- [ ] **UnifiedGroup 구현**: Tab, Accordion, Modal 통합
### Phase 4: 안정화 및 마이그레이션 (2주)
신규 컴포넌트 안정화 후 점진적 전환을 진행합니다.
- [ ] 신규 화면은 Unified 컴포넌트만 사용하도록 가이드
- [ ] 기존 화면 데이터 마이그레이션 스크립트 개발
- [ ] 마이그레이션 테스트 (스테이징 환경)
- [ ] 문서화 및 개발 가이드 작성
### Phase 5: 레거시 정리 (추후 결정)
충분한 안정화 기간 후 레거시 컴포넌트 정리를 검토합니다.
- [ ] 사용 현황 재분석 (Unified 전환율 확인)
- [ ] 미전환 화면 목록 정리
- [ ] 레거시 컴포넌트 삭제 여부 결정 (별도 회의)
---
## 5. 데이터 마이그레이션 전략
### 5.1 위젯 타입 매핑 테이블
기존 `widgetType`을 신규 Unified 컴포넌트로 매핑합니다.
| 기존 widgetType | 신규 컴포넌트 | 속성 설정 |
| :-------------- | :------------ | :------------------------------ |
| `text` | UnifiedInput | `type: "text"` |
| `number` | UnifiedInput | `type: "number"` |
| `email` | UnifiedInput | `type: "text", format: "email"` |
| `tel` | UnifiedInput | `type: "text", format: "tel"` |
| `select` | UnifiedSelect | `mode: "dropdown"` |
| `radio` | UnifiedSelect | `mode: "radio"` |
| `checkbox` | UnifiedSelect | `mode: "check"` |
| `date` | UnifiedDate | `type: "date"` |
| `datetime` | UnifiedDate | `type: "datetime"` |
| `textarea` | UnifiedText | `mode: "simple"` |
| `file` | UnifiedMedia | `type: "file"` |
| `image` | UnifiedMedia | `type: "image"` |
### 5.2 마이그레이션 원칙
1. **비파괴적 전환**: 기존 데이터 구조 유지, 신규 필드 추가 방식
2. **하위 호환성**: 기존 `widgetType` 필드는 유지, `unifiedType` 필드 추가
3. **점진적 전환**: 화면 수정 시점에 자동 또는 수동 전환
---
## 6. 기대 효과
1. **컴포넌트 수 감소**: 68개 → **9개** (관리 포인트 87% 감소)
2. **Config Panel 통합**: 28개 → **1개** (DynamicConfigPanel)
3. **유연한 UI 변경**: 컴포넌트 교체 없이 속성 변경만으로 UI 모드 전환 가능
4. **Low-Code 확장성**: 새로운 유형의 입력 방식이 필요할 때 코딩 없이 DB 설정만으로 추가 가능
---
## 7. 리스크 및 대응 방안
| 리스크 | 영향도 | 대응 방안 |
| :----------------------- | :----: | :-------------------------------- |
| 기존 화면 호환성 깨짐 | 높음 | 병행 운영 + 하위 호환성 유지 |
| 마이그레이션 데이터 손실 | 높음 | 백업 필수 + 롤백 스크립트 준비 |
| 개발자 학습 곡선 | 중간 | 상세 가이드 문서 + 예제 코드 제공 |
| 성능 저하 (동적 렌더링) | 중간 | 메모이제이션 + 지연 로딩 적용 |
---
## 8. 현재 컴포넌트 매핑 분석
### 8.1 Registry 등록 컴포넌트 전수 조사 (44개)
현재 `frontend/lib/registry/components/`에 등록된 모든 컴포넌트의 통합 가능 여부를 분석했습니다.
#### UnifiedInput으로 통합 (4개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------- | :--------------- | :------------- |
| text-input | `type: "text"` | |
| number-input | `type: "number"` | |
| slider-basic | `type: "slider"` | 속성 추가 필요 |
| button-primary | `type: "button"` | 별도 검토 |
#### UnifiedSelect로 통합 (8개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------------------ | :----------------------------------- | :------------- |
| select-basic | `mode: "dropdown"` | |
| checkbox-basic | `mode: "check"` | |
| radio-basic | `mode: "radio"` | |
| toggle-switch | `mode: "toggle"` | 속성 추가 필요 |
| autocomplete-search-input | `mode: "dropdown", searchable: true` | |
| entity-search-input | `source: "entity"` | |
| mail-recipient-selector | `mode: "multi", type: "email"` | 복합 컴포넌트 |
| location-swap-selector | `mode: "swap"` | 특수 UI |
#### UnifiedDate로 통합 (1개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------ | :------------- | :--- |
| date-input | `type: "date"` | |
#### UnifiedText로 통합 (1개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------- | :--------------- | :--- |
| textarea-basic | `mode: "simple"` | |
#### UnifiedMedia로 통합 (3개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------ | :------------------------------ | :--- |
| file-upload | `type: "file"` | |
| image-widget | `type: "image"` | |
| image-display | `type: "image", readonly: true` | |
#### UnifiedList로 통합 (8개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :-------------------- | :------------------------------------ | :------------ |
| table-list | `viewMode: "table"` | |
| card-display | `viewMode: "card"` | |
| repeater-field-group | `editable: true` | |
| modal-repeater-table | `viewMode: "table", modal: true` | |
| simple-repeater-table | `viewMode: "table", simple: true` | |
| repeat-screen-modal | `viewMode: "card", modal: true` | |
| table-search-widget | `viewMode: "table", searchable: true` | |
| tax-invoice-list | `viewMode: "table", bizType: "tax"` | 특수 비즈니스 |
#### UnifiedLayout으로 통합 (4개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------------ | :-------------------------- | :------------- |
| split-panel-layout | `type: "split"` | |
| split-panel-layout2 | `type: "split", version: 2` | |
| divider-line | `type: "divider"` | 속성 추가 필요 |
| screen-split-panel | `type: "screen-embed"` | 화면 임베딩 |
#### UnifiedGroup으로 통합 (5개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------------- | :--------------------- | :------------ |
| accordion-basic | `type: "accordion"` | |
| tabs | `type: "tabs"` | |
| section-paper | `type: "section"` | |
| section-card | `type: "card-section"` | |
| universal-form-modal | `type: "form-modal"` | 복합 컴포넌트 |
#### UnifiedBiz로 통합 (7개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :-------------------- | :------------------------ | :--------------- |
| flow-widget | `type: "flow"` | 플로우 관리 |
| rack-structure | `type: "rack"` | 창고 렉 구조 |
| map | `type: "map"` | 지도 |
| numbering-rule | `type: "numbering"` | 채번 규칙 |
| category-manager | `type: "category"` | 카테고리 관리 |
| customer-item-mapping | `type: "mapping"` | 거래처-품목 매핑 |
| related-data-buttons | `type: "related-buttons"` | 연관 데이터 |
#### 별도 검토 필요 (3개)
| 현재 컴포넌트 | 문제점 | 제안 |
| :-------------------------- | :------------------- | :------------------------------ |
| conditional-container | 조건부 렌더링 로직 | 공통 속성으로 분리 |
| selected-items-detail-input | 복합 (선택+상세입력) | UnifiedList + UnifiedGroup 조합 |
| text-display | 읽기 전용 텍스트 | UnifiedInput (readonly: true) |
### 8.2 매핑 분석 결과
```
┌─────────────────────────────────────────────────────────┐
│ 전체 44개 컴포넌트 분석 결과 │
├─────────────────────────────────────────────────────────┤
│ ✅ 즉시 통합 가능 : 36개 (82%) │
│ ⚠️ 속성 추가 후 통합 : 5개 (11%) │
│ 🔄 별도 검토 필요 : 3개 (7%) │
└─────────────────────────────────────────────────────────┘
```
### 8.3 속성 확장 필요 사항
#### UnifiedInput 속성 확장
```typescript
// 기존
type: "text" | "number" | "password";
// 확장
type: "text" | "number" | "password" | "slider" | "color" | "button";
```
#### UnifiedSelect 속성 확장
```typescript
// 기존
mode: "dropdown" | "radio" | "check" | "tag";
// 확장
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
```
#### UnifiedLayout 속성 확장
```typescript
// 기존
type: "grid" | "split" | "flex";
// 확장
type: "grid" | "split" | "flex" | "divider" | "screen-embed";
```
### 8.4 조건부 렌더링 공통화
`conditional-container`의 기능을 모든 컴포넌트에서 사용 가능한 공통 속성으로 분리합니다.
```typescript
// 모든 Unified 컴포넌트에 적용 가능한 공통 속성
interface BaseUnifiedProps {
// ... 기존 속성
/** 조건부 렌더링 설정 */
conditional?: {
enabled: boolean;
field: string; // 참조할 필드명
operator: "=" | "!=" | ">" | "<" | "in" | "notIn";
value: any; // 비교 값
hideOnFalse?: boolean; // false일 때 숨김 (기본: true)
};
}
```
---
## 9. 계층 구조(Hierarchy) 컴포넌트 전략
### 9.1 현재 계층 구조 지원 현황
DB 테이블 `cascading_hierarchy_group`에서 4가지 계층 타입을 지원합니다:
| 타입 | 설명 | 예시 |
| :----------------- | :---------------------- | :--------------- |
| **MULTI_TABLE** | 다중 테이블 계층 | 국가 > 도시 > 구 |
| **SELF_REFERENCE** | 자기 참조 (단일 테이블) | 조직도, 메뉴 |
| **BOM** | 자재명세서 구조 | 부품 > 하위부품 |
| **TREE** | 일반 트리 | 카테고리 |
### 9.2 통합 방안: UnifiedHierarchy 신설 (10번째 컴포넌트)
계층 구조는 일반 입력/표시 위젯과 성격이 다르므로 **별도 컴포넌트로 분리**합니다.
```typescript
interface UnifiedHierarchyProps {
/** 계층 유형 */
type: "tree" | "org" | "bom" | "cascading";
/** 표시 방식 */
viewMode: "tree" | "table" | "indent" | "dropdown";
/** 계층 그룹 코드 (cascading_hierarchy_group 연동) */
source: string;
/** 편집 가능 여부 */
editable?: boolean;
/** 드래그 정렬 가능 */
draggable?: boolean;
/** BOM 수량 표시 (BOM 타입 전용) */
showQty?: boolean;
/** 최대 레벨 제한 */
maxLevel?: number;
}
```
### 9.3 활용 예시
| 설정 | 결과 |
| :---------------------------------------- | :------------------------- |
| `type: "tree", viewMode: "tree"` | 카테고리 트리뷰 |
| `type: "org", viewMode: "tree"` | 조직도 |
| `type: "bom", viewMode: "indent"` | BOM 들여쓰기 테이블 |
| `type: "cascading", viewMode: "dropdown"` | 연쇄 셀렉트 (국가>도시>구) |
---
## 10. 최종 통합 컴포넌트 목록 (10개)
| # | 컴포넌트 | 역할 | 커버 범위 |
| :-: | :------------------- | :------------- | :----------------------------------- |
| 1 | **UnifiedInput** | 단일 값 입력 | text, number, slider, button 등 |
| 2 | **UnifiedSelect** | 선택 입력 | dropdown, radio, checkbox, toggle 등 |
| 3 | **UnifiedDate** | 날짜/시간 입력 | date, datetime, time, range |
| 4 | **UnifiedText** | 다중 행 텍스트 | textarea, rich editor, markdown |
| 5 | **UnifiedMedia** | 파일/미디어 | file, image, video, audio |
| 6 | **UnifiedList** | 데이터 목록 | table, card, repeater, kanban |
| 7 | **UnifiedLayout** | 레이아웃 배치 | grid, split, flex, divider |
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 | tabs, accordion, section, modal |
| 9 | **UnifiedBiz** | 비즈니스 특화 | flow, rack, map, numbering 등 |
| 10 | **UnifiedHierarchy** | 계층 구조 | tree, org, bom, cascading |
---
## 11. 연쇄관계 관리 메뉴 통합 전략
### 11.1 현재 연쇄관계 관리 현황
**관리 메뉴**: `연쇄 드롭다운 통합 관리` (6개 탭)
| 탭 | DB 테이블 | 실제 데이터 | 복잡도 |
| :--------------- | :--------------------------------------- | :---------: | :----: |
| 2단계 연쇄관계 | `cascading_relation` | 2건 | 낮음 |
| 다단계 계층 | `cascading_hierarchy_group/level` | 1건 | 높음 |
| 조건부 필터 | `cascading_condition` | 0건 | 중간 |
| 자동 입력 | `cascading_auto_fill_group/mapping` | 0건 | 낮음 |
| 상호 배제 | `cascading_mutual_exclusion` | 0건 | 낮음 |
| 카테고리 값 연쇄 | `category_value_cascading_group/mapping` | 2건 | 중간 |
### 11.2 통합 방향: 속성 기반 vs 공통 정의
#### 판단 기준
| 기능 | 재사용 빈도 | 설정 복잡도 | 권장 방식 |
| :--------------- | :---------: | :---------: | :----------------------- |
| 2단계 연쇄 | 낮음 | 간단 | **속성에 inline 정의** |
| 다단계 계층 | 높음 | 복잡 | **공통 정의 유지** |
| 조건부 필터 | 낮음 | 간단 | **속성에 inline 정의** |
| 자동 입력 | 낮음 | 간단 | **속성에 inline 정의** |
| 상호 배제 | 낮음 | 간단 | **속성에 inline 정의** |
| 카테고리 값 연쇄 | 중간 | 중간 | **카테고리 관리와 통합** |
### 11.3 속성 통합 설계
#### 2단계 연쇄 → UnifiedSelect 속성
```typescript
// AS-IS: 별도 관리 메뉴에서 정의 후 참조
<SelectWidget cascadingRelation="WAREHOUSE_LOCATION" />
// TO-BE: 컴포넌트 속성에서 직접 정의
<UnifiedSelect
source="db"
table="warehouse_location"
valueColumn="location_code"
labelColumn="location_name"
cascading={{
parentField: "warehouse_code", // 같은 화면 내 부모 필드
filterColumn: "warehouse_code", // 필터링할 컬럼
clearOnChange: true // 부모 변경 시 초기화
}}
/>
```
#### 조건부 필터 → 공통 conditional 속성
```typescript
// AS-IS: 별도 관리 메뉴에서 조건 정의
// cascading_condition 테이블에 저장
// TO-BE: 모든 컴포넌트에 공통 속성으로 적용
<UnifiedInput
conditional={{
enabled: true,
field: "order_type", // 참조할 필드
operator: "=", // 비교 연산자
value: "EXPORT", // 비교 값
action: "show", // show | hide | disable | enable
}}
/>
```
#### 자동 입력 → autoFill 속성
```typescript
// AS-IS: cascading_auto_fill_group 테이블에 정의
// TO-BE: 컴포넌트 속성에서 직접 정의
<UnifiedInput
autoFill={{
enabled: true,
sourceTable: "company_mng", // 조회할 테이블
filterColumn: "company_code", // 필터링 컬럼
userField: "companyCode", // 사용자 정보 필드
displayColumn: "company_name", // 표시할 컬럼
}}
/>
```
#### 상호 배제 → mutualExclusion 속성
```typescript
// AS-IS: cascading_mutual_exclusion 테이블에 정의
// TO-BE: 컴포넌트 속성에서 직접 정의
<UnifiedSelect
mutualExclusion={{
enabled: true,
targetField: "sub_category", // 상호 배제 대상 필드
type: "exclusive", // exclusive | inclusive
}}
/>
```
### 11.4 관리 메뉴 정리 계획
| 현재 메뉴 | TO-BE | 비고 |
| :-------------------------- | :----------------------- | :-------------------- |
| **연쇄 드롭다운 통합 관리** | **삭제** | 6개 탭 전체 제거 |
| ├─ 2단계 연쇄관계 | UnifiedSelect 속성 | inline 정의 |
| ├─ 다단계 계층 | **테이블관리로 이동** | 복잡한 구조 유지 필요 |
| ├─ 조건부 필터 | 공통 conditional 속성 | 모든 컴포넌트에 적용 |
| ├─ 자동 입력 | autoFill 속성 | 컴포넌트별 정의 |
| ├─ 상호 배제 | mutualExclusion 속성 | 컴포넌트별 정의 |
| └─ 카테고리 값 연쇄 | **카테고리 관리로 이동** | 기존 메뉴 통합 |
### 11.5 DB 테이블 정리 (Phase 5)
| 테이블 | 조치 | 시점 |
| :--------------------------- | :----------------------- | :------ |
| `cascading_relation` | 마이그레이션 후 삭제 | Phase 5 |
| `cascading_condition` | 삭제 (데이터 없음) | Phase 5 |
| `cascading_auto_fill_*` | 삭제 (데이터 없음) | Phase 5 |
| `cascading_mutual_exclusion` | 삭제 (데이터 없음) | Phase 5 |
| `cascading_hierarchy_*` | **유지** | - |
| `category_value_cascading_*` | **유지** (카테고리 관리) | - |
### 11.6 마이그레이션 스크립트 필요 항목
```sql
-- cascading_relation → 화면 레이아웃 데이터로 마이그레이션
-- 기존 2건의 연쇄관계를 사용하는 화면을 찾아서
-- 해당 컴포넌트의 cascading 속성으로 변환
-- 예시: WAREHOUSE_LOCATION 연쇄관계
-- 이 관계를 사용하는 화면의 컴포넌트에
-- cascading: { parentField: "warehouse_code", filterColumn: "warehouse_code" }
-- 속성 추가
```
---
## 12. 최종 아키텍처 요약
### 12.1 통합 컴포넌트 (10개)
| # | 컴포넌트 | 역할 |
| :-: | :------------------- | :--------------------------------------- |
| 1 | **UnifiedInput** | 단일 값 입력 (text, number, slider 등) |
| 2 | **UnifiedSelect** | 선택 입력 (dropdown, radio, checkbox 등) |
| 3 | **UnifiedDate** | 날짜/시간 입력 |
| 4 | **UnifiedText** | 다중 행 텍스트 (textarea, rich editor) |
| 5 | **UnifiedMedia** | 파일/미디어 (file, image) |
| 6 | **UnifiedList** | 데이터 목록 (table, card, repeater) |
| 7 | **UnifiedLayout** | 레이아웃 배치 (grid, split, flex) |
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 (tabs, accordion, section) |
| 9 | **UnifiedBiz** | 비즈니스 특화 (flow, rack, map 등) |
| 10 | **UnifiedHierarchy** | 계층 구조 (tree, org, bom, cascading) |
### 12.2 공통 속성 (모든 컴포넌트에 적용)
```typescript
interface BaseUnifiedProps {
// 기본 속성
id: string;
label?: string;
required?: boolean;
readonly?: boolean;
disabled?: boolean;
// 스타일
style?: ComponentStyle;
className?: string;
// 조건부 렌더링 (conditional-container 대체)
conditional?: {
enabled: boolean;
field: string;
operator:
| "="
| "!="
| ">"
| "<"
| "in"
| "notIn"
| "isEmpty"
| "isNotEmpty";
value: any;
action: "show" | "hide" | "disable" | "enable";
};
// 자동 입력 (autoFill 대체)
autoFill?: {
enabled: boolean;
sourceTable: string;
filterColumn: string;
userField: "companyCode" | "userId" | "deptCode";
displayColumn: string;
};
// 유효성 검사
validation?: ValidationRule[];
}
```
### 12.3 UnifiedSelect 전용 속성
```typescript
interface UnifiedSelectProps extends BaseUnifiedProps {
// 표시 모드
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
// 데이터 소스
source: "static" | "code" | "db" | "api" | "entity";
// static 소스
options?: Array<{ value: string; label: string }>;
// db 소스
table?: string;
valueColumn?: string;
labelColumn?: string;
// code 소스
codeGroup?: string;
// 연쇄 관계 (cascading_relation 대체)
cascading?: {
parentField: string; // 부모 필드명
filterColumn: string; // 필터링할 컬럼
clearOnChange?: boolean; // 부모 변경 시 초기화
};
// 상호 배제 (mutual_exclusion 대체)
mutualExclusion?: {
enabled: boolean;
targetField: string; // 상호 배제 대상
type: "exclusive" | "inclusive";
};
// 다중 선택
multiple?: boolean;
maxSelect?: number;
}
```
### 12.4 관리 메뉴 정리 결과
| AS-IS | TO-BE |
| :---------------------------- | :----------------------------------- |
| 연쇄 드롭다운 통합 관리 (6탭) | **삭제** |
| - 2단계 연쇄관계 | → UnifiedSelect.cascading 속성 |
| - 다단계 계층 | → 테이블관리 > 계층 구조 설정 |
| - 조건부 필터 | → 공통 conditional 속성 |
| - 자동 입력 | → 공통 autoFill 속성 |
| - 상호 배제 | → UnifiedSelect.mutualExclusion 속성 |
| - 카테고리 값 연쇄 | → 카테고리 관리와 통합 |
---
## 13. 주의사항
> **기존 컴포넌트 삭제 금지**
> 모든 Phase에서 기존 컴포넌트는 삭제하지 않고 **병행 운영**합니다.
> 레거시 정리는 Phase 5에서 충분한 안정화 후 별도 검토합니다.
> **연쇄관계 마이그레이션 필수**
> 관리 메뉴 삭제 전 기존 `cascading_relation` 데이터(2건)를
> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다.

View File

@ -12,12 +12,15 @@
"@types/mssql": "^9.1.8", "@types/mssql": "^9.1.8",
"axios": "^1.11.0", "axios": "^1.11.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bwip-js": "^4.8.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"docx": "^9.5.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.0",
"imap": "^0.8.19", "imap": "^0.8.19",
"joi": "^17.11.0", "joi": "^17.11.0",
@ -2256,6 +2259,93 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@oozcitak/dom": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.6.tgz",
"integrity": "sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.5",
"@oozcitak/url": "1.0.0",
"@oozcitak/util": "8.3.4"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/@oozcitak/infra": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.5.tgz",
"integrity": "sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ==",
"license": "MIT",
"dependencies": {
"@oozcitak/util": "8.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.0.tgz",
"integrity": "sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.3",
"@oozcitak/util": "1.0.2"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.3.tgz",
"integrity": "sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag==",
"license": "MIT",
"dependencies": {
"@oozcitak/util": "1.0.1"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.1.tgz",
"integrity": "sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/util": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.2.tgz",
"integrity": "sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/util": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.4.tgz",
"integrity": "sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==",
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/@paralleldrive/cuid2": { "node_modules/@paralleldrive/cuid2": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
@ -4326,6 +4416,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/browser-split": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz",
"integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==",
"license": "MIT"
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.26.2", "version": "4.26.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
@ -4445,6 +4541,15 @@
"node": ">=10.16.0" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -4521,6 +4626,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001745", "version": "1.0.30001745",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz",
@ -5202,6 +5316,56 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/docx": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz",
"integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==",
"license": "MIT",
"dependencies": {
"@types/node": "^24.0.1",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/@types/node": {
"version": "24.10.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/docx/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/docx/node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/dom-serializer": { "node_modules/dom-serializer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@ -5216,6 +5380,11 @@
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
} }
}, },
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/domelementtype": { "node_modules/domelementtype": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
@ -5349,6 +5518,27 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/ent": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
"integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.3",
"es-errors": "^1.3.0",
"punycode": "^1.4.1",
"safe-regex-test": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ent/node_modules/punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"license": "MIT"
},
"node_modules/entities": { "node_modules/entities": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@ -5361,6 +5551,16 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/error": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz",
"integrity": "sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==",
"dependencies": {
"camelize": "^1.0.0",
"string-template": "~0.2.0",
"xtend": "~4.0.0"
}
},
"node_modules/error-ex": { "node_modules/error-ex": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@ -5643,6 +5843,14 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/ev-store": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/ev-store/-/ev-store-7.0.0.tgz",
"integrity": "sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==",
"dependencies": {
"individual": "^3.0.0"
}
},
"node_modules/event-target-shim": { "node_modules/event-target-shim": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@ -6279,6 +6487,16 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/global": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
"license": "MIT",
"dependencies": {
"min-document": "^2.19.0",
"process": "^0.11.10"
}
},
"node_modules/globals": { "node_modules/globals": {
"version": "13.24.0", "version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
@ -6413,6 +6631,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -6443,6 +6671,22 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-escaper": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -6450,6 +6694,27 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/html-to-docx": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/html-to-docx/-/html-to-docx-1.8.0.tgz",
"integrity": "sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA==",
"license": "MIT",
"dependencies": {
"@oozcitak/dom": "1.15.6",
"@oozcitak/util": "8.3.4",
"color-name": "^1.1.4",
"html-entities": "^2.3.3",
"html-to-vdom": "^0.7.0",
"image-size": "^1.0.0",
"image-to-base64": "^2.2.0",
"jszip": "^3.7.1",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "^3.1.25",
"virtual-dom": "^2.1.1",
"xmlbuilder2": "2.1.2"
}
},
"node_modules/html-to-text": { "node_modules/html-to-text": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
@ -6466,6 +6731,106 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/html-to-vdom": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/html-to-vdom/-/html-to-vdom-0.7.0.tgz",
"integrity": "sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ==",
"license": "ISC",
"dependencies": {
"ent": "^2.0.0",
"htmlparser2": "^3.8.2"
}
},
"node_modules/html-to-vdom/node_modules/dom-serializer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
"integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.0.1",
"entities": "^2.0.0"
}
},
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"license": "BSD-2-Clause",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-to-vdom/node_modules/domelementtype": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==",
"license": "BSD-2-Clause"
},
"node_modules/html-to-vdom/node_modules/domhandler": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "1"
}
},
"node_modules/html-to-vdom/node_modules/domutils": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
"integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "0",
"domelementtype": "1"
}
},
"node_modules/html-to-vdom/node_modules/entities": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
"license": "BSD-2-Clause"
},
"node_modules/html-to-vdom/node_modules/htmlparser2": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
"integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
"license": "MIT",
"dependencies": {
"domelementtype": "^1.3.1",
"domhandler": "^2.3.0",
"domutils": "^1.5.1",
"entities": "^1.1.1",
"inherits": "^2.0.1",
"readable-stream": "^3.1.1"
}
},
"node_modules/html-to-vdom/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/htmlparser2": { "node_modules/htmlparser2": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@ -6590,6 +6955,30 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/image-to-base64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/image-to-base64/-/image-to-base64-2.2.0.tgz",
"integrity": "sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.0"
}
},
"node_modules/imap": { "node_modules/imap": {
"version": "0.8.19", "version": "0.8.19",
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz", "resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
@ -6626,6 +7015,12 @@
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -6673,6 +7068,11 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/individual": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/individual/-/individual-3.0.0.tgz",
"integrity": "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g=="
},
"node_modules/inflight": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@ -6854,6 +7254,15 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/is-object": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-path-inside": { "node_modules/is-path-inside": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
@ -7696,6 +8105,18 @@
"npm": ">=6" "npm": ">=6"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": { "node_modules/jwa": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
@ -7812,6 +8233,15 @@
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -8177,6 +8607,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/min-document": {
"version": "2.19.2",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
"integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
"license": "MIT",
"dependencies": {
"dom-walk": "^0.1.0"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
@ -8300,6 +8745,24 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/native-duplexpair": { "node_modules/native-duplexpair": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
@ -8329,6 +8792,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/next-tick": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.2.2.tgz",
"integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==",
"license": "MIT"
},
"node_modules/node-cron": { "node_modules/node-cron": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
@ -8670,6 +9139,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parchment": { "node_modules/parchment": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
@ -9179,6 +9654,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -9595,6 +10079,23 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"is-regex": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-stable-stringify": { "node_modules/safe-stable-stringify": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
@ -9610,6 +10111,12 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sax": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@ -9744,6 +10251,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -10020,6 +10533,11 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/string-template": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz",
"integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -10685,6 +11203,22 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/virtual-dom": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/virtual-dom/-/virtual-dom-2.1.1.tgz",
"integrity": "sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==",
"license": "MIT",
"dependencies": {
"browser-split": "0.0.1",
"error": "^4.3.0",
"ev-store": "^7.0.0",
"global": "^4.3.0",
"is-object": "^1.0.1",
"next-tick": "^0.2.2",
"x-is-array": "0.1.0",
"x-is-string": "0.1.0"
}
},
"node_modules/walker": { "node_modules/walker": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
@ -10862,6 +11396,80 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/x-is-array": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/x-is-array/-/x-is-array-0.1.0.tgz",
"integrity": "sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA=="
},
"node_modules/x-is-string": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz",
"integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w=="
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/xmlbuilder2": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-2.1.2.tgz",
"integrity": "sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==",
"license": "MIT",
"dependencies": {
"@oozcitak/dom": "1.15.5",
"@oozcitak/infra": "1.0.5",
"@oozcitak/util": "8.3.3"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.5.tgz",
"integrity": "sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.5",
"@oozcitak/url": "1.0.0",
"@oozcitak/util": "8.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom/node_modules/@oozcitak/util": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/xmlbuilder2/node_modules/@oozcitak/util": {
"version": "8.3.3",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.3.tgz",
"integrity": "sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -26,12 +26,15 @@
"@types/mssql": "^9.1.8", "@types/mssql": "^9.1.8",
"axios": "^1.11.0", "axios": "^1.11.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bwip-js": "^4.8.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"docx": "^9.5.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.0",
"imap": "^0.8.19", "imap": "^0.8.19",
"joi": "^17.11.0", "joi": "^17.11.0",

View File

@ -71,7 +71,6 @@ import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
@ -81,6 +80,7 @@ import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리 import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리 import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리 import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -249,7 +249,6 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합 app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/orders", orderRoutes); // 수주 관리
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리 app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리 app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리 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-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리 app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리 app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석

View File

@ -3245,6 +3245,7 @@ export const resetUserPassword = async (
/** /**
* ( ) * ( )
* column_labels
*/ */
export async function getTableSchema( export async function getTableSchema(
req: AuthenticatedRequest, req: AuthenticatedRequest,
@ -3264,20 +3265,25 @@ export async function getTableSchema(
logger.info("테이블 스키마 조회", { tableName, companyCode }); logger.info("테이블 스키마 조회", { tableName, companyCode });
// information_schema에서 컬럼 정보 가져오기 // information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
const schemaQuery = ` const schemaQuery = `
SELECT SELECT
column_name, ic.column_name,
data_type, ic.data_type,
is_nullable, ic.is_nullable,
column_default, ic.column_default,
character_maximum_length, ic.character_maximum_length,
numeric_precision, ic.numeric_precision,
numeric_scale ic.numeric_scale,
FROM information_schema.columns cl.column_label,
WHERE table_schema = 'public' cl.display_order
AND table_name = $1 FROM information_schema.columns ic
ORDER BY ordinal_position 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]); const columns = await query<any>(schemaQuery, [tableName]);
@ -3290,9 +3296,10 @@ export async function getTableSchema(
return; return;
} }
// 컬럼 정보를 간단한 형태로 변환 // 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
const columnList = columns.map((col: any) => ({ const columnList = columns.map((col: any) => ({
name: col.column_name, name: col.column_name,
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
type: col.data_type, type: col.data_type,
nullable: col.is_nullable === "YES", nullable: col.is_nullable === "YES",
default: col.column_default, default: col.column_default,
@ -3387,13 +3394,23 @@ export async function copyMenu(
} }
: undefined; : undefined;
// 추가 복사 옵션 (카테고리, 코드, 채번규칙 등)
const additionalCopyOptions = req.body.additionalCopyOptions
? {
copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true,
copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true,
copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true,
}
: undefined;
// 메뉴 복사 실행 // 메뉴 복사 실행
const menuCopyService = new MenuCopyService(); const menuCopyService = new MenuCopyService();
const result = await menuCopyService.copyMenu( const result = await menuCopyService.copyMenu(
parseInt(menuObjid, 10), parseInt(menuObjid, 10),
targetCompanyCode, targetCompanyCode,
userId, userId,
screenNameConfig screenNameConfig,
additionalCopyOptions
); );
logger.info("✅ 메뉴 복사 API 성공"); logger.info("✅ 메뉴 복사 API 성공");

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -424,18 +424,16 @@ export class EntityJoinController {
config.referenceTable config.referenceTable
); );
// 현재 display_column으로 사용 중인 컬럼 제외 // 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
const currentDisplayColumn = const currentDisplayColumn =
config.displayColumn || config.displayColumns[0]; config.displayColumn || config.displayColumns[0];
const availableColumns = columns.filter(
(col) => col.columnName !== currentDisplayColumn
);
// 모든 컬럼 표시 (기본 표시 컬럼도 포함)
return { return {
joinConfig: config, joinConfig: config,
tableName: config.referenceTable, tableName: config.referenceTable,
currentDisplayColumn: currentDisplayColumn, currentDisplayColumn: currentDisplayColumn,
availableColumns: availableColumns.map((col) => ({ availableColumns: columns.map((col) => ({
columnName: col.columnName, columnName: col.columnName,
columnLabel: col.displayName || col.columnName, columnLabel: col.displayName || col.columnName,
dataType: col.dataType, dataType: col.dataType,

View File

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

View File

@ -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,
});
}
};
/** /**
* ( ) * ( )
* *

View File

@ -767,11 +767,12 @@ export async function getTableData(
const tableManagementService = new TableManagementService(); const tableManagementService = new TableManagementService();
// 🆕 현재 사용자 필터 적용 // 🆕 현재 사용자 필터 적용 (autoFilter가 없거나 enabled가 명시적으로 false가 아니면 기본 적용)
let enhancedSearch = { ...search }; let enhancedSearch = { ...search };
if (autoFilter?.enabled && req.user) { const shouldApplyAutoFilter = autoFilter?.enabled !== false; // 기본값: true
const filterColumn = autoFilter.filterColumn || "company_code"; if (shouldApplyAutoFilter && req.user) {
const userField = autoFilter.userField || "companyCode"; const filterColumn = autoFilter?.filterColumn || "company_code";
const userField = autoFilter?.userField || "companyCode";
const userValue = (req.user as any)[userField]; const userValue = (req.user as any)[userField];
if (userValue) { if (userValue) {
@ -877,7 +878,17 @@ export async function addTableData(
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code"); const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
if (hasCompanyCodeColumn) { if (hasCompanyCodeColumn) {
data.company_code = companyCode; 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}`);
} }
} }

View File

@ -50,3 +50,7 @@ router.get("/data/:groupCode", getAutoFillData);
export default router; export default router;

View File

@ -46,3 +46,7 @@ router.get("/filtered-options/:relationCode", getFilteredOptions);
export default router; export default router;

View File

@ -62,3 +62,7 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions);
export default router; export default router;

View File

@ -50,3 +50,7 @@ router.get("/options/:exclusionCode", getExcludedOptions);
export default router; export default router;

View File

@ -0,0 +1,74 @@
import { Router } from "express";
import {
getCategoryValueCascadingGroups,
getCategoryValueCascadingGroupById,
getCategoryValueCascadingByCode,
createCategoryValueCascadingGroup,
updateCategoryValueCascadingGroup,
deleteCategoryValueCascadingGroup,
saveCategoryValueCascadingMappings,
getCategoryValueCascadingOptions,
getCategoryValueCascadingParentOptions,
getCategoryValueCascadingChildOptions,
getCategoryValueCascadingMappingsByTable,
} from "../controllers/categoryValueCascadingController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 모든 라우트에 인증 적용
router.use(authenticateToken);
// ============================================
// 카테고리 값 연쇄관계 그룹 CRUD
// ============================================
// 그룹 목록 조회
router.get("/groups", getCategoryValueCascadingGroups);
// 그룹 상세 조회 (ID)
router.get("/groups/:groupId", getCategoryValueCascadingGroupById);
// 관계 코드로 조회
router.get("/code/:code", getCategoryValueCascadingByCode);
// 그룹 생성
router.post("/groups", createCategoryValueCascadingGroup);
// 그룹 수정
router.put("/groups/:groupId", updateCategoryValueCascadingGroup);
// 그룹 삭제
router.delete("/groups/:groupId", deleteCategoryValueCascadingGroup);
// ============================================
// 카테고리 값 연쇄관계 매핑
// ============================================
// 매핑 일괄 저장
router.post("/groups/:groupId/mappings", saveCategoryValueCascadingMappings);
// ============================================
// 연쇄 옵션 조회 (실제 드롭다운에서 사용)
// ============================================
// 부모 카테고리 값 목록 조회
router.get("/parent-options/:code", getCategoryValueCascadingParentOptions);
// 자식 카테고리 값 목록 조회 (매핑 설정 UI용)
router.get("/child-options/:code", getCategoryValueCascadingChildOptions);
// 연쇄 옵션 조회 (부모 값 기반 자식 옵션)
router.get("/options/:code", getCategoryValueCascadingOptions);
// ============================================
// 테이블별 매핑 조회 (테이블 목록 표시용)
// ============================================
// 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회
router.get(
"/table/:tableName/mappings",
getCategoryValueCascadingMappingsByTable
);
export default router;

View File

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

View File

@ -56,6 +56,11 @@ router.post("/upload-image", upload.single("image"), (req, res, next) =>
reportController.uploadImage(req, res, next) reportController.uploadImage(req, res, next)
); );
// WORD(DOCX) 내보내기
router.post("/export-word", (req, res, next) =>
reportController.exportToWord(req, res, next)
);
// 리포트 목록 // 리포트 목록
router.get("/", (req, res, next) => router.get("/", (req, res, next) =>
reportController.getReports(req, res, next) reportController.getReports(req, res, next)

View File

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

View File

@ -1,6 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import { import {
getCategoryColumns, getCategoryColumns,
getAllCategoryColumns,
getCategoryValues, getCategoryValues,
addCategoryValue, addCategoryValue,
updateCategoryValue, updateCategoryValue,
@ -22,6 +23,10 @@ const router = Router();
// 모든 라우트에 인증 미들웨어 적용 // 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken); router.use(authenticateToken);
// 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
// 주의: 더 구체적인 라우트보다 먼저 와야 함
router.get("/all-columns", getAllCategoryColumns);
// 테이블의 카테고리 컬럼 목록 조회 // 테이블의 카테고리 컬럼 목록 조회
router.get("/:tableName/columns", getCategoryColumns); router.get("/:tableName/columns", getCategoryColumns);

View File

@ -86,11 +86,12 @@ export class CommonCodeService {
} }
// 회사별 필터링 (최고 관리자가 아닌 경우) // 회사별 필터링 (최고 관리자가 아닌 경우)
// company_code = '*'인 공통 데이터도 함께 조회
if (userCompanyCode && userCompanyCode !== "*") { if (userCompanyCode && userCompanyCode !== "*") {
whereConditions.push(`company_code = $${paramIndex}`); whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
values.push(userCompanyCode); values.push(userCompanyCode);
paramIndex++; paramIndex++;
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode}`); logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode} (공통 데이터 포함)`);
} else if (userCompanyCode === "*") { } else if (userCompanyCode === "*") {
// 최고 관리자는 모든 데이터 조회 가능 // 최고 관리자는 모든 데이터 조회 가능
logger.info(`최고 관리자: 모든 코드 카테고리 조회`); logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
@ -116,7 +117,7 @@ export class CommonCodeService {
const offset = (page - 1) * size; const offset = (page - 1) * size;
// 카테고리 조회 // code_category 테이블에서만 조회 (comm_code 제거)
const categories = await query<CodeCategory>( const categories = await query<CodeCategory>(
`SELECT * FROM code_category `SELECT * FROM code_category
${whereClause} ${whereClause}
@ -134,7 +135,7 @@ export class CommonCodeService {
const total = parseInt(countResult?.count || "0"); const total = parseInt(countResult?.count || "0");
logger.info( logger.info(
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})` `카테고리 조회 완료: code_category ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
); );
return { return {
@ -224,7 +225,7 @@ export class CommonCodeService {
paramIndex, paramIndex,
}); });
// 코드 조회 // code_info 테이블에서만 코드 조회 (comm_code fallback 제거)
const codes = await query<CodeInfo>( const codes = await query<CodeInfo>(
`SELECT * FROM code_info `SELECT * FROM code_info
${whereClause} ${whereClause}
@ -242,20 +243,9 @@ export class CommonCodeService {
const total = parseInt(countResult?.count || "0"); const total = parseInt(countResult?.count || "0");
logger.info( 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 }; return { data: codes, total };
} catch (error) { } catch (error) {
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error); logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);

View File

@ -854,6 +854,11 @@ export class DynamicFormService {
if (tableColumns.includes("updated_at")) { if (tableColumns.includes("updated_at")) {
changedFields.updated_at = new Date(); 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); console.log("🎯 실제 업데이트할 필드들:", changedFields);
@ -903,7 +908,7 @@ export class DynamicFormService {
return `${key} = $${index + 1}::numeric`; return `${key} = $${index + 1}::numeric`;
} else if (dataType === "boolean") { } else if (dataType === "boolean") {
return `${key} = $${index + 1}::boolean`; return `${key} = $${index + 1}::boolean`;
} else if (dataType === 'jsonb' || dataType === 'json') { } else if (dataType === "jsonb" || dataType === "json") {
// 🆕 JSONB/JSON 타입은 명시적 캐스팅 // 🆕 JSONB/JSON 타입은 명시적 캐스팅
return `${key} = $${index + 1}::jsonb`; return `${key} = $${index + 1}::jsonb`;
} else { } else {
@ -919,7 +924,11 @@ export class DynamicFormService {
const dataType = columnTypes[key]; const dataType = columnTypes[key];
// JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환 // 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 JSON.stringify(value);
} }
return value; return value;
@ -1588,6 +1597,7 @@ export class DynamicFormService {
/** /**
* ( ) * ( )
*
*/ */
private async executeDataflowControlIfConfigured( private async executeDataflowControlIfConfigured(
screenId: number, screenId: number,
@ -1629,29 +1639,221 @@ export class DynamicFormService {
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig, hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
hasDiagramId: hasDiagramId:
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId, !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
hasFlowControls:
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
}); });
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
if ( if (
properties?.componentType === "button-primary" && properties?.componentType === "button-primary" &&
properties?.componentConfig?.action?.type === "save" && properties?.componentConfig?.action?.type === "save" &&
properties?.webTypeConfig?.enableDataflowControl === true && properties?.webTypeConfig?.enableDataflowControl === true
properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId
) { ) {
controlConfigFound = true; const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
const diagramId =
properties.webTypeConfig.dataflowConfig.selectedDiagramId;
const relationshipId =
properties.webTypeConfig.dataflowConfig.selectedRelationshipId;
console.log(`🎯 제어관리 설정 발견:`, { // 다중 제어 설정 확인 (flowControls 배열)
const flowControls = dataflowConfig?.flowControls || [];
// flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행
if (flowControls.length > 0) {
controlConfigFound = true;
console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}`);
// 순서대로 정렬
const sortedControls = [...flowControls].sort(
(a: any, b: any) => (a.order || 0) - (b.order || 0)
);
// 다중 제어 순차 실행
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, componentId: layout.component_id,
diagramId, diagramId,
relationshipId, relationshipId,
triggerType, triggerType,
}); });
// 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주) await this.executeSingleFlowControl(
diagramId,
relationshipId,
savedData,
screenId,
tableName,
triggerType,
userId,
companyCode
);
}
// 첫 번째 설정된 버튼의 제어관리만 실행
break;
}
}
if (!controlConfigFound) {
console.log(` 제어관리 설정이 없습니다. (화면 ID: ${screenId})`);
}
} catch (error) {
console.error("❌ 제어관리 설정 확인 및 실행 오류:", error);
// 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해
}
}
/**
*
*/
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; let controlResult: any;
if (!relationshipId) { if (!relationshipId) {
@ -1691,8 +1893,7 @@ export class DynamicFormService {
console.log( console.log(
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})` `🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
); );
controlResult = controlResult = await this.dataflowControlService.executeDataflowControl(
await this.dataflowControlService.executeDataflowControl(
diagramId, diagramId,
relationshipId, relationshipId,
triggerType, triggerType,
@ -1713,31 +1914,14 @@ export class DynamicFormService {
console.log(`📊 실행된 액션들:`, controlResult.executedActions); console.log(`📊 실행된 액션들:`, controlResult.executedActions);
} }
// 오류가 있는 경우 경고 로그 출력 (성공이지만 일부 액션 실패)
if (controlResult.errors && controlResult.errors.length > 0) { if (controlResult.errors && controlResult.errors.length > 0) {
console.warn( console.warn(
`⚠️ 제어관리 실행 중 일부 오류 발생:`, `⚠️ 제어관리 실행 중 일부 오류 발생:`,
controlResult.errors controlResult.errors
); );
// 오류 정보를 별도로 저장하여 필요시 사용자에게 알림 가능
// 현재는 로그만 출력하고 메인 저장 프로세스는 계속 진행
} }
} else { } else {
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`); console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
// 제어관리 실패는 메인 저장 프로세스에 영향을 주지 않음
}
// 첫 번째 설정된 제어관리만 실행 (여러 개가 있을 경우)
break;
}
}
if (!controlConfigFound) {
console.log(` 제어관리 설정이 없습니다. (화면 ID: ${screenId})`);
}
} catch (error) {
console.error("❌ 제어관리 설정 확인 및 실행 오류:", error);
// 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해
} }
} }

View File

@ -186,8 +186,13 @@ export class EntityJoinService {
} }
} }
// 별칭 컬럼명 생성 (writer -> writer_name) // 🎯 별칭 컬럼명 생성 - 사용자가 선택한 displayColumns 기반으로 동적 생성
const aliasColumn = `${column.column_name}_name`; // 단일 컬럼: 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 = { const joinConfig: EntityJoinConfig = {
sourceTable: tableName, sourceTable: tableName,

File diff suppressed because it is too large Load Diff

View File

@ -898,9 +898,10 @@ class NumberingRuleService {
switch (part.partType) { switch (part.partType) {
case "sequence": { case "sequence": {
// 순번 (현재 순번으로 미리보기, 증가 안 함) // 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시)
const length = autoConfig.sequenceLength || 3; 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": { case "number": {
@ -958,9 +959,10 @@ class NumberingRuleService {
switch (part.partType) { switch (part.partType) {
case "sequence": { case "sequence": {
// 순번 (자동 증가 숫자) // 순번 (자동 증가 숫자 - 다음 번호 사용)
const length = autoConfig.sequenceLength || 3; 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": { case "number": {

View File

@ -477,6 +477,12 @@ export class ReportService {
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`; `;
// components가 이미 문자열이면 그대로, 객체면 JSON.stringify
const componentsData =
typeof originalLayout.components === "string"
? originalLayout.components
: JSON.stringify(originalLayout.components);
await client.query(copyLayoutQuery, [ await client.query(copyLayoutQuery, [
newLayoutId, newLayoutId,
newReportId, newReportId,
@ -487,7 +493,7 @@ export class ReportService {
originalLayout.margin_bottom, originalLayout.margin_bottom,
originalLayout.margin_left, originalLayout.margin_left,
originalLayout.margin_right, originalLayout.margin_right,
JSON.stringify(originalLayout.components), componentsData,
userId, userId,
]); ]);
} }
@ -561,7 +567,7 @@ export class ReportService {
} }
/** /**
* ( ) * ( ) -
*/ */
async saveLayout( async saveLayout(
reportId: string, reportId: string,
@ -569,6 +575,19 @@ export class ReportService {
userId: string userId: string
): Promise<boolean> { ): Promise<boolean> {
return transaction(async (client) => { return transaction(async (client) => {
// 첫 번째 페이지 정보를 기본 레이아웃으로 사용
const firstPage = data.layoutConfig.pages[0];
const canvasWidth = firstPage?.width || 210;
const canvasHeight = firstPage?.height || 297;
const pageOrientation =
canvasWidth > canvasHeight ? "landscape" : "portrait";
const margins = firstPage?.margins || {
top: 20,
bottom: 20,
left: 20,
right: 20,
};
// 1. 레이아웃 저장 // 1. 레이아웃 저장
const existingQuery = ` const existingQuery = `
SELECT layout_id FROM report_layout WHERE report_id = $1 SELECT layout_id FROM report_layout WHERE report_id = $1
@ -576,7 +595,7 @@ export class ReportService {
const existing = await client.query(existingQuery, [reportId]); const existing = await client.query(existingQuery, [reportId]);
if (existing.rows.length > 0) { if (existing.rows.length > 0) {
// 업데이트 // 업데이트 - components 컬럼에 전체 layoutConfig 저장
const updateQuery = ` const updateQuery = `
UPDATE report_layout UPDATE report_layout
SET SET
@ -594,14 +613,14 @@ export class ReportService {
`; `;
await client.query(updateQuery, [ await client.query(updateQuery, [
data.canvasWidth, canvasWidth,
data.canvasHeight, canvasHeight,
data.pageOrientation, pageOrientation,
data.marginTop, margins.top,
data.marginBottom, margins.bottom,
data.marginLeft, margins.left,
data.marginRight, margins.right,
JSON.stringify(data.components), JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
userId, userId,
reportId, reportId,
]); ]);
@ -627,14 +646,14 @@ export class ReportService {
await client.query(insertQuery, [ await client.query(insertQuery, [
layoutId, layoutId,
reportId, reportId,
data.canvasWidth, canvasWidth,
data.canvasHeight, canvasHeight,
data.pageOrientation, pageOrientation,
data.marginTop, margins.top,
data.marginBottom, margins.bottom,
data.marginLeft, margins.left,
data.marginRight, margins.right,
JSON.stringify(data.components), JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
userId, userId,
]); ]);
} }

View File

@ -1751,7 +1751,7 @@ export class ScreenManagementService {
// 기타 // 기타
label: "text-display", label: "text-display",
code: "select-basic", code: "select-basic",
entity: "select-basic", entity: "entity-search-input", // 엔티티는 entity-search-input 사용
category: "select-basic", category: "select-basic",
}; };

View File

@ -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;
}
}
/** /**
* ( ) * ( )
* *

View File

@ -1447,7 +1447,8 @@ export class TableManagementService {
tableName, tableName,
columnName, columnName,
actualValue, actualValue,
paramIndex paramIndex,
operator // operator 전달 (equals면 직접 매칭)
); );
default: default:
@ -1676,7 +1677,8 @@ export class TableManagementService {
tableName: string, tableName: string,
columnName: string, columnName: string,
value: any, value: any,
paramIndex: number paramIndex: number,
operator: string = "contains" // 연결 필터에서 "equals"로 전달되면 직접 매칭
): Promise<{ ): Promise<{
whereClause: string; whereClause: string;
values: any[]; values: any[];
@ -1688,7 +1690,7 @@ export class TableManagementService {
columnName columnName
); );
// 🆕 배열 처리: IN 절 사용 // 배열 처리: IN 절 사용
if (Array.isArray(value)) { if (Array.isArray(value)) {
if (value.length === 0) { if (value.length === 0) {
// 빈 배열이면 항상 false 조건 // 빈 배열이면 항상 false 조건
@ -1720,13 +1722,35 @@ export class TableManagementService {
} }
if (typeof value === "string" && value.trim() !== "") { 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 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 { return {
whereClause: `EXISTS ( whereClause: `EXISTS (
SELECT 1 FROM ${entityTypeInfo.referenceTable} ref SELECT 1 FROM ${referenceTable} ref
WHERE ref.${referenceColumn} = ${columnName} WHERE ref.${referenceColumn} = ${columnName}
AND ref.${displayColumn} ILIKE $${paramIndex} 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)); 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 columns = Object.keys(data);
const values = Object.values(data).map((value, index) => { const values = Object.values(data).map((value, index) => {
@ -2310,6 +2401,13 @@ export class TableManagementService {
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap)); logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys); 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 절 생성 (수정할 데이터) - 먼저 생성 // SET 절 생성 (수정할 데이터) - 먼저 생성
const setConditions: string[] = []; const setConditions: string[] = [];
const setValues: any[] = []; const setValues: any[] = [];

View File

@ -116,22 +116,55 @@ export interface UpdateReportRequest {
useYn?: string; 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 { export interface SaveLayoutRequest {
canvasWidth: number; layoutConfig: ReportLayoutConfig;
canvasHeight: number;
pageOrientation: string;
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
components: any[];
queries?: Array<{ queries?: Array<{
id: string; id: string;
name: string; name: string;
type: "MASTER" | "DETAIL"; type: "MASTER" | "DETAIL";
sqlQuery: string; sqlQuery: string;
parameters: string[]; parameters: string[];
externalConnectionId?: number;
}>; }>;
} }
@ -150,3 +183,113 @@ export interface CreateTemplateRequest {
layoutConfig?: any; layoutConfig?: any;
defaultQueries?: 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"; // 레이블 위치
}

View File

@ -582,3 +582,7 @@ const result = await executeNodeFlow(flowId, {

View File

@ -355,3 +355,7 @@
- [ ] 부모 화면에서 모달로 데이터가 전달되는가? - [ ] 부모 화면에서 모달로 데이터가 전달되는가?
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가? - [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?

View File

@ -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. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -459,11 +459,39 @@ export default function TableManagementPage() {
if (!selectedTable) return; if (!selectedTable) return;
try { 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 = { const columnSetting = {
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, // 사용자가 입력한 표시명 columnLabel: column.displayName, // 사용자가 입력한 표시명
inputType: column.inputType || "text", inputType: column.inputType || "text",
detailSettings: column.detailSettings || "", detailSettings: finalDetailSettings,
codeCategory: column.codeCategory || "", codeCategory: column.codeCategory || "",
codeValue: column.codeValue || "", codeValue: column.codeValue || "",
referenceTable: column.referenceTable || "", referenceTable: column.referenceTable || "",
@ -547,7 +575,7 @@ export default function TableManagementPage() {
} else if (successCount > 0 && failCount > 0) { } else if (successCount > 0 && failCount > 0) {
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`); toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`);
} else if (failCount > 0) { } else if (failCount > 0) {
toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`); toast.error("컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.");
} }
} else { } else {
toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)"); toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)");
@ -680,9 +708,7 @@ export default function TableManagementPage() {
console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount }); console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount });
if (totalSuccessCount > 0) { if (totalSuccessCount > 0) {
toast.success( toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`);
`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`
);
} else if (totalFailCount > 0) { } else if (totalFailCount > 0) {
toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`); toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`);
} else { } else {
@ -1000,14 +1026,15 @@ export default function TableManagementPage() {
.filter( .filter(
(table) => (table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || 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)) .every((table) => selectedTableIds.has(table.tableName))
} }
onCheckedChange={handleSelectAll} onCheckedChange={handleSelectAll}
aria-label="전체 선택" aria-label="전체 선택"
/> />
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
{selectedTableIds.size > 0 && `${selectedTableIds.size}개 선택됨`} {selectedTableIds.size > 0 && `${selectedTableIds.size}개 선택됨`}
</span> </span>
</div> </div>
@ -1048,8 +1075,8 @@ export default function TableManagementPage() {
key={table.tableName} key={table.tableName}
className={`bg-card rounded-lg p-4 shadow-sm transition-all ${ className={`bg-card rounded-lg p-4 shadow-sm transition-all ${
selectedTable === table.tableName selectedTable === table.tableName
? "shadow-md bg-muted/30" ? "bg-muted/30 shadow-md"
: "hover:shadow-lg hover:bg-muted/20" : "hover:bg-muted/20 hover:shadow-lg"
}`} }`}
style={ style={
selectedTable === table.tableName selectedTable === table.tableName
@ -1068,10 +1095,7 @@ export default function TableManagementPage() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
)} )}
<div <div className="flex-1 cursor-pointer" onClick={() => handleTableSelect(table.tableName)}>
className="flex-1 cursor-pointer"
onClick={() => handleTableSelect(table.tableName)}
>
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4> <h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
<p className="text-muted-foreground mt-1 text-xs"> <p className="text-muted-foreground mt-1 text-xs">
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")} {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="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="pr-4"></div>
<div className="px-4"></div> <div className="px-4"></div>
<div className="pr-6"> </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" 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" }} 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 className="font-mono text-sm">{column.columnName}</div>
</div> </div>
<div className="px-4"> <div className="px-4">
@ -1226,9 +1253,9 @@ export default function TableManagementPage() {
<label className="text-muted-foreground mb-1 block text-xs"> <label className="text-muted-foreground mb-1 block text-xs">
(2) (2)
</label> </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 ? ( {secondLevelMenus.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">
2 . . 2 . .
</p> </p>
) : ( ) : (
@ -1253,15 +1280,15 @@ export default function TableManagementPage() {
prev.map((col) => prev.map((col) =>
col.columnName === column.columnName col.columnName === column.columnName
? { ...col, categoryMenus: newMenus } ? { ...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 <label
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`} 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} {menu.parentMenuName} {menu.menuName}
</label> </label>
@ -1282,9 +1309,7 @@ export default function TableManagementPage() {
<> <>
{/* 참조 테이블 */} {/* 참조 테이블 */}
<div className="w-48"> <div className="w-48">
<label className="text-muted-foreground mb-1 block text-xs"> <label className="text-muted-foreground mb-1 block text-xs"> </label>
</label>
<Select <Select
value={column.referenceTable || "none"} value={column.referenceTable || "none"}
onValueChange={(value) => onValueChange={(value) =>
@ -1296,15 +1321,10 @@ export default function TableManagementPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{referenceTableOptions.map((option, index) => ( {referenceTableOptions.map((option, index) => (
<SelectItem <SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
key={`entity-${option.value}-${index}`}
value={option.value}
>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{option.label}</span> <span className="font-medium">{option.label}</span>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">{option.value}</span>
{option.value}
</span>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@ -1315,9 +1335,7 @@ export default function TableManagementPage() {
{/* 조인 컬럼 */} {/* 조인 컬럼 */}
{column.referenceTable && column.referenceTable !== "none" && ( {column.referenceTable && column.referenceTable !== "none" && (
<div className="w-48"> <div className="w-48">
<label className="text-muted-foreground mb-1 block text-xs"> <label className="text-muted-foreground mb-1 block text-xs"> </label>
</label>
<Select <Select
value={column.referenceColumn || "none"} value={column.referenceColumn || "none"}
onValueChange={(value) => onValueChange={(value) =>
@ -1361,9 +1379,7 @@ export default function TableManagementPage() {
column.referenceColumn && column.referenceColumn &&
column.referenceColumn !== "none" && ( column.referenceColumn !== "none" && (
<div className="w-48"> <div className="w-48">
<label className="text-muted-foreground mb-1 block text-xs"> <label className="text-muted-foreground mb-1 block text-xs"> </label>
</label>
<Select <Select
value={column.displayColumn || "none"} value={column.displayColumn || "none"}
onValueChange={(value) => onValueChange={(value) =>
@ -1408,7 +1424,7 @@ export default function TableManagementPage() {
column.referenceColumn !== "none" && column.referenceColumn !== "none" &&
column.displayColumn && column.displayColumn &&
column.displayColumn !== "none" && ( 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></span>
<span className="truncate"> </span> <span className="truncate"> </span>
</div> </div>
@ -1460,7 +1476,8 @@ export default function TableManagementPage() {
setDuplicateSourceTable(null); setDuplicateSourceTable(null);
}} }}
onSuccess={async (result) => { onSuccess={async (result) => {
const message = duplicateModalMode === "duplicate" const message =
duplicateModalMode === "duplicate"
? "테이블이 성공적으로 복제되었습니다!" ? "테이블이 성공적으로 복제되었습니다!"
: "테이블이 성공적으로 생성되었습니다!"; : "테이블이 성공적으로 생성되었습니다!";
toast.success(message); toast.success(message);
@ -1516,13 +1533,10 @@ export default function TableManagementPage() {
{selectedTableIds.size > 0 ? ( {selectedTableIds.size > 0 ? (
<> <>
<strong>{selectedTableIds.size}</strong> ? <strong>{selectedTableIds.size}</strong> ?
<br /> <br /> .
.
</> </>
) : ( ) : (
<> <> ? .</>
? .
</>
)} )}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -1600,4 +1614,3 @@ export default function TableManagementPage() {
</div> </div>
); );
} }

View File

@ -18,9 +18,11 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보 import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지 import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션 import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 테이블 옵션
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리 import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 높이 관리
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신 import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
function ScreenViewPage() { function ScreenViewPage() {
const params = useParams(); const params = useParams();
@ -306,11 +308,9 @@ function ScreenViewPage() {
return ( return (
<ScreenPreviewProvider isPreviewMode={false}> <ScreenPreviewProvider isPreviewMode={false}>
<ActiveTabProvider>
<TableOptionsProvider> <TableOptionsProvider>
<div <div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
ref={containerRef}
className="bg-background h-full w-full overflow-auto p-3"
>
{/* 레이아웃 준비 중 로딩 표시 */} {/* 레이아웃 준비 중 로딩 표시 */}
{!layoutReady && ( {!layoutReady && (
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br"> <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; return isButton;
}); });
topLevelComponents.forEach((component) => { topLevelComponents.forEach((component) => {
const isButton = const isButton =
(component.type === "component" && (component.type === "component" &&
@ -790,6 +789,7 @@ function ScreenViewPage() {
/> />
</div> </div>
</TableOptionsProvider> </TableOptionsProvider>
</ActiveTabProvider>
</ScreenPreviewProvider> </ScreenPreviewProvider>
); );
} }
@ -799,7 +799,9 @@ function ScreenViewPageWrapper() {
return ( return (
<TableSearchWidgetHeightProvider> <TableSearchWidgetHeightProvider>
<ScreenContextProvider> <ScreenContextProvider>
<SplitPanelProvider>
<ScreenViewPage /> <ScreenViewPage />
</SplitPanelProvider>
</ScreenContextProvider> </ScreenContextProvider>
</TableSearchWidgetHeightProvider> </TableSearchWidgetHeightProvider>
); );

View File

@ -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 ===== */ /* ===== Tailwind CSS & Animations ===== */
@import "tailwindcss"; @import "tailwindcss";

View File

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

View File

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

View File

@ -390,9 +390,11 @@ export interface RowDetailPopupConfig {
// 추가 데이터 조회 설정 // 추가 데이터 조회 설정
additionalQuery?: { additionalQuery?: {
enabled: boolean; enabled: boolean;
queryMode?: "table" | "custom"; // 조회 모드: table(테이블 조회), custom(커스텀 쿼리)
tableName: string; // 조회할 테이블명 (예: vehicles) tableName: string; // 조회할 테이블명 (예: vehicles)
matchColumn: string; // 매칭할 컬럼 (예: id) matchColumn: string; // 매칭할 컬럼 (예: id)
sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일) sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일)
customQuery?: string; // 커스텀 쿼리 ({id}, {vehicle_number} 등 파라미터 사용)
// 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시) // 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시)
displayColumns?: DisplayColumnConfig[]; displayColumns?: DisplayColumnConfig[];
}; };

View File

@ -158,7 +158,7 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
checked={popupConfig.additionalQuery?.enabled || false} checked={popupConfig.additionalQuery?.enabled || false}
onCheckedChange={(enabled) => onCheckedChange={(enabled) =>
updatePopupConfig({ updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" }, additionalQuery: { ...popupConfig.additionalQuery, enabled, queryMode: "table", tableName: "", matchColumn: "" },
}) })
} }
aria-label="추가 데이터 조회 활성화" aria-label="추가 데이터 조회 활성화"
@ -167,6 +167,30 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
{popupConfig.additionalQuery?.enabled && ( {popupConfig.additionalQuery?.enabled && (
<div className="space-y-2"> <div className="space-y-2">
{/* 조회 모드 선택 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={popupConfig.additionalQuery?.queryMode || "table"}
onValueChange={(value: "table" | "custom") =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, queryMode: value },
})
}
>
<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> <div>
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
<Input <Input
@ -206,10 +230,58 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
className="mt-1 h-8 text-xs" className="mt-1 h-8 text-xs"
/> />
</div> </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> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
{/* 테이블 모드: 기존 쿼리 결과에서 선택 */}
{popupConfig.additionalQuery?.queryMode !== "custom" && (
<>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs"> <Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
@ -274,9 +346,51 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<p className="text-muted-foreground mt-1 text-xs"> </p> <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 && ( {(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
})
}
>
</Button>
)}
</div>
</>
)}
{/* 선택된 컬럼 라벨 편집 (테이블 모드) */}
{popupConfig.additionalQuery?.queryMode !== "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -321,6 +435,63 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
</div> </div>
</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>
</div> </div>
)} )}

View File

@ -64,7 +64,50 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
// 추가 데이터 조회 설정이 있으면 실행 // 추가 데이터 조회 설정이 있으면 실행
const additionalQuery = config.rowDetailPopup?.additionalQuery; const additionalQuery = config.rowDetailPopup?.additionalQuery;
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) { if (additionalQuery?.enabled) {
const queryMode = additionalQuery.queryMode || "table";
// 커스텀 쿼리 모드
if (queryMode === "custom" && additionalQuery.customQuery) {
setDetailPopupLoading(true);
try {
// 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
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]);
} else {
setAdditionalDetailData({});
}
} catch (error) {
console.error("커스텀 쿼리 실행 실패:", error);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
// 테이블 조회 모드
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn; const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn]; const matchValue = row[sourceColumn];
@ -94,6 +137,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
} }
} }
} }
}
}, },
[config.rowDetailPopup], [config.rowDetailPopup],
); );
@ -104,9 +148,19 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
switch (format) { switch (format) {
case "date": 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": 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": case "number":
return Number(value).toLocaleString("ko-KR"); return Number(value).toLocaleString("ko-KR");
case "currency": case "currency":
@ -190,23 +244,35 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => { const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
const groups: FieldGroup[] = []; const groups: FieldGroup[] = [];
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns; 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가 있으면 해당 컬럼만, 없으면 전체 // 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
let basicFields: { column: string; label: string }[] = []; let basicFields: { column: string; label: string }[] = [];
if (displayColumns && displayColumns.length > 0) { if (displayColumns && displayColumns.length > 0) {
// DisplayColumnConfig 형식 지원 // DisplayColumnConfig 형식 지원
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
basicFields = displayColumns basicFields = displayColumns
.map((colConfig) => { .map((colConfig) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig; const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
const label = typeof colConfig === 'object' ? colConfig.label : colConfig; const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
return { column, label }; return { column, label };
}) })
.filter((item) => item.column in row); .filter((item) => item.column in mergedData);
} else {
// 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
basicFields = Object.keys(additional).map((key) => ({ column: key, label: key }));
} else { } else {
// 전체 컬럼
basicFields = Object.keys(row).map((key) => ({ column: key, label: key })); basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
} }
}
groups.push({ groups.push({
id: "basic", id: "basic",
@ -220,8 +286,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
})), })),
}); });
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 // 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
if (additional && Object.keys(additional).length > 0) { if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
// 운행 정보 // 운행 정보
if (additional.last_trip_start || additional.last_trip_end) { if (additional.last_trip_start || additional.last_trip_end) {
groups.push({ groups.push({

View File

@ -2,11 +2,24 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button"; 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import type { PlacedObject, ToolType, Warehouse, Area, Location, ObjectType } from "@/types/digitalTwin"; import type { PlacedObject, ToolType, Warehouse, Area, Location, ObjectType } from "@/types/digitalTwin";
@ -545,8 +558,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
// 레이아웃 데이터 로드 // 레이아웃 데이터 로드
const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null); const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
useEffect(() => { // 레이아웃 로드 함수
const loadLayout = async () => { const loadLayout = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@ -651,9 +665,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달) // Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
const dbConnectionId = layout.external_db_connection_id; const dbConnectionId = layout.external_db_connection_id;
const hierarchyConfigParsed = const hierarchyConfigParsed =
typeof layout.hierarchy_config === "string" typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config;
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
const materialTableName = hierarchyConfigParsed?.material?.tableName; const materialTableName = hierarchyConfigParsed?.material?.tableName;
const locationObjects = loadedObjects.filter( const locationObjects = loadedObjects.filter(
@ -686,9 +698,30 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
} }
}; };
// 위젯 새로고침 핸들러
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(); loadLayout();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); // toast 제거 }, [layoutId]);
// 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시) // 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시)
useEffect(() => { useEffect(() => {
@ -1052,7 +1085,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
}; };
// Location별 자재 개수 로드 (locaKeys를 직접 받음) // 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 connectionId = dbConnectionId || selectedDbConnection;
const tableName = materialTableName || selectedTables.material; const tableName = materialTableName || selectedTables.material;
if (!connectionId || locaKeys.length === 0) return; if (!connectionId || locaKeys.length === 0) return;
@ -1073,10 +1110,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
} }
// 백엔드 응답 필드명: location_key, count (대소문자 모두 체크) // 백엔드 응답 필드명: location_key, count (대소문자 모두 체크)
const materialCount = response.data?.find( const materialCount = response.data?.find(
(mc: any) => (mc: any) => mc.LOCAKEY === obj.locaKey || mc.location_key === obj.locaKey || mc.locakey === obj.locaKey,
mc.LOCAKEY === obj.locaKey ||
mc.location_key === obj.locaKey ||
mc.locakey === obj.locaKey
); );
if (materialCount) { if (materialCount) {
// count 또는 material_count 필드 사용 // count 또는 material_count 필드 사용
@ -1527,6 +1561,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasUnsavedChanges && <span className="text-warning text-sm font-medium"> </span>} {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}> <Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}>
{isSaving ? ( {isSaving ? (
<> <>
@ -1620,27 +1664,20 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</Button> </Button>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select <Select value={selectedTemplateId} onValueChange={(val) => setSelectedTemplateId(val)}>
value={selectedTemplateId}
onValueChange={(val) => setSelectedTemplateId(val)}
>
<SelectTrigger className="h-8 flex-1 text-xs"> <SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder={loadingTemplates ? "로딩 중..." : "템플릿 선택..."} /> <SelectValue placeholder={loadingTemplates ? "로딩 중..." : "템플릿 선택..."} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{mappingTemplates.length === 0 ? ( {mappingTemplates.length === 0 ? (
<div className="text-muted-foreground px-2 py-1 text-xs"> <div className="text-muted-foreground px-2 py-1 text-xs"> 릿 </div>
릿
</div>
) : ( ) : (
mappingTemplates.map((tpl) => ( mappingTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id} className="text-xs"> <SelectItem key={tpl.id} value={tpl.id} className="text-xs">
<div className="flex flex-col"> <div className="flex flex-col">
<span>{tpl.name}</span> <span>{tpl.name}</span>
{tpl.description && ( {tpl.description && (
<span className="text-muted-foreground text-[10px]"> <span className="text-muted-foreground text-[10px]">{tpl.description}</span>
{tpl.description}
</span>
)} )}
</div> </div>
</SelectItem> </SelectItem>
@ -1704,17 +1741,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
}} }}
onLoadColumns={async (tableName: string) => { onLoadColumns={async (tableName: string) => {
try { try {
const response = await ExternalDbConnectionAPI.getTableColumns( const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName);
selectedDbConnection,
tableName,
);
if (response.success && response.data) { if (response.success && response.data) {
// 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그) // 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그)
return response.data.map((col: any) => ({ return response.data.map((col: any) => ({
column_name: column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col),
typeof col === "string"
? col
: col.column_name || col.COLUMN_NAME || String(col),
data_type: col.data_type || col.DATA_TYPE, data_type: col.data_type || col.DATA_TYPE,
description: col.description || col.COLUMN_COMMENT || undefined, description: col.description || col.COLUMN_COMMENT || undefined,
is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY, is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY,
@ -2111,45 +2142,39 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</div> </div>
) : ( ) : (
<Accordion type="single" collapsible className="w-full"> <div className="max-h-[400px] overflow-y-auto rounded-md border">
<Table>
<TableHeader className="bg-muted sticky top-0">
<TableRow>
<TableHead className="w-[70px] whitespace-nowrap px-3 py-3 text-sm"></TableHead>
{(hierarchyConfig?.material?.displayColumns || []).map((col) => (
<TableHead key={col.column} className="px-3 py-3 text-sm">
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material, index) => { {materials.map((material, index) => {
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY"; const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY";
const displayColumns = hierarchyConfig?.material?.displayColumns || []; const displayColumns = hierarchyConfig?.material?.displayColumns || [];
const layerNumber = material[layerColumn] || index + 1;
const layerValue = material[layerColumn] || index + 1;
const keyValue = material[keyColumn] || `자재 ${index + 1}`;
return ( return (
<AccordionItem key={`${keyValue}-${index}`} value={`item-${index}`} className="border-b"> <TableRow key={material[keyColumn] || `material-${index}`}>
<AccordionTrigger className="px-2 py-3 hover:no-underline"> <TableCell className="whitespace-nowrap px-3 py-3 text-sm font-medium">{layerNumber}</TableCell>
<div className="flex w-full items-center justify-between pr-2"> {displayColumns.map((col) => (
<span className="text-sm font-medium"> {layerValue}</span> <TableCell key={col.column} className="px-3 py-3 text-sm">
<span className="text-muted-foreground max-w-[150px] truncate text-xs">{keyValue}</span> {material[col.column] || "-"}
</div> </TableCell>
</AccordionTrigger>
<AccordionContent className="px-2 pb-3">
{displayColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs">
</p>
) : (
<div className="space-y-1.5">
{displayColumns.map((item) => (
<div key={item.column} className="flex justify-between gap-2 text-xs">
<span className="text-muted-foreground shrink-0">{item.label}:</span>
<span className="text-right font-medium break-all">
{material[item.column] || "-"}
</span>
</div>
))} ))}
</div> </TableRow>
)}
</AccordionContent>
</AccordionItem>
); );
})} })}
</Accordion> </TableBody>
</Table>
</div>
)} )}
</div> </div>
) : selectedObject ? ( ) : selectedObject ? (
@ -2354,10 +2379,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
> >
</Button> </Button>
<Button <Button onClick={handleSaveTemplate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
onClick={handleSaveTemplate}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

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

View File

@ -29,7 +29,6 @@ import {
Plus, Plus,
Minus, Minus,
ArrowRight, ArrowRight,
Save,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport"; import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
@ -52,12 +51,6 @@ interface ColumnMapping {
systemColumn: string | null; systemColumn: string | null;
} }
interface UploadConfig {
name: string;
type: string;
mappings: ColumnMapping[];
}
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
open, open,
onOpenChange, onOpenChange,
@ -88,8 +81,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const [excelColumns, setExcelColumns] = useState<string[]>([]); const [excelColumns, setExcelColumns] = useState<string[]>([]);
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]); const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]); const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
const [configName, setConfigName] = useState<string>("");
const [configType, setConfigType] = useState<string>("");
// 4단계: 확인 // 4단계: 확인
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
@ -114,7 +105,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const data = await importFromExcel(selectedFile, sheets[0]); const data = await importFromExcel(selectedFile, sheets[0]);
setAllData(data); setAllData(data);
setDisplayData(data.slice(0, 10)); setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
if (data.length > 0) { if (data.length > 0) {
const columns = Object.keys(data[0]); const columns = Object.keys(data[0]);
@ -139,7 +130,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
try { try {
const data = await importFromExcel(file, sheetName); const data = await importFromExcel(file, sheetName);
setAllData(data); setAllData(data);
setDisplayData(data.slice(0, 10)); setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
if (data.length > 0) { if (data.length > 0) {
const columns = Object.keys(data[0]); const columns = Object.keys(data[0]);
@ -236,13 +227,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
}; };
// 자동 매핑 // 자동 매핑 - 컬럼명과 라벨 모두 비교
const handleAutoMapping = () => { const handleAutoMapping = () => {
const newMappings = excelColumns.map((excelCol) => { const newMappings = excelColumns.map((excelCol) => {
const matchedSystemCol = systemColumns.find( const normalizedExcelCol = excelCol.toLowerCase().trim();
(sysCol) => sysCol.name.toLowerCase() === excelCol.toLowerCase()
// 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 { return {
excelColumn: excelCol, excelColumn: excelCol,
systemColumn: matchedSystemCol ? matchedSystemCol.name : null, 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 = () => { const handleNext = () => {
if (currentStep === 1 && !file) { if (currentStep === 1 && !file) {
@ -317,7 +296,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setIsUploading(true); setIsUploading(true);
try { try {
const mappedData = displayData.map((row) => { // allData를 사용하여 전체 데이터 업로드 (displayData는 미리보기용 10개만)
const mappedData = allData.map((row) => {
const mappedRow: Record<string, any> = {}; const mappedRow: Record<string, any> = {};
columnMappings.forEach((mapping) => { columnMappings.forEach((mapping) => {
if (mapping.systemColumn) { if (mapping.systemColumn) {
@ -379,8 +359,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setExcelColumns([]); setExcelColumns([]);
setSystemColumns([]); setSystemColumns([]);
setColumnMappings([]); setColumnMappings([]);
setConfigName("");
setConfigType("");
} }
}, [open]); }, [open]);
@ -689,27 +667,25 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</div> </div>
)} )}
{/* 3단계: 컬럼 매핑 - 3단 레이아웃 */} {/* 3단계: 컬럼 매핑 */}
{currentStep === 3 && ( {currentStep === 3 && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_3fr_2fr]">
{/* 왼쪽: 컬럼 매핑 설정 제목 + 자동 매핑 버튼 */}
<div className="space-y-4"> <div className="space-y-4">
<div> {/* 상단: 제목 + 자동 매핑 버튼 */}
<h3 className="mb-3 text-sm font-semibold sm:text-base"> </h3> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
<Button <Button
type="button" type="button"
variant="default" variant="default"
size="sm" size="sm"
onClick={handleAutoMapping} onClick={handleAutoMapping}
className="h-8 w-full text-xs sm:h-9 sm:text-sm" className="h-8 text-xs sm:h-9 sm:text-sm"
> >
<Zap className="mr-2 h-4 w-4" /> <Zap className="mr-2 h-4 w-4" />
</Button> </Button>
</div> </div>
</div>
{/* 중앙: 매핑 리스트 */} {/* 매핑 리스트 */}
<div className="space-y-2"> <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 className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
<div> </div> <div> </div>
@ -734,7 +710,14 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
> >
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"> <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> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none" className="text-xs sm:text-sm"> <SelectItem value="none" className="text-xs sm:text-sm">
@ -746,7 +729,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
value={col.name} value={col.name}
className="text-xs sm:text-sm" className="text-xs sm:text-sm"
> >
{col.name} ({col.type}) {col.label || col.name} ({col.type})
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -755,50 +738,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
))} ))}
</div> </div>
</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> </div>
)} )}
@ -815,7 +754,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<span className="font-medium">:</span> {selectedSheet} <span className="font-medium">:</span> {selectedSheet}
</p> </p>
<p> <p>
<span className="font-medium"> :</span> {displayData.length} <span className="font-medium"> :</span> {allData.length}
</p> </p>
<p> <p>
<span className="font-medium">:</span> {tableName} <span className="font-medium">:</span> {tableName}

View File

@ -1,13 +1,7 @@
"use client"; "use client";
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
@ -18,6 +12,7 @@ import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext"; import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
interface ScreenModalState { interface ScreenModalState {
isOpen: boolean; isOpen: boolean;
@ -183,15 +178,66 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} else { } else {
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정 // 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
// 1순위: 이벤트로 전달된 splitPanelParentData (탭 안에서 열린 모달) // 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
// 2순위: splitPanelContext에서 직접 가져온 데이터 (분할 패널 내에서 열린 모달) // 모든 필드를 전달하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생
const parentData = // 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감
// parentDataMapping에서 명시된 필드만 추출
const parentDataMapping = splitPanelContext?.parentDataMapping || [];
// 부모 데이터 소스
const rawParentData =
splitPanelParentData && Object.keys(splitPanelParentData).length > 0 splitPanelParentData && Object.keys(splitPanelParentData).length > 0
? splitPanelParentData ? 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) { if (Object.keys(parentData).length > 0) {
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정:", parentData); console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정 (연결 필드만):", parentData);
setFormData(parentData); setFormData(parentData);
} else { } else {
setFormData({}); setFormData({});
@ -604,19 +650,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DialogTitle className="text-base">{modalState.title}</DialogTitle> <DialogTitle className="text-base">{modalState.title}</DialogTitle>
{modalState.description && !loading && ( {modalState.description && !loading && (
<DialogDescription className="text-muted-foreground text-xs"> <DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
{modalState.description}
</DialogDescription>
)} )}
{loading && ( {loading && (
<DialogDescription className="text-xs"> <DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
{loading ? "화면을 불러오는 중입니다..." : ""}
</DialogDescription>
)} )}
</div> </div>
</DialogHeader> </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 ? ( {loading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center"> <div className="text-center">
@ -625,6 +667,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div> </div>
</div> </div>
) : screenData ? ( ) : screenData ? (
<ActiveTabProvider>
<TableOptionsProvider> <TableOptionsProvider>
<div <div
className="relative mx-auto bg-white" className="relative mx-auto bg-white"
@ -697,6 +740,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
})} })}
</div> </div>
</TableOptionsProvider> </TableOptionsProvider>
</ActiveTabProvider>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p> <p className="text-muted-foreground"> .</p>

View File

@ -96,7 +96,50 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
// 추가 데이터 조회 설정이 있으면 실행 // 추가 데이터 조회 설정이 있으면 실행
const additionalQuery = config.rowDetailPopup?.additionalQuery; const additionalQuery = config.rowDetailPopup?.additionalQuery;
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) { if (additionalQuery?.enabled) {
const queryMode = additionalQuery.queryMode || "table";
// 커스텀 쿼리 모드
if (queryMode === "custom" && additionalQuery.customQuery) {
setDetailPopupLoading(true);
try {
// 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
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]);
} else {
setAdditionalDetailData({});
}
} catch (err) {
console.error("커스텀 쿼리 실행 실패:", err);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
// 테이블 조회 모드
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn; const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn]; const matchValue = row[sourceColumn];
@ -126,6 +169,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
} }
} }
} }
}
}, },
[config.rowDetailPopup], [config.rowDetailPopup],
); );
@ -136,9 +180,19 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
switch (format) { switch (format) {
case "date": 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": 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": case "number":
return Number(value).toLocaleString("ko-KR"); return Number(value).toLocaleString("ko-KR");
case "currency": case "currency":
@ -222,13 +276,21 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => { const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
const groups: FieldGroup[] = []; const groups: FieldGroup[] = [];
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns; 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가 있으면 해당 컬럼만, 없으면 전체 // 기본 정보 그룹 - 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 }[] = []; let basicFields: { column: string; label: string }[] = [];
if (displayColumns && displayColumns.length > 0) { if (displayColumns && displayColumns.length > 0) {
// DisplayColumnConfig 형식 지원 // DisplayColumnConfig 형식 지원
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
basicFields = displayColumns basicFields = displayColumns
.map((colConfig) => { .map((colConfig) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig; const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
@ -237,9 +299,15 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
}) })
.filter((item) => allKeys.includes(item.column)); .filter((item) => allKeys.includes(item.column));
} else { } else {
// 전체 컬럼 // 전체 컬럼 - 커스텀 쿼리 모드일 때는 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 })); basicFields = allKeys.map((key) => ({ column: key, label: key }));
} }
}
groups.push({ groups.push({
id: "basic", id: "basic",
@ -253,8 +321,8 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
})), })),
}); });
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 // 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
if (additional && Object.keys(additional).length > 0) { if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
// 운행 정보 // 운행 정보
if (additional.last_trip_start || additional.last_trip_end) { if (additional.last_trip_start || additional.last_trip_end) {
groups.push({ groups.push({

View File

@ -203,11 +203,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
setTripInfoLoading(identifier); setTripInfoLoading(identifier);
try { try {
// user_id 또는 vehicle_number로 조회 // user_id 또는 vehicle_number로 조회 (TIMESTAMPTZ는 변환 불필요)
const query = `SELECT const query = `SELECT
id, vehicle_number, user_id, id, vehicle_number, user_id,
last_trip_start, last_trip_end, last_trip_distance, last_trip_time, last_trip_start,
last_empty_start, last_empty_end, last_empty_distance, last_empty_time, last_trip_end,
last_trip_distance, last_trip_time,
last_empty_start,
last_empty_end,
last_empty_distance, last_empty_time,
departure, arrival, status departure, arrival, status
FROM vehicles FROM vehicles
WHERE user_id = '${identifier}' WHERE user_id = '${identifier}'
@ -277,12 +281,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
if (identifiers.length === 0) return; if (identifiers.length === 0) return;
try { try {
// 모든 마커의 운행/공차 정보를 한 번에 조회 // 모든 마커의 운행/공차 정보를 한 번에 조회 (TIMESTAMPTZ는 변환 불필요)
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", "); const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
const query = `SELECT const query = `SELECT
id, vehicle_number, user_id, id, vehicle_number, user_id,
last_trip_start, last_trip_end, last_trip_distance, last_trip_time, last_trip_start,
last_empty_start, last_empty_end, last_empty_distance, last_empty_time, last_trip_end,
last_trip_distance, last_trip_time,
last_empty_start,
last_empty_end,
last_empty_distance, last_empty_time,
departure, arrival, status departure, arrival, status
FROM vehicles FROM vehicles
WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")}) WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")})

View File

@ -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}
/>
);
}

View File

@ -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}
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,159 @@ import { useRef, useState, useEffect } from "react";
import { ComponentConfig } from "@/types/report"; import { ComponentConfig } from "@/types/report";
import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { getFullImageUrl } from "@/lib/api/client"; 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 { interface CanvasComponentProps {
component: ComponentConfig; component: ComponentConfig;
@ -23,6 +176,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
canvasWidth, canvasWidth,
canvasHeight, canvasHeight,
margins, margins,
layoutConfig,
currentPageId,
} = useReportDesigner(); } = useReportDesigner();
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
@ -100,15 +255,15 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const newX = Math.max(0, e.clientX - dragStart.x); const newX = Math.max(0, e.clientX - dragStart.x);
const newY = Math.max(0, e.clientY - dragStart.y); const newY = Math.max(0, e.clientY - dragStart.y);
// 여백을 px로 변환 (1mm ≈ 3.7795px) // 여백을 px로 변환
const marginTopPx = margins.top * 3.7795; const marginTopPx = margins.top * MM_TO_PX;
const marginBottomPx = margins.bottom * 3.7795; const marginBottomPx = margins.bottom * MM_TO_PX;
const marginLeftPx = margins.left * 3.7795; const marginLeftPx = margins.left * MM_TO_PX;
const marginRightPx = margins.right * 3.7795; const marginRightPx = margins.right * MM_TO_PX;
// 캔버스 경계 체크 (mm를 px로 변환) // 캔버스 경계 체크 (mm를 px로 변환)
const canvasWidthPx = canvasWidth * 3.7795; const canvasWidthPx = canvasWidth * MM_TO_PX;
const canvasHeightPx = canvasHeight * 3.7795; const canvasHeightPx = canvasHeight * MM_TO_PX;
// 컴포넌트가 여백 안에 있도록 제한 // 컴포넌트가 여백 안에 있도록 제한
const minX = marginLeftPx; const minX = marginLeftPx;
@ -160,12 +315,12 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const newHeight = Math.max(30, resizeStart.height + deltaY); const newHeight = Math.max(30, resizeStart.height + deltaY);
// 여백을 px로 변환 // 여백을 px로 변환
const marginRightPx = margins.right * 3.7795; const marginRightPx = margins.right * MM_TO_PX;
const marginBottomPx = margins.bottom * 3.7795; const marginBottomPx = margins.bottom * MM_TO_PX;
// 캔버스 경계 체크 // 캔버스 경계 체크
const canvasWidthPx = canvasWidth * 3.7795; const canvasWidthPx = canvasWidth * MM_TO_PX;
const canvasHeightPx = canvasHeight * 3.7795; const canvasHeightPx = canvasHeight * MM_TO_PX;
// 컴포넌트가 여백을 벗어나지 않도록 최대 크기 제한 // 컴포넌트가 여백을 벗어나지 않도록 최대 크기 제한
const maxWidth = canvasWidthPx - marginRightPx - component.x; const maxWidth = canvasWidthPx - marginRightPx - component.x;
@ -174,12 +329,41 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const boundedWidth = Math.min(newWidth, maxWidth); const boundedWidth = Math.min(newWidth, maxWidth);
const boundedHeight = Math.min(newHeight, maxHeight); const boundedHeight = Math.min(newHeight, maxHeight);
// 구분선은 방향에 따라 한 축만 조절 가능
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 적용 // Grid Snap 적용
updateComponent(component.id, { updateComponent(component.id, {
width: snapValueToGrid(boundedWidth), width: snapValueToGrid(boundedWidth),
height: snapValueToGrid(boundedHeight), height: snapValueToGrid(boundedHeight),
}); });
} }
}
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
@ -258,44 +442,20 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
switch (component.type) { switch (component.type) {
case "text": 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": case "label":
return ( 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 <div
className="h-full w-full"
style={{ style={{
fontSize: `${component.fontSize}px`, fontSize: `${component.fontSize}px`,
color: component.fontColor, color: component.fontColor,
fontWeight: component.fontWeight, fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right", textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap",
}} }}
> >
{displayValue} {displayValue}
</div> </div>
</div>
); );
case "table": case "table":
@ -317,10 +477,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
return ( return (
<div className="h-full w-full overflow-auto"> <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 <table
className="w-full border-collapse text-xs" className="w-full border-collapse text-xs"
style={{ style={{
@ -377,30 +533,26 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
// 기본 테이블 (데이터 없을 때) // 기본 테이블 (데이터 없을 때)
return ( return (
<div className="h-full w-full"> <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 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>
</div>
); );
case "image": case "image":
return ( return (
<div className="h-full w-full overflow-hidden"> <div className="h-full w-full overflow-hidden">
<div className="mb-1 text-xs text-gray-500"></div>
{component.imageUrl ? ( {component.imageUrl ? (
<img <img
src={getFullImageUrl(component.imageUrl)} src={getFullImageUrl(component.imageUrl)}
alt="이미지" alt="이미지"
style={{ style={{
width: "100%", width: "100%",
height: "calc(100% - 20px)", height: "100%",
objectFit: component.objectFit || "contain", 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> </div>
)} )}
@ -408,21 +560,23 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
); );
case "divider": 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 ( 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 <div
style={{ style={{
width: component.orientation === "horizontal" ? "100%" : `${lineWidth}px`, width: isHorizontal ? "100%" : `${dividerLineWidth}px`,
height: component.orientation === "vertical" ? "100%" : `${lineWidth}px`, height: isHorizontal ? `${dividerLineWidth}px` : "100%",
backgroundColor: lineColor, backgroundColor: dividerLineColor,
...(component.lineStyle === "dashed" && { ...(component.lineStyle === "dashed" && {
backgroundImage: `repeating-linear-gradient( backgroundImage: `repeating-linear-gradient(
${component.orientation === "horizontal" ? "90deg" : "0deg"}, ${isHorizontal ? "90deg" : "0deg"},
${lineColor} 0px, ${dividerLineColor} 0px,
${lineColor} 10px, ${dividerLineColor} 10px,
transparent 10px, transparent 10px,
transparent 20px transparent 20px
)`, )`,
@ -430,19 +584,18 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
}), }),
...(component.lineStyle === "dotted" && { ...(component.lineStyle === "dotted" && {
backgroundImage: `repeating-linear-gradient( backgroundImage: `repeating-linear-gradient(
${component.orientation === "horizontal" ? "90deg" : "0deg"}, ${isHorizontal ? "90deg" : "0deg"},
${lineColor} 0px, ${dividerLineColor} 0px,
${lineColor} 3px, ${dividerLineColor} 3px,
transparent 3px, transparent 3px,
transparent 10px transparent 10px
)`, )`,
backgroundColor: "transparent", backgroundColor: "transparent",
}), }),
...(component.lineStyle === "double" && { ...(component.lineStyle === "double" && {
boxShadow: boxShadow: isHorizontal
component.orientation === "horizontal" ? `0 ${dividerLineWidth * 2}px 0 0 ${dividerLineColor}`
? `0 ${lineWidth * 2}px 0 0 ${lineColor}` : `${dividerLineWidth * 2}px 0 0 0 ${dividerLineColor}`,
: `${lineWidth * 2}px 0 0 0 ${lineColor}`,
}), }),
}} }}
/> />
@ -457,9 +610,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<div className="mb-1 text-xs text-gray-500"></div>
<div <div
className={`flex h-[calc(100%-20px)] gap-2 ${ className={`flex h-full gap-2 ${
sigLabelPos === "top" sigLabelPos === "top"
? "flex-col" ? "flex-col"
: sigLabelPos === "bottom" : sigLabelPos === "bottom"
@ -521,8 +673,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<div className="mb-1 text-xs text-gray-500"></div> <div className="flex h-full gap-2">
<div className="flex h-[calc(100%-20px)] gap-2">
{stampPersonName && <div className="flex items-center text-xs font-medium">{stampPersonName}</div>} {stampPersonName && <div className="flex items-center text-xs font-medium">{stampPersonName}</div>}
<div className="relative flex-1"> <div className="relative flex-1">
{component.imageUrl ? ( {component.imageUrl ? (
@ -561,6 +712,454 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
</div> </div>
); );
case "pageNumber":
// 페이지 번호 포맷
const format = component.pageNumberFormat || "number";
const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order);
const currentPageIndex = sortedPages.findIndex((p) => p.page_id === currentPageId);
const totalPages = sortedPages.length;
const currentPageNum = currentPageIndex + 1;
let pageNumberText = "";
switch (format) {
case "number":
pageNumberText = `${currentPageNum}`;
break;
case "numberTotal":
pageNumberText = `${currentPageNum} / ${totalPages}`;
break;
case "koreanNumber":
pageNumberText = `${currentPageNum} 페이지`;
break;
default:
pageNumberText = `${currentPageNum}`;
}
return (
<div
className="flex h-full w-full items-center justify-center"
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
}}
>
{pageNumberText}
</div>
);
case "card":
// 카드 컴포넌트: 제목 + 항목 목록
const cardTitle = component.cardTitle || "정보 카드";
const cardItems = component.cardItems || [];
const labelWidth = component.labelWidth || 80;
const showCardTitle = component.showCardTitle !== false;
const titleFontSize = component.titleFontSize || 14;
const labelFontSize = component.labelFontSize || 13;
const valueFontSize = component.valueFontSize || 13;
const titleColor = component.titleColor || "#1e40af";
const labelColor = component.labelColor || "#374151";
const valueColor = component.valueColor || "#000000";
// 쿼리 바인딩된 값 가져오기
const getCardItemValue = (item: { label: string; value: string; fieldName?: string }) => {
if (item.fieldName && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
}
}
return item.value;
};
return (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* 제목 */}
{showCardTitle && (
<>
<div
className="flex-shrink-0 px-2 py-1 font-semibold"
style={{
fontSize: `${titleFontSize}px`,
color: titleColor,
}}
>
{cardTitle}
</div>
{/* 구분선 */}
<div
className="mx-1 flex-shrink-0 border-b"
style={{ borderColor: component.borderColor || "#e5e7eb" }}
/>
</>
)}
{/* 항목 목록 */}
<div className="flex-1 overflow-auto px-2 py-1">
{cardItems.map((item: { label: string; value: string; fieldName?: string }, index: number) => (
<div key={index} className="flex py-0.5">
<span
className="flex-shrink-0 font-medium"
style={{
width: `${labelWidth}px`,
fontSize: `${labelFontSize}px`,
color: labelColor,
}}
>
{item.label}
</span>
<span
className="flex-1"
style={{
fontSize: `${valueFontSize}px`,
color: valueColor,
}}
>
{getCardItemValue(item)}
</span>
</div>
))}
</div>
</div>
);
case "calculation":
// 계산 컴포넌트
const calcItems = component.calcItems || [];
const resultLabel = component.resultLabel || "합계";
const calcLabelWidth = component.labelWidth || 120;
const calcLabelFontSize = component.labelFontSize || 13;
const calcValueFontSize = component.valueFontSize || 13;
const calcResultFontSize = component.resultFontSize || 16;
const calcLabelColor = component.labelColor || "#374151";
const calcValueColor = component.valueColor || "#000000";
const calcResultColor = component.resultColor || "#2563eb";
const numberFormat = component.numberFormat || "currency";
const currencySuffix = component.currencySuffix || "원";
// 숫자 포맷팅 함수
const formatNumber = (num: number): string => {
if (numberFormat === "none") return String(num);
if (numberFormat === "comma") return num.toLocaleString();
if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
return String(num);
};
// 쿼리 바인딩된 값 가져오기
const getCalcItemValue = (item: {
label: string;
value: number | string;
operator: string;
fieldName?: string;
}): number => {
if (item.fieldName && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
const val = row[item.fieldName];
return typeof val === "number" ? val : parseFloat(String(val)) || 0;
}
}
return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
};
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
const calculateResult = (): number => {
if (calcItems.length === 0) return 0;
// 첫 번째 항목은 기준값
let result = getCalcItemValue(
calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string },
);
// 두 번째 항목부터 연산자 적용
for (let i = 1; i < calcItems.length; i++) {
const item = calcItems[i];
const val = getCalcItemValue(
item as { label: string; value: number | string; operator: string; fieldName?: string },
);
switch (item.operator) {
case "+":
result += val;
break;
case "-":
result -= val;
break;
case "x":
result *= val;
break;
case "÷":
result = val !== 0 ? result / val : result;
break;
}
}
return result;
};
const calcResult = calculateResult();
return (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* 항목 목록 */}
<div className="flex-1 overflow-auto px-2 py-1">
{calcItems.map(
(
item: { label: string; value: number | string; operator: string; fieldName?: string },
index: number,
) => {
const itemValue = getCalcItemValue(item);
return (
<div key={index} className="flex items-center justify-between py-1">
<span
className="flex-shrink-0"
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcLabelFontSize}px`,
color: calcLabelColor,
}}
>
{item.label}
</span>
<span
className="text-right"
style={{
fontSize: `${calcValueFontSize}px`,
color: calcValueColor,
}}
>
{formatNumber(itemValue)}
</span>
</div>
);
},
)}
</div>
{/* 구분선 */}
<div className="mx-1 flex-shrink-0 border-t" style={{ borderColor: component.borderColor || "#374151" }} />
{/* 결과 */}
<div className="flex items-center justify-between px-2 py-2">
<span
className="font-semibold"
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcResultFontSize}px`,
color: calcLabelColor,
}}
>
{resultLabel}
</span>
<span
className="text-right font-bold"
style={{
fontSize: `${calcResultFontSize}px`,
color: calcResultColor,
}}
>
{formatNumber(calcResult)}
</span>
</div>
</div>
);
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: default:
return <div> </div>; return <div> </div>;
} }
@ -569,7 +1168,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
return ( return (
<div <div
ref={componentRef} 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 isSelected
? isLocked ? isLocked
? "ring-2 ring-red-500" ? "ring-2 ring-red-500"
@ -608,8 +1207,21 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
{/* 리사이즈 핸들 (선택된 경우만, 잠금 안 된 경우만) */} {/* 리사이즈 핸들 (선택된 경우만, 잠금 안 된 경우만) */}
{isSelected && !isLocked && ( {isSelected && !isLocked && (
<div <div
className="resize-handle absolute right-0 bottom-0 h-3 w-3 cursor-se-resize rounded-full bg-blue-500" className={`resize-handle absolute h-3 w-3 rounded-full bg-blue-500 ${
style={{ transform: "translate(50%, 50%)" }} 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} onMouseDown={handleResizeStart}
/> />
)} )}

View File

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

View File

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

View File

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

View File

@ -3,10 +3,191 @@
import { useRef, useEffect } from "react"; import { useRef, useEffect } from "react";
import { useDrop } from "react-dnd"; import { useDrop } from "react-dnd";
import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { ComponentConfig } from "@/types/report"; import { ComponentConfig, WatermarkConfig } from "@/types/report";
import { CanvasComponent } from "./CanvasComponent"; import { CanvasComponent } from "./CanvasComponent";
import { Ruler } from "./Ruler"; import { Ruler } from "./Ruler";
import { v4 as uuidv4 } from "uuid"; 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() { export function ReportDesignerCanvas() {
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
@ -32,6 +213,7 @@ export function ReportDesignerCanvas() {
undo, undo,
redo, redo,
showRuler, showRuler,
layoutConfig,
} = useReportDesigner(); } = useReportDesigner();
const [{ isOver }, drop] = useDrop(() => ({ const [{ isOver }, drop] = useDrop(() => ({
@ -58,24 +240,33 @@ export function ReportDesignerCanvas() {
height = 150; height = 150;
} else if (item.componentType === "divider") { } else if (item.componentType === "divider") {
width = 300; width = 300;
height = 2; height = 10; // 선 두께 + 여백 (선택/드래그를 위한 최소 높이)
} else if (item.componentType === "signature") { } else if (item.componentType === "signature") {
width = 120; width = 120;
height = 70; height = 70;
} else if (item.componentType === "stamp") { } else if (item.componentType === "stamp") {
width = 70; width = 70;
height = 70; height = 70;
} else if (item.componentType === "pageNumber") {
width = 100;
height = 30;
} else if (item.componentType === "barcode") {
width = 200;
height = 80;
} else if (item.componentType === "checkbox") {
width = 150;
height = 30;
} }
// 여백을 px로 변환 (1mm ≈ 3.7795px) // 여백을 px로 변환
const marginTopPx = margins.top * 3.7795; const marginTopPx = margins.top * MM_TO_PX;
const marginLeftPx = margins.left * 3.7795; const marginLeftPx = margins.left * MM_TO_PX;
const marginRightPx = margins.right * 3.7795; const marginRightPx = margins.right * MM_TO_PX;
const marginBottomPx = margins.bottom * 3.7795; const marginBottomPx = margins.bottom * MM_TO_PX;
// 캔버스 경계 (px) // 캔버스 경계 (px)
const canvasWidthPx = canvasWidth * 3.7795; const canvasWidthPx = canvasWidth * MM_TO_PX;
const canvasHeightPx = canvasHeight * 3.7795; const canvasHeightPx = canvasHeight * MM_TO_PX;
// 드롭 위치 계산 (여백 내부로 제한) // 드롭 위치 계산 (여백 내부로 제한)
const rawX = x - 100; const rawX = x - 100;
@ -143,6 +334,55 @@ export function ReportDesignerCanvas() {
borderWidth: 0, borderWidth: 0,
borderColor: "#cccccc", borderColor: "#cccccc",
}), }),
// 페이지 번호 전용
...(item.componentType === "pageNumber" && {
pageNumberFormat: "number" as const, // number, numberTotal, koreanNumber
textAlign: "center" as const,
}),
// 카드 컴포넌트 전용
...(item.componentType === "card" && {
width: 300,
height: 180,
cardTitle: "정보 카드",
showCardTitle: true,
cardItems: [
{ label: "항목1", value: "내용1", fieldName: "" },
{ label: "항목2", value: "내용2", fieldName: "" },
{ label: "항목3", value: "내용3", fieldName: "" },
],
labelWidth: 80,
showCardBorder: true,
titleFontSize: 14,
labelFontSize: 13,
valueFontSize: 13,
titleColor: "#1e40af",
labelColor: "#374151",
valueColor: "#000000",
borderWidth: 1,
borderColor: "#e5e7eb",
}),
// 계산 컴포넌트 전용
...(item.componentType === "calculation" && {
width: 350,
height: 120,
calcItems: [
{ label: "공급가액", value: 0, operator: "+" as const, fieldName: "" },
{ label: "부가세 (10%)", value: 0, operator: "+" as const, fieldName: "" },
],
resultLabel: "합계 금액",
labelWidth: 120,
labelFontSize: 13,
valueFontSize: 13,
resultFontSize: 16,
labelColor: "#374151",
valueColor: "#000000",
resultColor: "#2563eb",
showCalcBorder: false,
numberFormat: "currency" as const,
currencySuffix: "원",
borderWidth: 0,
borderColor: "#e5e7eb",
}),
// 테이블 전용 // 테이블 전용
...(item.componentType === "table" && { ...(item.componentType === "table" && {
queryId: undefined, queryId: undefined,
@ -152,6 +392,26 @@ export function ReportDesignerCanvas() {
showBorder: true, showBorder: true,
rowHeight: 32, 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); addComponent(newComponent);
@ -297,13 +557,8 @@ export function ReportDesignerCanvas() {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100"> <div className="flex flex-1 flex-col overflow-hidden bg-gray-100">
{/* 작업 영역 제목 */}
<div className="border-b bg-white px-4 py-2 text-center text-sm font-medium text-gray-700">
{currentPage.page_name} ({currentPage.width} x {currentPage.height}mm)
</div>
{/* 캔버스 스크롤 영역 */} {/* 캔버스 스크롤 영역 */}
<div className="flex flex-1 items-center justify-center overflow-auto p-8"> <div className="flex flex-1 items-center justify-center overflow-auto px-8 pt-[280px] pb-8">
{/* 눈금자와 캔버스를 감싸는 컨테이너 */} {/* 눈금자와 캔버스를 감싸는 컨테이너 */}
<div className="inline-flex flex-col"> <div className="inline-flex flex-col">
{/* 좌상단 코너 + 가로 눈금자 */} {/* 좌상단 코너 + 가로 눈금자 */}
@ -329,8 +584,8 @@ export function ReportDesignerCanvas() {
}} }}
className={`relative bg-white shadow-lg ${isOver ? "ring-2 ring-blue-500" : ""}`} className={`relative bg-white shadow-lg ${isOver ? "ring-2 ring-blue-500" : ""}`}
style={{ style={{
width: `${canvasWidth}mm`, width: `${canvasWidth * MM_TO_PX}px`,
minHeight: `${canvasHeight}mm`, minHeight: `${canvasHeight * MM_TO_PX}px`,
backgroundImage: showGrid backgroundImage: showGrid
? ` ? `
linear-gradient(to right, #e5e7eb 1px, transparent 1px), linear-gradient(to right, #e5e7eb 1px, transparent 1px),
@ -346,14 +601,23 @@ export function ReportDesignerCanvas() {
<div <div
className="pointer-events-none absolute border-2 border-dashed border-blue-300/50" className="pointer-events-none absolute border-2 border-dashed border-blue-300/50"
style={{ style={{
top: `${currentPage.margins.top}mm`, top: `${currentPage.margins.top * MM_TO_PX}px`,
left: `${currentPage.margins.left}mm`, left: `${currentPage.margins.left * MM_TO_PX}px`,
right: `${currentPage.margins.right}mm`, right: `${currentPage.margins.right * MM_TO_PX}px`,
bottom: `${currentPage.margins.bottom}mm`, 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) => ( {alignmentGuides.vertical.map((x, index) => (
<div <div

File diff suppressed because it is too large Load Diff

View File

@ -8,9 +8,12 @@ interface RulerProps {
offset?: number; // 스크롤 오프셋 (px) offset?: number; // 스크롤 오프셋 (px)
} }
// 고정 스케일 팩터 (화면 해상도와 무관)
const MM_TO_PX = 4;
export function Ruler({ orientation, length, offset = 0 }: RulerProps) { export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
// mm를 px로 변환 (1mm = 3.7795px, 96dpi 기준) // mm를 px로 변환
const mmToPx = (mm: number) => mm * 3.7795; const mmToPx = (mm: number) => mm * MM_TO_PX;
const lengthPx = mmToPx(length); const lengthPx = mmToPx(length);
const isHorizontal = orientation === "horizontal"; const isHorizontal = orientation === "horizontal";

View File

@ -13,17 +13,17 @@ interface SignatureGeneratorProps {
onSignatureSelect: (dataUrl: string) => void; onSignatureSelect: (dataUrl: string) => void;
} }
// 서명용 손글씨 폰트 목록 (스타일이 확실히 구분되는 폰트들) // 서명용 손글씨 폰트 목록 (완전한 한글 지원 폰트만 사용)
const SIGNATURE_FONTS = { const SIGNATURE_FONTS = {
korean: [ korean: [
{ name: "나눔손글씨 붓", style: "'Nanum Brush Script', cursive", weight: 400 }, { name: "나눔손글씨 붓", style: "'Nanum Brush Script', cursive", weight: 400 },
{ name: "나눔손글씨 펜", style: "'Nanum Pen Script', cursive", weight: 400 }, { name: "나눔손글씨 펜", style: "'Nanum Pen Script', cursive", weight: 400 },
{ name: "배달의민족 도현", style: "Dokdo, cursive", weight: 400 }, { name: "손글씨 (Gaegu)", style: "Gaegu, cursive", weight: 700 },
{ name: "귀여운", style: "Gugi, cursive", weight: 400 }, { name: "하이멜로디", style: "'Hi Melody', cursive", weight: 400 },
{ name: "싱글데이", style: "'Single Day', cursive", weight: 400 }, { name: "감자꽃", style: "'Gamja Flower', cursive", weight: 400 },
{ name: "스타일리시", style: "Stylish, cursive", weight: 400 }, { name: "푸어스토리", style: "'Poor Story', cursive", weight: 400 },
{ name: "해바라기", style: "Sunflower, sans-serif", weight: 700 }, { name: "도현", style: "'Do Hyeon', sans-serif", weight: 400 },
{ name: "손글씨", style: "Gaegu, cursive", weight: 700 }, { name: "주아", style: "Jua, sans-serif", weight: 400 },
], ],
english: [ english: [
{ name: "Allura (우아한)", style: "Allura, cursive", weight: 400 }, { name: "Allura (우아한)", style: "Allura, cursive", weight: 400 },

View File

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

View File

@ -40,6 +40,7 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [screenInfo, setScreenInfo] = useState<any>(null); const [screenInfo, setScreenInfo] = useState<any>(null);
const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작 const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작
const [formDataVersion, setFormDataVersion] = useState(0); // 🆕 폼 데이터 버전 (강제 리렌더링용)
// 컴포넌트 참조 맵 // 컴포넌트 참조 맵
const componentRefs = useRef<Map<string, DataReceivable>>(new Map()); const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
@ -47,6 +48,10 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용) // 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
const splitPanelContext = useSplitPanelContext(); const splitPanelContext = useSplitPanelContext();
// 🆕 selectedLeftData 참조 안정화 (실제 값이 바뀔 때만 업데이트)
const selectedLeftData = splitPanelContext?.selectedLeftData;
const prevSelectedLeftDataRef = useRef<string>("");
// 🆕 사용자 정보 가져오기 (저장 액션에 필요) // 🆕 사용자 정보 가져오기 (저장 액션에 필요)
const { userId, userName, companyCode } = useAuth(); const { userId, userName, companyCode } = useAuth();
@ -71,7 +76,6 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
// 필드 값 변경 핸들러 // 필드 값 변경 핸들러
const handleFieldChange = useCallback((fieldName: string, value: any) => { const handleFieldChange = useCallback((fieldName: string, value: any) => {
console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value });
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
[fieldName]: value, [fieldName]: value,
@ -83,35 +87,55 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
loadScreenData(); loadScreenData();
}, [embedding.childScreenId]); }, [embedding.childScreenId]);
// 🆕 initialFormData 변경 시 formData 업데이트 (수정 모드) // initialFormData 변경 시 formData 업데이트 (수정 모드)
useEffect(() => { useEffect(() => {
if (initialFormData && Object.keys(initialFormData).length > 0) { if (initialFormData && Object.keys(initialFormData).length > 0) {
console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData);
setFormData(initialFormData); setFormData(initialFormData);
} }
}, [initialFormData]); }, [initialFormData]);
// 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영 // 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) // 🆕 좌측 선택 데이터가 변경되면 우측 formData를 업데이트
useEffect(() => { useEffect(() => {
// 우측 화면인 경우에만 적용 // 우측 화면인 경우에만 적용
if (position !== "right" || !splitPanelContext) return; if (position !== "right" || !splitPanelContext) {
// 자동 데이터 전달이 비활성화된 경우 스킵
if (splitPanelContext.disableAutoDataTransfer) {
console.log("🔗 [EmbeddedScreen] 자동 데이터 전달 비활성화됨 - 버튼 클릭으로만 전달");
return; return;
} }
const mappedData = splitPanelContext.getMappedParentData(); // 자동 데이터 전달이 비활성화된 경우 스킵
if (Object.keys(mappedData).length > 0) { if (splitPanelContext.disableAutoDataTransfer) {
console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData); return;
setFormData((prev) => ({
...prev,
...mappedData,
}));
} }
}, [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(() => { useEffect(() => {
@ -128,13 +152,6 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
// 화면 정보 로드 (screenApi.getScreen은 직접 ScreenDefinition 객체를 반환) // 화면 정보 로드 (screenApi.getScreen은 직접 ScreenDefinition 객체를 반환)
const screenData = await screenApi.getScreen(embedding.childScreenId); 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) { if (screenData) {
setScreenInfo(screenData); setScreenInfo(screenData);
} else { } else {
@ -399,11 +416,7 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
}; };
return ( return (
<div <div key={`${component.id}-${formDataVersion}`} className="absolute" style={componentStyle}>
key={component.id}
className="absolute"
style={componentStyle}
>
<DynamicComponentRenderer <DynamicComponentRenderer
component={component} component={component}
isInteractive={true} isInteractive={true}

View File

@ -27,29 +27,12 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp
// config에서 splitRatio 추출 (기본값 50) // config에서 splitRatio 추출 (기본값 50)
const configSplitRatio = 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 상태 // 드래그로 조절 가능한 splitRatio 상태
const [splitRatio, setSplitRatio] = useState(configSplitRatio); const [splitRatio, setSplitRatio] = useState(configSplitRatio);
// config.splitRatio가 변경되면 동기화 (설정 패널에서 변경 시) // config.splitRatio가 변경되면 동기화 (설정 패널에서 변경 시)
React.useEffect(() => { React.useEffect(() => {
console.log("📐 [ScreenSplitPanel] splitRatio 동기화:", { configSplitRatio, currentSplitRatio: splitRatio });
setSplitRatio(configSplitRatio); setSplitRatio(configSplitRatio);
}, [configSplitRatio]); }, [configSplitRatio]);

View File

@ -26,12 +26,56 @@ interface EditModalState {
onSave?: () => void; onSave?: () => void;
groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"]) groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"])
tableName?: string; // 🆕 테이블명 (그룹 조회용) tableName?: string; // 🆕 테이블명 (그룹 조회용)
buttonConfig?: any; // 🆕 버튼 설정 (제어로직 실행용)
buttonContext?: any; // 🆕 버튼 컨텍스트 (screenId, userId 등)
saveButtonConfig?: {
enableDataflowControl?: boolean;
dataflowConfig?: any;
dataflowTiming?: string;
}; // 🆕 모달 내부 저장 버튼의 제어로직 설정
} }
interface EditModalProps { interface EditModalProps {
className?: string; 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 }) => { export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const { user } = useAuth(); const { user } = useAuth();
const [modalState, setModalState] = useState<EditModalState>({ const [modalState, setModalState] = useState<EditModalState>({
@ -44,6 +88,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
onSave: undefined, onSave: undefined,
groupByColumns: undefined, groupByColumns: undefined,
tableName: undefined, tableName: undefined,
buttonConfig: undefined,
buttonContext: undefined,
saveButtonConfig: undefined,
}); });
const [screenData, setScreenData] = useState<{ const [screenData, setScreenData] = useState<{
@ -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(() => { useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => { const handleOpenEditModal = async (event: CustomEvent) => {
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } = event.detail; 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({ setModalState({
isOpen: true, isOpen: true,
@ -130,13 +255,16 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
onSave, onSave,
groupByColumns, // 🆕 그룹핑 컬럼 groupByColumns, // 🆕 그룹핑 컬럼
tableName, // 🆕 테이블명 tableName, // 🆕 테이블명
buttonConfig, // 🆕 버튼 설정
buttonContext, // 🆕 버튼 컨텍스트
saveButtonConfig, // 🆕 모달 내부 저장 버튼의 제어로직 설정
}); });
// 편집 데이터로 폼 데이터 초기화 // 편집 데이터로 폼 데이터 초기화
setFormData(editData || {}); setFormData(editData || {});
// 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드) // 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드)
// originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨 // originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨
setOriginalData(isCreateMode ? {} : (editData || {})); setOriginalData(isCreateMode ? {} : editData || {});
if (isCreateMode) { if (isCreateMode) {
console.log("[EditModal] 생성 모드로 열림, 초기값:", editData); console.log("[EditModal] 생성 모드로 열림, 초기값:", editData);
@ -176,7 +304,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
loadGroupData(); loadGroupData();
} }
} }
}, [modalState.isOpen, modalState.screenId]); }, [modalState.isOpen, modalState.screenId, modalState.groupByColumns, modalState.tableName]);
// 🆕 그룹 데이터 조회 함수 // 🆕 그룹 데이터 조회 함수
const loadGroupData = async () => { const loadGroupData = async () => {
@ -225,7 +353,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const dataArray = Array.isArray(response) ? response : response?.data || []; const dataArray = Array.isArray(response) ? response : response?.data || [];
if (dataArray.length > 0) { if (dataArray.length > 0) {
console.log("✅ 그룹 데이터 조회 성공:", dataArray); console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건");
setGroupData(dataArray); setGroupData(dataArray);
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`); toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
@ -464,9 +592,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
for (const currentData of groupData) { for (const currentData of groupData) {
if (currentData.id) { if (currentData.id) {
// id 기반 매칭 (인덱스 기반 X) // id 기반 매칭 (인덱스 기반 X)
const originalItemData = originalGroupData.find( const originalItemData = originalGroupData.find((orig) => orig.id === currentData.id);
(orig) => orig.id === currentData.id
);
if (!originalItemData) { if (!originalItemData) {
console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`); console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`);
@ -539,9 +665,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 3⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목) // 3⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목)
const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean)); const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean));
const deletedItems = originalGroupData.filter( const deletedItems = originalGroupData.filter((orig) => orig.id && !currentIds.has(orig.id));
(orig) => orig.id && !currentIds.has(orig.id)
);
for (const deletedItem of deletedItems) { for (const deletedItem of deletedItems) {
console.log("🗑️ 품목 삭제:", deletedItem); console.log("🗑️ 품목 삭제:", deletedItem);
@ -549,7 +673,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
try { try {
const response = await dynamicFormApi.deleteFormDataFromTable( const response = await dynamicFormApi.deleteFormDataFromTable(
deletedItem.id, deletedItem.id,
screenData.screenInfo.tableName screenData.screenInfo.tableName,
); );
if (response.success) { 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(); handleClose();
} else { } else {
toast.info("변경된 내용이 없습니다."); toast.info("변경된 내용이 없습니다.");
@ -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(); handleClose();
} else { } else {
throw new Error(response.message || "생성에 실패했습니다."); 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(); handleClose();
} else { } else {
throw new Error(response.message || "수정에 실패했습니다."); throw new Error(response.message || "수정에 실패했습니다.");
@ -701,10 +927,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
return ( return (
<Dialog open={modalState.isOpen} onOpenChange={handleClose}> <Dialog open={modalState.isOpen} onOpenChange={handleClose}>
<DialogContent <DialogContent className={`${modalStyle.className} ${className || ""} max-w-none`} style={modalStyle.style}>
className={`${modalStyle.className} ${className || ""} max-w-none`}
style={modalStyle.style}
>
<DialogHeader className="shrink-0 border-b px-4 py-3"> <DialogHeader className="shrink-0 border-b px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle> <DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
@ -717,7 +940,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div> </div>
</DialogHeader> </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 ? ( {loading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center"> <div className="text-center">
@ -751,15 +974,20 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}, },
}; };
// 🔍 디버깅: 컴포넌트 렌더링 시점의 groupData 확인 const groupedDataProp = groupData.length > 0 ? groupData : undefined;
if (component.id === screenData.components[0]?.id) {
console.log("🔍 [EditModal] InteractiveScreenViewerDynamic props:", { // 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
componentId: component.id, // 최상위 컴포넌트 또는 조건부 컨테이너 내부 화면에 universal-form-modal이 있는지 확인
groupDataLength: groupData.length, const hasUniversalFormModal = screenData.components.some(
groupData: groupData, (c) => {
formData: groupData.length > 0 ? groupData[0] : formData, // 최상위에 universal-form-modal이 있는 경우
}); if (c.componentType === "universal-form-modal") return true;
// 조건부 컨테이너 내부에 universal-form-modal이 있는 경우
// (조건부 컨테이너가 있으면 내부 화면에서 universal-form-modal을 사용하는 것으로 가정)
if (c.componentType === "conditional-container") return true;
return false;
} }
);
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가 // 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
const enrichedFormData = { const enrichedFormData = {
@ -782,6 +1010,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
component={adjustedComponent} component={adjustedComponent}
allComponents={screenData.components} allComponents={screenData.components}
formData={enrichedFormData} formData={enrichedFormData}
originalData={originalData} // 🆕 원본 데이터 전달 (수정 모드에서 UniversalFormModal 초기화용)
onFormDataChange={(fieldName, value) => { onFormDataChange={(fieldName, value) => {
// 🆕 그룹 데이터가 있으면 처리 // 🆕 그룹 데이터가 있으면 처리
if (groupData.length > 0) { if (groupData.length > 0) {
@ -794,7 +1023,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
prev.map((item) => ({ prev.map((item) => ({
...item, ...item,
[fieldName]: value, [fieldName]: value,
})) })),
); );
} }
} else { } else {
@ -808,10 +1037,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
id: modalState.screenId!, id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName, tableName: screenData.screenInfo?.tableName,
}} }}
onSave={handleSave} // 🆕 UniversalFormModal이 있으면 onSave 전달 안 함 (자체 저장 로직 사용)
// ModalRepeaterTable만 있으면 기존대로 onSave 전달 (호환성 유지)
onSave={hasUniversalFormModal ? undefined : handleSave}
isInModal={true} isInModal={true}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달 // 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupData.length > 0 ? groupData : undefined} groupedData={groupedDataProp}
// 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처) // 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
disabledFields={["order_no", "partner_id"]} disabledFields={["order_no", "partner_id"]}
/> />

View File

@ -267,7 +267,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
{/* 컨텐츠 */} {/* 컨텐츠 */}
<div <div
ref={contentRef} 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={ style={
autoHeight autoHeight
? {} ? {}

View File

@ -55,6 +55,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useTableOptions } from "@/contexts/TableOptionsContext"; import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options"; import { TableFilter, ColumnVisibility } from "@/types/table-options";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management"; import { CascadingDropdownConfig } from "@/types/screen-management";
@ -184,6 +185,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const { user } = useAuth(); // 사용자 정보 가져오기 const { user } = useAuth(); // 사용자 정보 가져오기
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅 const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용)
const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치
const [data, setData] = useState<Record<string, any>[]>([]); const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -308,6 +311,41 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}; };
}, [currentPage, searchValues, loadData, component.tableName]); }, [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(() => { useEffect(() => {
const loadCategoryMappings = async () => { const loadCategoryMappings = async () => {
@ -702,10 +740,17 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
return; return;
} }
// 🆕 RelatedDataButtons 필터 적용
let relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
}
// 검색 파라미터와 연결 필터 병합 // 검색 파라미터와 연결 필터 병합
const mergedSearchParams = { const mergedSearchParams = {
...searchParams, ...searchParams,
...linkedFilterValues, ...linkedFilterValues,
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
}; };
console.log("🔍 데이터 조회 시작:", { console.log("🔍 데이터 조회 시작:", {
@ -713,6 +758,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
page, page,
pageSize, pageSize,
linkedFilterValues, linkedFilterValues,
relatedButtonFilterValues,
mergedSearchParams, mergedSearchParams,
}); });
@ -819,7 +865,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setLoading(false); 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; 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( const handleSelectAll = useCallback(
@ -2144,12 +2201,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const mapping = categoryMappings[column.columnName]; const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)]; const categoryData = mapping?.[String(value)];
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상 // 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값만 텍스트로 표시
const displayLabel = categoryData?.label || String(value); const displayLabel = categoryData?.label || String(value);
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상 const displayColor = categoryData?.color;
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시 // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (displayColor === "none") { if (!displayColor || displayColor === "none" || !categoryData) {
return <span className="text-sm">{displayLabel}</span>; return <span className="text-sm">{displayLabel}</span>;
} }

View File

@ -50,6 +50,8 @@ import { cn } from "@/lib/utils";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar"; import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
/** /**
* 🔗 * 🔗
@ -2101,6 +2103,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
: component; : component;
return ( return (
<SplitPanelProvider>
<ActiveTabProvider>
<TableOptionsProvider> <TableOptionsProvider>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* 테이블 옵션 툴바 */} {/* 테이블 옵션 툴바 */}
@ -2209,5 +2213,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</TableOptionsProvider> </TableOptionsProvider>
</ActiveTabProvider>
</SplitPanelProvider>
); );
}; };

View File

@ -39,22 +39,25 @@ interface InteractiveScreenViewerProps {
id: number; id: number;
tableName?: string; tableName?: string;
}; };
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용) menuObjid?: number; // 메뉴 OBJID (코드 스코프용)
onSave?: () => Promise<void>; onSave?: () => Promise<void>;
onRefresh?: () => void; onRefresh?: () => void;
onFlowRefresh?: () => void; onFlowRefresh?: () => void;
// 🆕 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용) // 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용)
userId?: string; userId?: string;
userName?: string; userName?: string;
companyCode?: string; companyCode?: string;
// 🆕 그룹 데이터 (EditModal에서 전달) // 그룹 데이터 (EditModal에서 전달)
groupedData?: Record<string, any>[]; groupedData?: Record<string, any>[];
// 🆕 비활성화할 필드 목록 (EditModal에서 전달) // 비활성화할 필드 목록 (EditModal에서 전달)
disabledFields?: string[]; disabledFields?: string[];
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록) // EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
isInModal?: boolean; isInModal?: boolean;
// 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용) // 원본 데이터 (수정 모드에서 UPDATE 판단용)
originalData?: Record<string, any> | null; originalData?: Record<string, any> | null;
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
} }
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
@ -74,7 +77,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
groupedData, groupedData,
disabledFields = [], disabledFields = [],
isInModal = false, isInModal = false,
originalData, // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용) originalData,
parentTabId,
parentTabsComponentId,
}) => { }) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { userName: authUserName, user: authUser } = useAuth(); const { userName: authUserName, user: authUser } = useAuth();
@ -359,43 +364,43 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
component={comp} component={comp}
isInteractive={true} isInteractive={true}
formData={formData} formData={formData}
originalData={originalData || undefined} // 🆕 원본 데이터 전달 (UPDATE 판단용) originalData={originalData || undefined}
onFormDataChange={handleFormDataChange} onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id} screenId={screenInfo?.id}
tableName={screenInfo?.tableName} tableName={screenInfo?.tableName}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 menuObjid={menuObjid}
userId={user?.userId} // ✅ 사용자 ID 전달 userId={user?.userId}
userName={user?.userName} // ✅ 사용자 이름 전달 userName={user?.userName}
companyCode={user?.companyCode} // ✅ 회사 코드 전달 companyCode={user?.companyCode}
onSave={onSave} // 🆕 EditModal의 handleSave 콜백 전달 onSave={onSave}
allComponents={allComponents} // 🆕 같은 화면의 모든 컴포넌트 전달 (TableList 자동 감지용) allComponents={allComponents}
selectedRowsData={selectedRowsData} selectedRowsData={selectedRowsData}
onSelectedRowsChange={(selectedRows, selectedData) => { onSelectedRowsChange={(selectedRows, selectedData) => {
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData); console.log("테이블에서 선택된 행 데이터:", selectedData);
setSelectedRowsData(selectedData); setSelectedRowsData(selectedData);
}} }}
// 🆕 그룹 데이터 전달 (EditModal → ModalRepeaterTable)
groupedData={groupedData} groupedData={groupedData}
// 🆕 비활성화 필드 전달 (EditModal → 각 컴포넌트)
disabledFields={disabledFields} disabledFields={disabledFields}
flowSelectedData={flowSelectedData} flowSelectedData={flowSelectedData}
flowSelectedStepId={flowSelectedStepId} flowSelectedStepId={flowSelectedStepId}
onFlowSelectedDataChange={(selectedData, stepId) => { onFlowSelectedDataChange={(selectedData, stepId) => {
console.log("🔍 플로우에서 선택된 데이터:", { selectedData, stepId }); console.log("플로우에서 선택된 데이터:", { selectedData, stepId });
setFlowSelectedData(selectedData); setFlowSelectedData(selectedData);
setFlowSelectedStepId(stepId); setFlowSelectedStepId(stepId);
}} }}
onRefresh={ onRefresh={
onRefresh || onRefresh ||
(() => { (() => {
// 부모로부터 전달받은 onRefresh 또는 기본 동작 console.log("InteractiveScreenViewerDynamic onRefresh 호출");
console.log("🔄 InteractiveScreenViewerDynamic onRefresh 호출");
}) })
} }
onFlowRefresh={onFlowRefresh} onFlowRefresh={onFlowRefresh}
onClose={() => { onClose={() => {
// buttonActions.ts가 이미 처리함 // 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 () => { const handleClick = async () => {
try { try {
const actionType = config?.actionType || "save"; const actionType = config?.actionType || "save";
@ -604,6 +822,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
case "custom": case "custom":
await handleCustomAction(); await handleCustomAction();
break; break;
case "quickInsert":
await handleQuickInsertAction();
break;
default: default:
// console.log("🔘 기본 버튼 클릭"); // console.log("🔘 기본 버튼 클릭");
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types"; import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
import { isFileComponent } from "@/lib/utils/componentTypeUtils"; import { isFileComponent } from "@/lib/utils/componentTypeUtils";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -14,6 +14,7 @@ import { FileUpload } from "./widgets/FileUpload";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry"; import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate"; import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate";
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import { import {
Database, Database,
Type, Type,
@ -253,7 +254,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
// 플로우 위젯의 실제 높이 측정 // 플로우 위젯의 실제 높이 측정
useEffect(() => { 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) { if (isFlowWidget && contentRef.current) {
const measureHeight = () => { const measureHeight = () => {
@ -400,12 +402,118 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
}, [component.id, fileUpdateTrigger]); }, [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 isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper";
const positionX = position?.x || 0; const positionX = position?.x || 0;
const positionY = position?.y || 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 (픽셀) // 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
const getWidth = () => { const getWidth = () => {
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값) // 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
@ -437,18 +545,22 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
const componentStyle = { const componentStyle = {
position: "absolute" as const, position: "absolute" as const,
...style, // 먼저 적용하고 ...style, // 먼저 적용하고
left: positionX, left: adjustedPositionX, // 🆕 분할 패널 위 버튼은 조정된 X 좌표 사용
top: positionY, top: positionY,
width: getWidth(), // 우선순위에 따른 너비 width: getWidth(), // 우선순위에 따른 너비
height: getHeight(), // 우선순위에 따른 높이 height: getHeight(), // 우선순위에 따른 높이
zIndex: position?.z || 1, zIndex: position?.z || 1,
// right 속성 강제 제거 // right 속성 강제 제거
right: undefined, right: undefined,
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
transition:
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
}; };
// 선택된 컴포넌트 스타일 // 선택된 컴포넌트 스타일
// Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거 // Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거
const selectionStyle = isSelected && !isSectionPaper const selectionStyle =
isSelected && !isSectionPaper
? { ? {
outline: "2px solid rgb(59, 130, 246)", outline: "2px solid rgb(59, 130, 246)",
outlineOffset: "2px", outlineOffset: "2px",
@ -481,10 +593,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
{/* 컴포넌트 타입별 렌더링 */} {/* 컴포넌트 타입별 렌더링 */}
<div <div ref={isFlowWidget ? contentRef : undefined} className="h-full w-full">
ref={isFlowWidget ? contentRef : undefined}
className="h-full w-full"
>
{/* 영역 타입 */} {/* 영역 타입 */}
{type === "area" && renderArea(component, children)} {type === "area" && renderArea(component, children)}
@ -549,16 +658,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
return ( return (
<div className="h-auto w-full"> <div className="h-auto w-full">
<FlowWidget <FlowWidget component={flowComponent as any} onSelectedDataChange={onFlowSelectedDataChange} />
component={flowComponent as any}
onSelectedDataChange={onFlowSelectedDataChange}
/>
</div> </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("🎯 탭 컴포넌트 조건 충족:", { console.log("🎯 탭 컴포넌트 조건 충족:", {
type, type,
@ -590,9 +699,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
<Badge key={tab.id} variant="outline" className="text-xs"> <Badge key={tab.id} variant="outline" className="text-xs">
{tab.label || `${index + 1}`} {tab.label || `${index + 1}`}
{tab.screenName && ( {tab.screenName && (
<span className="ml-1 text-[10px] text-gray-400"> <span className="ml-1 text-[10px] text-gray-400">({tab.screenName})</span>
({tab.screenName})
</span>
)} )}
</Badge> </Badge>
))} ))}
@ -632,7 +739,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
)} )}
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */} {/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
{type === "component" && (() => { {type === "component" &&
(() => {
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer"); const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
return ( return (
<DynamicComponentRenderer <DynamicComponentRenderer

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React from "react"; import React, { useMemo } from "react";
import { ComponentData, WebType, WidgetComponent } from "@/types/screen"; import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { import {
@ -16,6 +16,7 @@ import {
Building, Building,
File, File,
} from "lucide-react"; } from "lucide-react";
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
// 컴포넌트 렌더러들 자동 등록 // 컴포넌트 렌더러들 자동 등록
import "@/lib/registry/components"; import "@/lib/registry/components";
@ -262,14 +263,145 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
} }
: component; : 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 = { const baseStyle = {
left: `${position.x}px`, left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용
top: `${position.y}px`, top: `${position.y}px`,
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨) ...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
width: getWidth(), // getWidth() 우선 (table-list 등 특수 케이스) width: getWidth(), // getWidth() 우선 (table-list 등 특수 케이스)
height: getHeight(), // getHeight() 우선 (flow-widget 등 특수 케이스) height: getHeight(), // getHeight() 우선 (flow-widget 등 특수 케이스)
zIndex: component.type === "layout" ? 1 : position.z || 2, zIndex: component.type === "layout" ? 1 : position.z || 2,
right: undefined, right: undefined,
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
transition:
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
}; };
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제) // 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)

View File

@ -101,6 +101,46 @@ export const SaveModal: React.FC<SaveModalProps> = ({
}; };
}, [onClose]); }, [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 () => { const handleSave = async () => {
if (!screenData || !screenId) return; if (!screenData || !screenId) return;
@ -111,6 +151,13 @@ export const SaveModal: React.FC<SaveModalProps> = ({
return; return;
} }
// ✅ 필수 항목 검증
const validation = validateRequiredFields();
if (!validation.isValid) {
toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`);
return;
}
try { try {
setIsSaving(true); setIsSaving(true);

View File

@ -958,6 +958,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
codeCategory: col.codeCategory || col.code_category, codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value, codeValue: col.codeValue || col.code_value,
// 엔티티 타입용 참조 테이블 정보
referenceTable: col.referenceTable || col.reference_table,
referenceColumn: col.referenceColumn || col.reference_column,
displayColumn: col.displayColumn || col.display_column,
}; };
}); });

View File

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

View File

@ -16,6 +16,7 @@ import { apiClient } from "@/lib/api/client";
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel"; import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel"; import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel"; import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
// 🆕 제목 블록 타입 // 🆕 제목 블록 타입
interface TitleBlock { interface TitleBlock {
@ -333,23 +334,73 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
const loadModalMappingColumns = async () => { const loadModalMappingColumns = async () => {
// 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지 // 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지
// allComponents에서 split-panel-layout 또는 table-list 찾기
let sourceTableName: string | null = null; 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) { for (const comp of allComponents) {
const compType = comp.componentType || (comp as any).componentConfig?.type; const compType = comp.componentType || (comp as any).componentConfig?.type;
const compConfig = (comp as any).componentConfig || {};
// 분할 패널 타입들 (다양한 경로에서 테이블명 추출)
if (compType === "split-panel-layout" || compType === "screen-split-panel") { if (compType === "split-panel-layout" || compType === "screen-split-panel") {
// 분할 패널의 좌측 테이블명 sourceTableName = compConfig?.leftPanel?.tableName ||
sourceTableName = (comp as any).componentConfig?.leftPanel?.tableName || compConfig?.leftTableName ||
(comp as any).componentConfig?.leftTableName; compConfig?.tableName;
if (sourceTableName) {
console.log(`✅ [openModalWithData] split-panel-layout에서 소스 테이블 감지: ${sourceTableName}`);
break; 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") { if (compType === "table-list") {
sourceTableName = (comp as any).componentConfig?.tableName; sourceTableName = compConfig?.tableName;
if (sourceTableName) {
console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`);
break; 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) { if (sourceTableName) {
try { try {
@ -361,11 +412,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
if (Array.isArray(columnData)) { if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({ const columns = columnData.map((col: any) => ({
name: col.name || col.columnName, name: col.name || col.columnName || col.column_name,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name,
})); }));
setModalSourceColumns(columns); setModalSourceColumns(columns);
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드:`, columns.length); console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드 완료:`, columns.length);
} }
} }
} catch (error) { } catch (error) {
@ -379,8 +430,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
try { try {
// 타겟 화면 정보 가져오기 // 타겟 화면 정보 가져오기
const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`); const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`);
console.log("[openModalWithData] 타겟 화면 응답:", screenResponse.data);
if (screenResponse.data.success && screenResponse.data.data) { if (screenResponse.data.success && screenResponse.data.data) {
const targetTableName = screenResponse.data.data.tableName; const targetTableName = screenResponse.data.data.tableName;
console.log("[openModalWithData] 타겟 화면 테이블명:", targetTableName);
if (targetTableName) { if (targetTableName) {
const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`); const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`);
if (columnResponse.data.success) { if (columnResponse.data.success) {
@ -390,23 +445,27 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
if (Array.isArray(columnData)) { if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({ const columns = columnData.map((col: any) => ({
name: col.name || col.columnName, name: col.name || col.columnName || col.column_name,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name,
})); }));
setModalTargetColumns(columns); setModalTargetColumns(columns);
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드:`, columns.length); console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드 완료:`, columns.length);
} }
} }
} else {
console.warn("[openModalWithData] 타겟 화면에 테이블명이 없습니다.");
} }
} }
} catch (error) { } catch (error) {
console.error("타겟 화면 테이블 컬럼 로드 실패:", error); console.error("타겟 화면 테이블 컬럼 로드 실패:", error);
} }
} else {
console.warn("[openModalWithData] 타겟 화면 ID가 없습니다.");
} }
}; };
loadModalMappingColumns(); loadModalMappingColumns();
}, [config.action?.type, config.action?.targetScreenId, allComponents]); }, [config.action?.type, config.action?.targetScreenId, allComponents, currentTableName]);
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준) // 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
useEffect(() => { useEffect(() => {
@ -584,9 +643,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="edit"></SelectItem> <SelectItem value="edit"></SelectItem>
<SelectItem value="copy"> ( )</SelectItem> <SelectItem value="copy"> ( )</SelectItem>
<SelectItem value="navigate"> </SelectItem> <SelectItem value="navigate"> </SelectItem>
<SelectItem value="transferData">📦 </SelectItem> <SelectItem value="transferData"> </SelectItem>
<SelectItem value="openModalWithData"> + 🆕</SelectItem> <SelectItem value="openModalWithData"> + </SelectItem>
<SelectItem value="openRelatedModal"> </SelectItem>
<SelectItem value="modal"> </SelectItem> <SelectItem value="modal"> </SelectItem>
<SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem> <SelectItem value="control"> </SelectItem>
<SelectItem value="view_table_history"> </SelectItem> <SelectItem value="view_table_history"> </SelectItem>
<SelectItem value="excel_download"> </SelectItem> <SelectItem value="excel_download"> </SelectItem>
@ -1158,11 +1219,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-3">
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => ( {(config.action?.fieldMappings || []).map((mapping: any, index: number) => (
<div key={index} className="flex items-center gap-2 rounded-md border bg-background p-2"> <div key={index} className="rounded-md border bg-background p-3 space-y-2">
{/* 소스 필드 선택 (Combobox) */} {/* 소스 필드 선택 (Combobox) - 세로 배치 */}
<div className="flex-1"> <div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Popover <Popover
open={modalSourcePopoverOpen[index] || false} open={modalSourcePopoverOpen[index] || false}
onOpenChange={(open) => setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} onOpenChange={(open) => setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
@ -1171,15 +1233,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
className="h-7 w-full justify-between text-xs" className="h-8 w-full justify-between text-xs"
> >
<span className="truncate">
{mapping.sourceField {mapping.sourceField
? modalSourceColumns.find((c) => c.name === mapping.sourceField)?.label || 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" /> <ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start"> <PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
<Command> <Command>
<CommandInput <CommandInput
placeholder="컬럼 검색..." placeholder="컬럼 검색..."
@ -1187,7 +1251,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
value={modalSourceSearch[index] || ""} value={modalSourceSearch[index] || ""}
onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))} onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))}
/> />
<CommandList> <CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty> <CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup> <CommandGroup>
{modalSourceColumns.map((col) => ( {modalSourceColumns.map((col) => (
@ -1208,9 +1272,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
mapping.sourceField === col.name ? "opacity-100" : "opacity-0" mapping.sourceField === col.name ? "opacity-100" : "opacity-0"
)} )}
/> />
<span>{col.label}</span> <span className="truncate">{col.label}</span>
{col.label !== col.name && ( {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> </CommandItem>
))} ))}
@ -1221,10 +1285,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</Popover> </Popover>
</div> </div>
<span className="text-xs text-muted-foreground"></span> {/* 화살표 표시 */}
<div className="flex justify-center">
<span className="text-xs text-muted-foreground"></span>
</div>
{/* 타겟 필드 선택 (Combobox) */} {/* 타겟 필드 선택 (Combobox) - 세로 배치 */}
<div className="flex-1"> <div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Popover <Popover
open={modalTargetPopoverOpen[index] || false} open={modalTargetPopoverOpen[index] || false}
onOpenChange={(open) => setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} onOpenChange={(open) => setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
@ -1233,15 +1301,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
className="h-7 w-full justify-between text-xs" className="h-8 w-full justify-between text-xs"
> >
<span className="truncate">
{mapping.targetField {mapping.targetField
? modalTargetColumns.find((c) => c.name === mapping.targetField)?.label || 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" /> <ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start"> <PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
<Command> <Command>
<CommandInput <CommandInput
placeholder="컬럼 검색..." placeholder="컬럼 검색..."
@ -1249,7 +1319,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
value={modalTargetSearch[index] || ""} value={modalTargetSearch[index] || ""}
onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))} onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))}
/> />
<CommandList> <CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty> <CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup> <CommandGroup>
{modalTargetColumns.map((col) => ( {modalTargetColumns.map((col) => (
@ -1270,9 +1340,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
mapping.targetField === col.name ? "opacity-100" : "opacity-0" mapping.targetField === col.name ? "opacity-100" : "opacity-0"
)} )}
/> />
<span>{col.label}</span> <span className="truncate">{col.label}</span>
{col.label !== col.name && ( {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> </CommandItem>
))} ))}
@ -1284,20 +1354,23 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div> </div>
{/* 삭제 버튼 */} {/* 삭제 버튼 */}
<div className="flex justify-end pt-1">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="sm"
className="h-7 w-7 text-destructive hover:bg-destructive/10" className="h-6 text-[10px] text-destructive hover:bg-destructive/10"
onClick={() => { onClick={() => {
const mappings = [...(config.action?.fieldMappings || [])]; const mappings = [...(config.action?.fieldMappings || [])];
mappings.splice(index, 1); mappings.splice(index, 1);
onUpdateProperty("componentConfig.action.fieldMappings", mappings); onUpdateProperty("componentConfig.action.fieldMappings", mappings);
}} }}
> >
<X className="h-4 w-4" /> <X className="h-3 w-3 mr-1" />
</Button> </Button>
</div> </div>
</div>
))} ))}
</div> </div>
)} )}
@ -2998,6 +3071,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div> </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"> <div className="mt-8 border-t border-border pt-6">
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} /> <ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />

View File

@ -6,18 +6,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button"; import { Database, Search, Info } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { Database, Search, Plus, Trash2 } from "lucide-react";
import { WebTypeConfigPanelProps } from "@/lib/registry/types"; import { WebTypeConfigPanelProps } from "@/lib/registry/types";
import { WidgetComponent, EntityTypeConfig } from "@/types/screen"; import { WidgetComponent, EntityTypeConfig } from "@/types/screen";
import { tableTypeApi } from "@/lib/api/screen";
interface EntityField {
name: string;
label: string;
type: string;
visible: boolean;
}
export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
component, component,
@ -27,16 +19,31 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
const widget = component as WidgetComponent; const widget = component as WidgetComponent;
const config = (widget.webTypeConfig as EntityTypeConfig) || {}; 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>({ const [localConfig, setLocalConfig] = useState<EntityTypeConfig>({
entityType: config.entityType || "", entityType: config.entityType || "",
displayFields: config.displayFields || [], displayFields: config.displayFields || [],
searchFields: config.searchFields || [], searchFields: config.searchFields || [],
valueField: config.valueField || "id", valueField: config.valueField || "",
labelField: config.labelField || "name", labelField: config.labelField || "",
multiple: config.multiple || false, multiple: config.multiple || false,
searchable: config.searchable !== false, // 기본값 true searchable: config.searchable !== false,
placeholder: config.placeholder || "엔티티를 선택하세요", placeholder: config.placeholder || "항목을 선택하세요",
emptyMessage: config.emptyMessage || "검색 결과가 없습니다", emptyMessage: config.emptyMessage || "검색 결과가 없습니다",
pageSize: config.pageSize || 20, pageSize: config.pageSize || 20,
minSearchLength: config.minSearchLength || 1, minSearchLength: config.minSearchLength || 1,
@ -47,10 +54,95 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
filters: config.filters || {}, filters: config.filters || {},
}); });
// 새 필드 추가용 상태 // 테이블 타입 관리에서 설정된 참조 테이블 정보 로드
const [newFieldName, setNewFieldName] = useState(""); useEffect(() => {
const [newFieldLabel, setNewFieldLabel] = useState(""); const loadReferenceInfo = async () => {
const [newFieldType, setNewFieldType] = useState("string"); // 컴포넌트의 테이블명과 컬럼명이 있는 경우에만 조회
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(() => { useEffect(() => {
@ -59,11 +151,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
entityType: currentConfig.entityType || "", entityType: currentConfig.entityType || "",
displayFields: currentConfig.displayFields || [], displayFields: currentConfig.displayFields || [],
searchFields: currentConfig.searchFields || [], searchFields: currentConfig.searchFields || [],
valueField: currentConfig.valueField || "id", valueField: currentConfig.valueField || referenceInfo.referenceColumn || "",
labelField: currentConfig.labelField || "name", labelField: currentConfig.labelField || referenceInfo.displayColumn || "",
multiple: currentConfig.multiple || false, multiple: currentConfig.multiple || false,
searchable: currentConfig.searchable !== false, searchable: currentConfig.searchable !== false,
placeholder: currentConfig.placeholder || "엔티티를 선택하세요", placeholder: currentConfig.placeholder || "항목을 선택하세요",
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다", emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
pageSize: currentConfig.pageSize || 20, pageSize: currentConfig.pageSize || 20,
minSearchLength: currentConfig.minSearchLength || 1, minSearchLength: currentConfig.minSearchLength || 1,
@ -73,7 +165,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
apiEndpoint: currentConfig.apiEndpoint || "", apiEndpoint: currentConfig.apiEndpoint || "",
filters: currentConfig.filters || {}, filters: currentConfig.filters || {},
}); });
}, [widget.webTypeConfig]); }, [widget.webTypeConfig, referenceInfo.referenceColumn, referenceInfo.displayColumn]);
// 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등) // 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등)
const updateConfig = (field: keyof EntityTypeConfig, value: any) => { const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
@ -92,89 +184,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
onUpdateProperty("webTypeConfig", localConfig); 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -182,214 +191,97 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Database className="h-4 w-4" /> <Database className="h-4 w-4" />
</CardTitle> </CardTitle>
<CardDescription className="text-xs"> .</CardDescription> <CardDescription className="text-xs">
.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* 기본 설정 */} {/* 참조 테이블 정보 (테이블 타입 관리에서 설정된 값 - 읽기 전용) */}
<div className="space-y-3"> <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"> {referenceInfo.isLoading ? (
<Label htmlFor="entityType" className="text-xs"> <div className="bg-muted/50 rounded-md border p-3">
<p className="text-xs text-muted-foreground"> ...</p>
</Label> </div>
<Input ) : referenceInfo.error ? (
id="entityType" <div className="bg-destructive/10 rounded-md border border-destructive/20 p-3">
value={localConfig.entityType || ""} <p className="text-xs text-destructive flex items-center gap-1">
onChange={(e) => updateConfigLocal("entityType", e.target.value)} <Info className="h-3 w-3" />
onBlur={handleInputBlur} {referenceInfo.error}
placeholder="user, product, department..." </p>
className="text-xs" <p className="text-[10px] text-muted-foreground mt-1">
/> .
</p>
</div>
) : !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 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>
<div className="space-y-2"> {/* UI 모드 설정 */}
<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>
))}
</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"> <div className="space-y-3">
<h4 className="text-sm font-medium"> </h4> <h4 className="text-sm font-medium">UI </h4>
<div className="grid grid-cols-2 gap-2"> {/* UI 모드 선택 */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="valueField" className="text-xs"> <Label htmlFor="uiMode" className="text-xs">
UI
</Label> </Label>
<Input
id="valueField"
value={localConfig.valueField || ""}
onChange={(e) => updateConfigLocal("valueField", e.target.value)}
onBlur={handleInputBlur}
placeholder="id"
className="text-xs"
/>
</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"
/>
</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 <Select
value={field.type} value={(localConfig as any).uiMode || "combo"}
onValueChange={(value) => { onValueChange={(value) => updateConfig("uiMode" as any, 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"> <SelectTrigger className="text-xs">
<SelectValue /> <SelectValue placeholder="모드 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{fieldTypes.map((type) => ( <SelectItem value="select"> (Select)</SelectItem>
<SelectItem key={type.value} value={type.value}> <SelectItem value="modal"> (Modal)</SelectItem>
{type.label} <SelectItem value="combo"> + (Combo)</SelectItem>
</SelectItem> <SelectItem value="autocomplete"> (Autocomplete)</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<Button <p className="text-[10px] text-muted-foreground">
size="sm" {(localConfig as any).uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."}
variant={localConfig.searchFields.includes(field.name) ? "default" : "outline"} {(localConfig as any).uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."}
onClick={() => toggleSearchField(field.name)} {((localConfig as any).uiMode === "combo" || !(localConfig as any).uiMode) && "입력 필드와 검색 버튼이 함께 표시됩니다."}
className="p-1 text-xs" {(localConfig as any).uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."}
title={localConfig.searchFields.includes(field.name) ? "검색 필드에서 제거" : "검색 필드로 추가"} </p>
>
<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> </div>
))}
</div>
</div>
</div>
{/* 검색 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="placeholder" className="text-xs"> <Label htmlFor="placeholder" className="text-xs">
@ -400,7 +292,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
value={localConfig.placeholder || ""} value={localConfig.placeholder || ""}
onChange={(e) => updateConfigLocal("placeholder", e.target.value)} onChange={(e) => updateConfigLocal("placeholder", e.target.value)}
onBlur={handleInputBlur} onBlur={handleInputBlur}
placeholder="엔티티를 선택하세요" placeholder="항목을 선택하세요"
className="text-xs" className="text-xs"
/> />
</div> </div>
@ -418,6 +310,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
className="text-xs" className="text-xs"
/> />
</div> </div>
</div>
{/* 검색 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-2"> <div className="space-y-2">
@ -456,7 +353,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="searchable" className="text-xs"> <Label htmlFor="searchable" className="text-xs">
</Label> </Label>
<p className="text-muted-foreground text-xs"> .</p> <p className="text-muted-foreground text-xs"> .</p>
</div> </div>
<Switch <Switch
id="searchable" id="searchable"
@ -470,7 +367,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="multiple" className="text-xs"> <Label htmlFor="multiple" className="text-xs">
</Label> </Label>
<p className="text-muted-foreground text-xs"> .</p> <p className="text-muted-foreground text-xs"> .</p>
</div> </div>
<Switch <Switch
id="multiple" id="multiple"
@ -480,33 +377,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div> </div>
</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"> <div className="space-y-3">
<h4 className="text-sm font-medium"> </h4> <h4 className="text-sm font-medium"> </h4>
@ -516,7 +386,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="required" className="text-xs"> <Label htmlFor="required" className="text-xs">
</Label> </Label>
<p className="text-muted-foreground text-xs"> .</p> <p className="text-muted-foreground text-xs"> .</p>
</div> </div>
<Switch <Switch
id="required" id="required"
@ -530,7 +400,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="readonly" className="text-xs"> <Label htmlFor="readonly" className="text-xs">
</Label> </Label>
<p className="text-muted-foreground text-xs"> .</p> <p className="text-muted-foreground text-xs"> .</p>
</div> </div>
<Switch <Switch
id="readonly" id="readonly"
@ -547,31 +417,18 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 rounded border bg-white p-2"> <div className="flex items-center gap-2 rounded border bg-white p-2">
<Database className="h-4 w-4 text-gray-400" /> <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" />} {localConfig.searchable && <Search className="h-4 w-4 text-gray-400" />}
</div> </div>
{localConfig.displayFields.length > 0 && (
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
<div className="font-medium"> :</div> <div>: {referenceInfo.referenceTable || "미설정"}</div>
<div className="mt-1 flex flex-wrap gap-1"> <div> : {localConfig.valueField || referenceInfo.referenceColumn || "-"}</div>
{localConfig.displayFields <div> : {localConfig.labelField || referenceInfo.displayColumn || "-"}</div>
.filter((f) => f.visible) {localConfig.multiple && <span> / </span>}
.map((field, index) => ( {localConfig.required && <span> / </span>}
<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> </div>
</div> </div>
</div> </div>
@ -582,5 +439,3 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
}; };
EntityConfigPanel.displayName = "EntityConfigPanel"; EntityConfigPanel.displayName = "EntityConfigPanel";

View File

@ -1,11 +1,14 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator"; 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 { ComponentData } from "@/types/screen";
import { getNodeFlows, NodeFlow } from "@/lib/api/nodeFlows"; import { getNodeFlows, NodeFlow } from "@/lib/api/nodeFlows";
@ -14,11 +17,22 @@ interface ImprovedButtonControlConfigPanelProps {
onUpdateProperty: (path: string, value: any) => void; 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> = ({ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlConfigPanelProps> = ({
component, component,
@ -27,6 +41,9 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
const config = component.webTypeConfig || {}; const config = component.webTypeConfig || {};
const dataflowConfig = config.dataflowConfig || {}; const dataflowConfig = config.dataflowConfig || {};
// 다중 제어 설정 (배열)
const flowControls: FlowControlConfig[] = dataflowConfig.flowControls || [];
// 🔥 State 관리 // 🔥 State 관리
const [flows, setFlows] = useState<NodeFlow[]>([]); const [flows, setFlows] = useState<NodeFlow[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -58,24 +75,118 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
}; };
/** /**
* 🔥 * 🔥
*/ */
const handleFlowSelect = (flowId: string) => { 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); const selectedFlow = flows.find((f) => f.flowId.toString() === flowId);
if (selectedFlow) { if (selectedFlow) {
// 전체 dataflowConfig 업데이트 (selectedDiagramId 포함) 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", { onUpdateProperty("webTypeConfig.dataflowConfig", {
...dataflowConfig, ...dataflowConfig,
selectedDiagramId: selectedFlow.flowId, // 백엔드에서 사용 // 기존 형식 (하위 호환성)
selectedRelationshipId: null, // 노드 플로우는 관계 ID 불필요 selectedDiagramId: firstValidControl?.flowId || null,
flowConfig: { selectedRelationshipId: null,
flowId: selectedFlow.flowId, flowConfig: firstValidControl
flowName: selectedFlow.flowName, ? {
executionTiming: "before", // 기본값 flowId: firstValidControl.flowId,
flowName: firstValidControl.flowName,
executionTiming: firstValidControl.executionTiming,
contextData: {}, contextData: {},
},
});
} }
: null,
// 새로운 다중 제어 형식
flowControls: controls,
});
}; };
return ( return (
@ -98,32 +209,57 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
{/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */} {/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */}
{config.enableDataflowControl && ( {config.enableDataflowControl && (
<div className="space-y-4"> <div className="space-y-4">
<FlowSelector {/* 제어 목록 헤더 */}
flows={flows} <div className="flex items-center justify-between">
selectedFlowId={dataflowConfig.flowConfig?.flowId} <div className="flex items-center space-x-2">
onSelect={handleFlowSelect} <Workflow className="h-4 w-4 text-green-600" />
loading={loading} <Label> ( )</Label>
/>
{dataflowConfig.flowConfig && (
<div className="space-y-4">
<Separator />
<ExecutionTimingSelector
value={dataflowConfig.flowConfig.executionTiming}
onChange={(timing) =>
onUpdateProperty("webTypeConfig.dataflowConfig.flowConfig.executionTiming", timing)
}
/>
<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> </div>
<Button variant="outline" size="sm" onClick={handleAddControl} className="h-8">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 제어 목록 */}
{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>
)}
{/* 안내 메시지 */}
{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> </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[]; flows: NodeFlow[];
selectedFlowId?: number;
onSelect: (flowId: string) => void;
loading: boolean; 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 ( return (
<div className="space-y-4"> <Card className="p-3">
<div className="flex items-center space-x-2"> <div className="flex items-start gap-2">
<Workflow className="h-4 w-4 text-green-600" /> {/* 순서 표시 및 이동 버튼 */}
<Label> </Label> <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> </div>
<Select value={selectedFlowId?.toString() || ""} onValueChange={onSelect}> {/* 플로우 선택 및 설정 */}
<SelectTrigger> <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="플로우를 선택하세요" /> <SelectValue placeholder="플로우를 선택하세요" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{loading ? ( {loading ? (
<div className="p-4 text-center text-sm text-gray-500"> ...</div> <div className="p-2 text-center text-xs text-gray-500"> ...</div>
) : flows.length === 0 ? ( ) : flows.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500"> <div className="p-2 text-center text-xs text-gray-500"> </div>
<p> </p>
<p className="mt-2 text-xs"> </p>
</div>
) : ( ) : (
flows.map((flow) => ( flows.map((flow) => (
<SelectItem key={flow.flowId} value={flow.flowId.toString()}> <SelectItem key={flow.flowId} value={flow.flowId.toString()}>
<div className="flex flex-col"> <span className="text-xs">{flow.flowName}</span>
<span className="font-medium">{flow.flowName}</span>
{flow.flowDescription && (
<span className="text-muted-foreground text-xs">{flow.flowDescription}</span>
)}
</div>
</SelectItem> </SelectItem>
)) ))
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
);
};
/** {/* 실행 타이밍 */}
* 🔥 <Select value={control.executionTiming} onValueChange={onTimingChange}>
*/ <SelectTrigger className="h-8 text-xs">
const ExecutionTimingSelector: React.FC<{ <SelectValue />
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>
</div>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="실행 타이밍을 선택하세요" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="before"> <SelectItem value="before">
<div className="flex flex-col"> <span className="text-xs">Before ( )</span>
<span className="font-medium">Before ( )</span>
<span className="text-muted-foreground text-xs"> </span>
</div>
</SelectItem> </SelectItem>
<SelectItem value="after"> <SelectItem value="after">
<div className="flex flex-col"> <span className="text-xs">After ( )</span>
<span className="font-medium">After ( )</span>
<span className="text-muted-foreground text-xs"> </span>
</div>
</SelectItem> </SelectItem>
<SelectItem value="replace"> <SelectItem value="replace">
<div className="flex flex-col"> <span className="text-xs">Replace ( )</span>
<span className="font-medium">Replace ( )</span>
<span className="text-muted-foreground text-xs"> </span>
</div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* 삭제 버튼 */}
<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>
</Card>
); );
}; };

View File

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

View File

@ -46,6 +46,7 @@ interface DetailSettingsPanelProps {
currentTableName?: string; // 현재 화면의 테이블명 currentTableName?: string; // 현재 화면의 테이블명
tables?: TableInfo[]; // 전체 테이블 목록 tables?: TableInfo[]; // 전체 테이블 목록
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드 currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
components?: ComponentData[]; // 현재 화면의 모든 컴포넌트 (연쇄관계 부모 필드 선택용)
} }
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
@ -55,6 +56,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
currentTableName, currentTableName,
tables = [], // 기본값 빈 배열 tables = [], // 기본값 빈 배열
currentScreenCompanyCode, currentScreenCompanyCode,
components = [], // 기본값 빈 배열
}) => { }) => {
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기 // 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
const { webTypes } = useWebTypes({ active: "Y" }); const { webTypes } = useWebTypes({ active: "Y" });
@ -878,7 +880,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
}; };
return ( 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"> <div className="flex items-center gap-2 border-b pb-2">
<Settings className="h-4 w-4 text-primary" /> <Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3> <h3 className="text-sm font-semibold">{definition.name} </h3>
@ -998,7 +1000,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
</div> </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> </div>
); );
} }
@ -1156,8 +1158,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
</div> </div>
{/* 컴포넌트 설정 패널 */} {/* 컴포넌트 설정 패널 */}
<div className="flex-1 overflow-y-auto px-6 pb-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"> <div className="space-y-6 w-full min-w-0">
{/* DynamicComponentConfigPanel */} {/* DynamicComponentConfigPanel */}
<DynamicComponentConfigPanel <DynamicComponentConfigPanel
componentId={componentId} componentId={componentId}
@ -1396,8 +1398,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
</div> </div>
{/* 상세 설정 영역 */} {/* 상세 설정 영역 */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto overflow-x-hidden p-4 w-full min-w-0">
<div className="space-y-6"> <div className="space-y-6 w-full min-w-0">
{console.log("🔍 [DetailSettingsPanel] widget 타입:", selectedComponent?.type, "autoFill:", widget.autoFill)} {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"> <div className="space-y-4 rounded-md border border-red-500 bg-yellow-50 p-4">

View File

@ -943,6 +943,18 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
</div> </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>
</div> </div>
); );

View File

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

View File

@ -7,15 +7,18 @@ import { X, Loader2 } from "lucide-react";
import type { TabsComponent, TabItem } from "@/types/screen-management"; import type { TabsComponent, TabItem } from "@/types/screen-management";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useActiveTab } from "@/contexts/ActiveTabContext";
interface TabsWidgetProps { interface TabsWidgetProps {
component: TabsComponent; component: TabsComponent;
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
menuObjid?: number; // 🆕 부모 화면의 메뉴 OBJID menuObjid?: number; // 부모 화면의 메뉴 OBJID
} }
export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) { export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) {
// ActiveTab context 사용
const { setActiveTab, removeTabsComponent } = useActiveTab();
const { const {
tabs = [], tabs = [],
defaultTab, defaultTab,
@ -25,12 +28,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
persistSelection = false, persistSelection = false,
} = component; } = component;
console.log("🎨 TabsWidget 렌더링:", {
componentId: component.id,
tabs,
tabsLength: tabs.length,
component,
});
const storageKey = `tabs-${component.id}-selected`; const storageKey = `tabs-${component.id}-selected`;
@ -57,25 +54,35 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
setVisibleTabs(tabs.filter((tab) => !tab.disabled)); setVisibleTabs(tabs.filter((tab) => !tab.disabled));
}, [tabs]); }, [tabs]);
// 선택된 탭 변경 시 localStorage에 저장 // 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
useEffect(() => { useEffect(() => {
if (persistSelection && typeof window !== "undefined") { if (persistSelection && typeof window !== "undefined") {
localStorage.setItem(storageKey, selectedTab); 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(() => { useEffect(() => {
const currentTab = visibleTabs.find((t) => t.id === selectedTab); 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]) { if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) {
console.log("📥 초기 화면 로딩 시작:", currentTab.screenId);
loadScreenLayout(currentTab.screenId); loadScreenLayout(currentTab.screenId);
} }
}, [selectedTab, visibleTabs]); }, [selectedTab, visibleTabs]);
@ -83,26 +90,20 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
// 화면 레이아웃 로드 // 화면 레이아웃 로드
const loadScreenLayout = async (screenId: number) => { const loadScreenLayout = async (screenId: number) => {
if (screenLayouts[screenId]) { if (screenLayouts[screenId]) {
console.log("✅ 이미 로드된 화면:", screenId);
return; // 이미 로드됨 return; // 이미 로드됨
} }
console.log("📥 화면 레이아웃 로딩 시작:", screenId);
setLoadingScreens((prev) => ({ ...prev, [screenId]: true })); setLoadingScreens((prev) => ({ ...prev, [screenId]: true }));
try { try {
const { apiClient } = await import("@/lib/api/client"); const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`); 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) { if (response.data.success && response.data.data) {
console.log("✅ 화면 레이아웃 로드 완료:", screenId);
setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data })); setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data }));
} else {
console.error("❌ 화면 레이아웃 로드 실패 - success false");
} }
} catch (error) { } catch (error) {
console.error(`화면 레이아웃 로드 실패 ${screenId}:`, error); console.error(`화면 레이아웃 로드 실패 ${screenId}:`, error);
} finally { } finally {
setLoadingScreens((prev) => ({ ...prev, [screenId]: false })); setLoadingScreens((prev) => ({ ...prev, [screenId]: false }));
} }
@ -110,10 +111,9 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
// 탭 변경 핸들러 // 탭 변경 핸들러
const handleTabChange = (tabId: string) => { const handleTabChange = (tabId: string) => {
console.log("🔄 탭 변경:", tabId);
setSelectedTab(tabId); setSelectedTab(tabId);
// 🆕 마운트된 탭 목록에 추가 (한 번 마운트되면 유지) // 마운트된 탭 목록에 추가 (한 번 마운트되면 유지)
setMountedTabs(prev => { setMountedTabs(prev => {
if (prev.has(tabId)) return prev; if (prev.has(tabId)) return prev;
const newSet = new Set(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); const tab = visibleTabs.find((t) => t.id === tabId);
console.log("🔍 선택된 탭 정보:", { tab, hasScreenId: !!tab?.screenId, screenId: tab?.screenId });
if (tab && tab.screenId && !screenLayouts[tab.screenId]) { if (tab && tab.screenId && !screenLayouts[tab.screenId]) {
console.log("📥 탭 변경 시 화면 로딩:", tab.screenId);
loadScreenLayout(tab.screenId); loadScreenLayout(tab.screenId);
} }
}; };
@ -157,7 +154,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
}; };
if (visibleTabs.length === 0) { if (visibleTabs.length === 0) {
console.log("⚠️ 보이는 탭이 없음");
return ( return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50"> <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> <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 ( return (
<div className="flex h-full w-full flex-col pt-4" style={style}> <div className="flex h-full w-full flex-col pt-4" style={style}>
<Tabs <Tabs
@ -233,14 +222,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
const layoutData = screenLayouts[tab.screenId]; const layoutData = screenLayouts[tab.screenId];
const { components = [], screenResolution } = layoutData; const { components = [], screenResolution } = layoutData;
// 비활성 탭은 로그 생략
if (isActive) {
console.log("🎯 렌더링할 화면 데이터:", {
screenId: tab.screenId,
componentsCount: components.length,
screenResolution,
});
}
const designWidth = screenResolution?.width || 1920; const designWidth = screenResolution?.width || 1920;
const designHeight = screenResolution?.height || 1080; const designHeight = screenResolution?.height || 1080;
@ -260,16 +241,18 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
margin: "0 auto", margin: "0 auto",
}} }}
> >
{components.map((component: any) => ( {components.map((comp: any) => (
<InteractiveScreenViewerDynamic <InteractiveScreenViewerDynamic
key={component.id} key={comp.id}
component={component} component={comp}
allComponents={components} allComponents={components}
screenInfo={{ screenInfo={{
id: tab.screenId, id: tab.screenId,
tableName: layoutData.tableName, tableName: layoutData.tableName,
}} }}
menuObjid={menuObjid} menuObjid={menuObjid}
parentTabId={tab.id}
parentTabsComponentId={component.id}
/> />
))} ))}
</div> </div>

View File

@ -10,7 +10,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react"; 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 { cn } from "@/lib/utils";
import { useBreakpoint } from "@/hooks/useBreakpoint"; import { useBreakpoint } from "@/hooks/useBreakpoint";
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal"; import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
@ -46,7 +52,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const breakpoint = previewBreakpoint || globalBreakpoint; const breakpoint = previewBreakpoint || globalBreakpoint;
// 카테고리 매핑 데이터 (값 -> {label, color}) // 카테고리 매핑 데이터 (값 -> {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 { const {
@ -98,16 +106,30 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트 // 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
useEffect(() => { useEffect(() => {
if (value.length > 0) { // 🆕 빈 배열도 처리 (FK 기반 필터링 시 데이터가 없을 수 있음)
if (value.length === 0) {
// minItems가 설정되어 있으면 빈 항목 생성, 아니면 빈 배열로 초기화
if (minItems > 0) {
const emptyItems = Array(minItems)
.fill(null)
.map(() => createEmptyItem());
setItems(emptyItems);
} else {
setItems([]);
}
initialCalcDoneRef.current = false; // 다음 데이터 로드 시 계산식 재실행
return;
}
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행) // 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
const calculatedFields = fields.filter(f => f.type === "calculated"); const calculatedFields = fields.filter((f) => f.type === "calculated");
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) { if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
const updatedValue = value.map(item => { const updatedValue = value.map((item) => {
const updatedItem = { ...item }; const updatedItem = { ...item };
let hasChange = false; let hasChange = false;
calculatedFields.forEach(calcField => { calculatedFields.forEach((calcField) => {
const calculatedValue = calculateValue(calcField.formula, updatedItem); const calculatedValue = calculateValue(calcField.formula, updatedItem);
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) { if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
updatedItem[calcField.name] = calculatedValue; updatedItem[calcField.name] = calculatedValue;
@ -133,13 +155,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
onChange?.(dataWithMeta); onChange?.(dataWithMeta);
} else { } else {
// 🆕 기존 레코드 플래그 추가 // 🆕 기존 레코드 플래그 추가
const valueWithFlag = value.map(item => ({ const valueWithFlag = value.map((item) => ({
...item, ...item,
_existingRecord: !!item.id, _existingRecord: !!item.id,
})); }));
setItems(valueWithFlag); setItems(valueWithFlag);
} }
}
}, [value]); }, [value]);
// 항목 추가 // 항목 추가
@ -161,9 +182,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 항목 제거 // 항목 제거
const handleRemoveItem = (index: number) => { const handleRemoveItem = (index: number) => {
if (items.length <= minItems) { // 🆕 항목이 1개 이하일 때도 삭제 가능 (빈 상태 허용)
return; // minItems 체크 제거 - 모든 항목 삭제 허용
}
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요) // 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
const removedItem = items[index]; const removedItem = items[index];
@ -207,8 +227,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
}; };
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산 // 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
const calculatedFields = fields.filter(f => f.type === "calculated"); const calculatedFields = fields.filter((f) => f.type === "calculated");
calculatedFields.forEach(calcField => { calculatedFields.forEach((calcField) => {
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]); const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
if (calculatedValue !== null) { if (calculatedValue !== null) {
newItems[itemIndex][calcField.name] = calculatedValue; newItems[itemIndex][calcField.name] = calculatedValue;
@ -290,9 +310,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (!formula || !formula.field1) return null; if (!formula || !formula.field1) return null;
const value1 = parseFloat(item[formula.field1]) || 0; const value1 = parseFloat(item[formula.field1]) || 0;
const value2 = formula.field2 const value2 = formula.field2 ? parseFloat(item[formula.field2]) || 0 : (formula.constantValue ?? 0);
? (parseFloat(item[formula.field2]) || 0)
: (formula.constantValue ?? 0);
let result: number; let result: number;
@ -341,10 +359,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
* @param format * @param format
* @returns * @returns
*/ */
const formatNumber = ( const formatNumber = (value: number | null, format?: RepeaterFieldDefinition["numberFormat"]): string => {
value: number | null,
format?: RepeaterFieldDefinition["numberFormat"]
): string => {
if (value === null || isNaN(value)) return "-"; if (value === null || isNaN(value)) return "-";
let formattedValue = value; let formattedValue = value;
@ -355,7 +370,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
} }
// 천 단위 구분자 // 천 단위 구분자
let result = format?.useThousandSeparator !== false let result =
format?.useThousandSeparator !== false
? formattedValue.toLocaleString("ko-KR", { ? formattedValue.toLocaleString("ko-KR", {
minimumFractionDigits: format?.minimumFractionDigits ?? 0, minimumFractionDigits: format?.minimumFractionDigits ?? 0,
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0, maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
@ -373,10 +389,14 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => { const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
const isReadonly = disabled || readonly || field.readonly; const isReadonly = disabled || readonly || field.readonly;
// 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
// "id(를) 입력하세요" 같은 잘못된 기본값 방지
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
const commonProps = { const commonProps = {
value: value || "", value: value || "",
disabled: isReadonly, disabled: isReadonly,
placeholder: field.placeholder, placeholder: defaultPlaceholder,
required: field.required, required: field.required,
}; };
@ -386,11 +406,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const calculatedValue = calculateValue(field.formula, item); const calculatedValue = calculateValue(field.formula, item);
const formattedValue = formatNumber(calculatedValue, field.numberFormat); const formattedValue = formatNumber(calculatedValue, field.numberFormat);
return ( return <span className="inline-block min-w-[80px] text-sm font-medium text-blue-700">{formattedValue}</span>;
<span className="text-sm font-medium text-blue-700 min-w-[80px] inline-block">
{formattedValue}
</span>
);
} }
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용) // 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
@ -402,7 +418,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const valueStr = String(value); // 값을 문자열로 변환 const valueStr = String(value); // 값을 문자열로 변환
const categoryData = mapping?.[valueStr]; const categoryData = mapping?.[valueStr];
const displayLabel = categoryData?.label || valueStr; const displayLabel = categoryData?.label || valueStr;
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate) const displayColor = categoryData?.color;
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, { console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
fieldName: field.name, fieldName: field.name,
@ -413,8 +429,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
displayColor, displayColor,
}); });
// 색상이 "none"이면 일반 텍스트로 표시 // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (displayColor === "none") { if (!displayColor || displayColor === "none" || !categoryData) {
return <span className="text-sm">{displayLabel}</span>; return <span className="text-sm">{displayLabel}</span>;
} }
@ -436,7 +452,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (field.displayMode === "readonly") { if (field.displayMode === "readonly") {
// select 타입인 경우 옵션에서 라벨 찾기 // select 타입인 경우 옵션에서 라벨 찾기
if (field.type === "select" && value && field.options) { 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>; return <span className="text-sm">{option?.label || value}</span>;
} }
@ -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 ( return <span className="text-foreground text-sm">{value || "-"}</span>;
<span className="text-sm text-foreground">
{value || "-"}
</span>
);
} }
switch (field.type) { switch (field.type) {
@ -500,19 +512,43 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps} {...commonProps}
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
rows={3} 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 ( return (
<Input <Input
{...commonProps} {...commonProps}
value={dateValue}
type="date" 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]" className="min-w-[120px]"
/> />
); );
}
case "number": case "number":
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시 // 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
@ -522,11 +558,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 읽기 전용이면 포맷팅된 텍스트만 표시 // 읽기 전용이면 포맷팅된 텍스트만 표시
if (isReadonly) { if (isReadonly) {
return ( return <span className="inline-block min-w-[80px] text-sm">{formattedDisplay}</span>;
<span className="text-sm min-w-[80px] inline-block">
{formattedDisplay}
</span>
);
} }
// 편집 가능: 입력은 숫자로, 표시는 포맷팅 // 편집 가능: 입력은 숫자로, 표시는 포맷팅
@ -540,11 +572,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
max={field.validation?.max} max={field.validation?.max}
className="pr-1" className="pr-1"
/> />
{value && ( {value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
<div className="text-muted-foreground text-[10px] mt-0.5">
{formattedDisplay}
</div>
)}
</div> </div>
); );
} }
@ -597,8 +625,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values // 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
useEffect(() => { useEffect(() => {
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성) // 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
const categoryFields = fields.filter(f => f.type === "category"); const categoryFields = fields.filter((f) => f.type === "category");
const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text"); const readonlyFields = fields.filter((f) => f.displayMode === "readonly" && f.type === "text");
if (categoryFields.length === 0 && readonlyFields.length === 0) return; if (categoryFields.length === 0 && readonlyFields.length === 0) return;
@ -632,7 +660,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
setCategoryMappings(prev => ({ setCategoryMappings((prev) => ({
...prev, ...prev,
[columnName]: mapping, [columnName]: mapping,
})); }));
@ -644,12 +672,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드 // 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
// material, division 등 조인된 테이블의 카테고리 필드 // material, division 등 조인된 테이블의 카테고리 필드
const joinedTableFields = ['material', 'division', 'status', 'currency_code']; const joinedTableFields = ["material", "division", "status", "currency_code"];
const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name)); const fieldsToLoadFromJoinedTable = readonlyFields.filter((f) => joinedTableFields.includes(f.name));
if (fieldsToLoadFromJoinedTable.length > 0) { if (fieldsToLoadFromJoinedTable.length > 0) {
// item_info 테이블에서 카테고리 매핑 로드 // item_info 테이블에서 카테고리 매핑 로드
const joinedTableName = 'item_info'; const joinedTableName = "item_info";
for (const field of fieldsToLoadFromJoinedTable) { for (const field of fieldsToLoadFromJoinedTable) {
const columnName = field.name; const columnName = field.name;
@ -674,7 +702,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
setCategoryMappings(prev => ({ setCategoryMappings((prev) => ({
...prev, ...prev,
[columnName]: mapping, [columnName]: mapping,
})); }));
@ -694,9 +722,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (fields.length === 0) { if (fields.length === 0) {
return ( return (
<div className={cn("space-y-4", className)}> <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"> <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-sm font-medium text-destructive"> </p> <p className="text-destructive text-sm font-medium"> </p>
<p className="mt-2 text-xs text-muted-foreground"> .</p> <p className="text-muted-foreground mt-2 text-xs"> .</p>
</div> </div>
</div> </div>
); );
@ -706,8 +734,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (items.length === 0) { if (items.length === 0) {
return ( return (
<div className={cn("space-y-4", className)}> <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"> <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="mb-4 text-sm text-muted-foreground">{emptyMessage}</p> <p className="text-muted-foreground mb-4 text-sm">{emptyMessage}</p>
{!readonly && !disabled && items.length < maxItems && ( {!readonly && !disabled && items.length < maxItems && (
<Button type="button" onClick={handleAddItem} size="sm"> <Button type="button" onClick={handleAddItem} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
@ -740,7 +768,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{fields.map((field) => ( {fields.map((field) => (
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold"> <TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
{field.label} {field.label}
{field.required && <span className="ml-1 text-destructive">*</span>} {field.required && <span className="text-destructive ml-1">*</span>}
</TableHead> </TableHead>
))} ))}
<TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold"></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 <TableRow
key={itemIndex} key={itemIndex}
className={cn( className={cn(
"bg-background transition-colors hover:bg-muted/50", "bg-background hover:bg-muted/50 transition-colors",
draggedIndex === itemIndex && "opacity-50", draggedIndex === itemIndex && "opacity-50",
)} )}
draggable={allowReorder && !readonly && !disabled} draggable={allowReorder && !readonly && !disabled}
@ -762,15 +790,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
> >
{/* 인덱스 번호 */} {/* 인덱스 번호 */}
{showIndex && ( {showIndex && (
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium"> <TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
{itemIndex + 1}
</TableCell>
)} )}
{/* 드래그 핸들 */} {/* 드래그 핸들 */}
{allowReorder && !readonly && !disabled && ( {allowReorder && !readonly && !disabled && (
<TableCell className="h-12 px-2.5 py-2 text-center"> <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> </TableCell>
)} )}
@ -783,13 +809,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{/* 삭제 버튼 */} {/* 삭제 버튼 */}
<TableCell className="h-12 px-2.5 py-2 text-center"> <TableCell className="h-12 px-2.5 py-2 text-center">
{!readonly && !disabled && items.length > minItems && ( {!readonly && !disabled && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleRemoveItem(itemIndex)} 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="항목 제거" title="항목 제거"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@ -829,12 +855,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 드래그 핸들 */} {/* 드래그 핸들 */}
{allowReorder && !readonly && !disabled && ( {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 && ( {showIndex && (
<CardTitle className="text-sm font-semibold text-foreground"> {itemIndex + 1}</CardTitle> <CardTitle className="text-foreground text-sm font-semibold"> {itemIndex + 1}</CardTitle>
)} )}
</div> </div>
@ -853,13 +879,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
)} )}
{/* 삭제 버튼 */} {/* 삭제 버튼 */}
{!readonly && !disabled && items.length > minItems && ( {!readonly && !disabled && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleRemoveItem(itemIndex)} 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="항목 제거" title="항목 제거"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@ -873,9 +899,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
<div className={getFieldsLayoutClass()}> <div className={getFieldsLayoutClass()}>
{fields.map((field) => ( {fields.map((field) => (
<div key={field.name} className="space-y-1" style={{ width: field.width }}> <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.label}
{field.required && <span className="ml-1 text-destructive">*</span>} {field.required && <span className="text-destructive ml-1">*</span>}
</label> </label>
{renderField(field, itemIndex, item[field.name])} {renderField(field, itemIndex, item[field.name])}
</div> </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>: {items.length} </span>
<span> <span>
(: {minItems}, : {maxItems}) (: {minItems}, : {maxItems})

View File

@ -10,7 +10,13 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react"; 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 { ColumnInfo } from "@/types/screen";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -88,13 +94,13 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
}; };
// 필드 수정 (입력 중 - 로컬 상태만) // 필드 수정 (입력 중 - 로컬 상태만)
const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => { const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => {
setLocalInputs(prev => ({ setLocalInputs((prev) => ({
...prev, ...prev,
[index]: { [index]: {
...prev[index], ...prev[index],
[field]: value [field]: value,
} },
})); }));
}; };
@ -106,7 +112,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
newFields[index] = { newFields[index] = {
...newFields[index], ...newFields[index],
label: localInput.label, label: localInput.label,
placeholder: localInput.placeholder placeholder: localInput.placeholder,
}; };
handleFieldsChange(newFields); handleFieldsChange(newFields);
} }
@ -218,6 +224,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</p> </p>
</div> </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"> <div className="space-y-3">
<Label className="text-sm font-semibold"> </Label> <Label className="text-sm font-semibold"> </Label>
@ -263,7 +295,8 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
onSelect={() => { onSelect={() => {
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType // input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
const col = column as any; 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] 필드 타입 결정:", { console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
columnName: column.columnName, columnName: column.columnName,
@ -280,12 +313,12 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
type: fieldType as RepeaterFieldType, type: fieldType as RepeaterFieldType,
}); });
// 로컬 입력 상태도 업데이트 // 로컬 입력 상태도 업데이트
setLocalInputs(prev => ({ setLocalInputs((prev) => ({
...prev, ...prev,
[index]: { [index]: {
label: column.columnLabel || column.columnName, label: column.columnLabel || column.columnName,
placeholder: prev[index]?.placeholder || "" placeholder: prev[index]?.placeholder || "",
} },
})); }));
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false }); setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
}} }}
@ -313,7 +346,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
<Input <Input
value={localInputs[index]?.label !== undefined ? localInputs[index].label : field.label} 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)} onBlur={() => handleFieldBlur(index)}
placeholder="필드 라벨" placeholder="필드 라벨"
className="h-8 w-full text-xs" className="h-8 w-full text-xs"
@ -358,8 +391,12 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">Placeholder</Label> <Label className="text-xs">Placeholder</Label>
<Input <Input
value={localInputs[index]?.placeholder !== undefined ? localInputs[index].placeholder : (field.placeholder || "")} value={
onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)} localInputs[index]?.placeholder !== undefined
? localInputs[index].placeholder
: field.placeholder || ""
}
onChange={(e) => updateFieldLocal(index, "placeholder", e.target.value)}
onBlur={() => handleFieldBlur(index)} onBlur={() => handleFieldBlur(index)}
placeholder="입력 안내" placeholder="입력 안내"
className="h-8 w-full text-xs" className="h-8 w-full text-xs"
@ -380,9 +417,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Label className="text-[10px] text-blue-700"> 1</Label> <Label className="text-[10px] text-blue-700"> 1</Label>
<Select <Select
value={field.formula?.field1 || ""} value={field.formula?.field1 || ""}
onValueChange={(value) => updateField(index, { onValueChange={(value) =>
formula: { ...field.formula, field1: value } as CalculationFormula updateField(index, {
})} formula: { ...field.formula, field1: value } as CalculationFormula,
})
}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" /> <SelectValue placeholder="필드 선택" />
@ -404,22 +443,40 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Label className="text-[10px] text-blue-700"></Label> <Label className="text-[10px] text-blue-700"></Label>
<Select <Select
value={field.formula?.operator || "+"} value={field.formula?.operator || "+"}
onValueChange={(value) => updateField(index, { onValueChange={(value) =>
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula updateField(index, {
})} formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula,
})
}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[9999]"> <SelectContent className="z-[9999]">
<SelectItem value="+" className="text-xs">+ </SelectItem> <SelectItem value="+" className="text-xs">
<SelectItem value="-" className="text-xs">- </SelectItem> +
<SelectItem value="*" className="text-xs">× </SelectItem> </SelectItem>
<SelectItem value="/" className="text-xs">÷ </SelectItem> <SelectItem value="-" className="text-xs">
<SelectItem value="%" className="text-xs">% </SelectItem> -
<SelectItem value="round" className="text-xs"></SelectItem> </SelectItem>
<SelectItem value="floor" className="text-xs"></SelectItem> <SelectItem value="*" className="text-xs">
<SelectItem value="ceil" className="text-xs"></SelectItem> ×
</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> </SelectContent>
</Select> </Select>
</div> </div>
@ -429,23 +486,26 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-[10px] text-blue-700"> 2 / </Label> <Label className="text-[10px] text-blue-700"> 2 / </Label>
<Select <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) => { onValueChange={(value) => {
if (value.startsWith("__const__")) { if (value.startsWith("__const__")) {
updateField(index, { updateField(index, {
formula: { formula: {
...field.formula, ...field.formula,
field2: undefined, field2: undefined,
constantValue: 0 constantValue: 0,
} as CalculationFormula } as CalculationFormula,
}); });
} else { } else {
updateField(index, { updateField(index, {
formula: { formula: {
...field.formula, ...field.formula,
field2: value, field2: value,
constantValue: undefined constantValue: undefined,
} as CalculationFormula } as CalculationFormula,
}); });
} }
}} }}
@ -475,9 +535,14 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
min={0} min={0}
max={10} max={10}
value={field.formula?.decimalPlaces ?? 0} value={field.formula?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, { onChange={(e) =>
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula updateField(index, {
})} formula: {
...field.formula,
decimalPlaces: parseInt(e.target.value) || 0,
} as CalculationFormula,
})
}
className="h-8 text-xs" className="h-8 text-xs"
/> />
</div> </div>
@ -490,9 +555,14 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Input <Input
type="number" type="number"
value={field.formula.constantValue} value={field.formula.constantValue}
onChange={(e) => updateField(index, { onChange={(e) =>
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula updateField(index, {
})} formula: {
...field.formula,
constantValue: parseFloat(e.target.value) || 0,
} as CalculationFormula,
})
}
placeholder="숫자 입력" placeholder="숫자 입력"
className="h-8 text-xs" className="h-8 text-xs"
/> />
@ -507,9 +577,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Checkbox <Checkbox
id={`thousand-sep-${index}`} id={`thousand-sep-${index}`}
checked={field.numberFormat?.useThousandSeparator ?? true} checked={field.numberFormat?.useThousandSeparator ?? true}
onCheckedChange={(checked) => updateField(index, { onCheckedChange={(checked) =>
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean } updateField(index, {
})} numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
})
}
/> />
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]"> <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> <Label className="text-[10px]">:</Label>
<Input <Input
value={field.numberFormat?.decimalPlaces ?? 0} value={field.numberFormat?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, { onChange={(e) =>
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 } updateField(index, {
})} numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
})
}
type="number" type="number"
min={0} min={0}
max={10} max={10}
@ -532,17 +606,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Input <Input
value={field.numberFormat?.prefix || ""} value={field.numberFormat?.prefix || ""}
onChange={(e) => updateField(index, { onChange={(e) =>
numberFormat: { ...field.numberFormat, prefix: e.target.value } updateField(index, {
})} numberFormat: { ...field.numberFormat, prefix: e.target.value },
})
}
placeholder="접두사 (₩)" placeholder="접두사 (₩)"
className="h-7 text-[10px]" className="h-7 text-[10px]"
/> />
<Input <Input
value={field.numberFormat?.suffix || ""} value={field.numberFormat?.suffix || ""}
onChange={(e) => updateField(index, { onChange={(e) =>
numberFormat: { ...field.numberFormat, suffix: e.target.value } updateField(index, {
})} numberFormat: { ...field.numberFormat, suffix: e.target.value },
})
}
placeholder="접미사 (원)" placeholder="접미사 (원)"
className="h-7 text-[10px]" className="h-7 text-[10px]"
/> />
@ -553,10 +631,9 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="rounded bg-white p-2 text-xs"> <div className="rounded bg-white p-2 text-xs">
<span className="text-gray-500">: </span> <span className="text-gray-500">: </span>
<code className="font-mono text-blue-700"> <code className="font-mono text-blue-700">
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} { {field.formula?.field1 || "필드1"} {field.formula?.operator || "+"}{" "}
field.formula?.field2 || {field.formula?.field2 ||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2") (field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")}
}
</code> </code>
</div> </div>
</div> </div>
@ -571,9 +648,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Checkbox <Checkbox
id={`number-thousand-sep-${index}`} id={`number-thousand-sep-${index}`}
checked={field.numberFormat?.useThousandSeparator ?? false} checked={field.numberFormat?.useThousandSeparator ?? false}
onCheckedChange={(checked) => updateField(index, { onCheckedChange={(checked) =>
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean } updateField(index, {
})} numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
})
}
/> />
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]"> <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> <Label className="text-[10px]">:</Label>
<Input <Input
value={field.numberFormat?.decimalPlaces ?? 0} value={field.numberFormat?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, { onChange={(e) =>
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 } updateField(index, {
})} numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
})
}
type="number" type="number"
min={0} min={0}
max={10} max={10}
@ -596,17 +677,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Input <Input
value={field.numberFormat?.prefix || ""} value={field.numberFormat?.prefix || ""}
onChange={(e) => updateField(index, { onChange={(e) =>
numberFormat: { ...field.numberFormat, prefix: e.target.value } updateField(index, {
})} numberFormat: { ...field.numberFormat, prefix: e.target.value },
})
}
placeholder="접두사 (₩)" placeholder="접두사 (₩)"
className="h-7 text-[10px]" className="h-7 text-[10px]"
/> />
<Input <Input
value={field.numberFormat?.suffix || ""} value={field.numberFormat?.suffix || ""}
onChange={(e) => updateField(index, { onChange={(e) =>
numberFormat: { ...field.numberFormat, suffix: e.target.value } updateField(index, {
})} numberFormat: { ...field.numberFormat, suffix: e.target.value },
})
}
placeholder="접미사 (원)" placeholder="접미사 (원)"
className="h-7 text-[10px]" className="h-7 text-[10px]"
/> />
@ -624,7 +709,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
placeholder="카테고리 코드 (예: INBOUND_TYPE)" placeholder="카테고리 코드 (예: INBOUND_TYPE)"
className="h-8 w-full text-xs" className="h-8 w-full text-xs"
/> />
<p className="text-[10px] text-muted-foreground"> <p className="text-muted-foreground text-[10px]">
</p> </p>
</div> </div>

View File

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

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from "react"; 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 { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
@ -40,6 +40,7 @@ interface ReportDesignerContextType {
reorderPages: (sourceIndex: number, targetIndex: number) => void; reorderPages: (sourceIndex: number, targetIndex: number) => void;
selectPage: (pageId: string) => void; selectPage: (pageId: string) => void;
updatePageSettings: (pageId: string, settings: Partial<ReportPage>) => void; updatePageSettings: (pageId: string, settings: Partial<ReportPage>) => void;
updateWatermark: (watermark: WatermarkConfig | undefined) => void; // 전체 페이지 공유 워터마크
// 컴포넌트 (현재 페이지) // 컴포넌트 (현재 페이지)
components: ComponentConfig[]; // currentPage의 components (읽기 전용) 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 currentPage = layoutConfig.pages.find((p) => p.page_id === currentPageId);
// 현재 페이지의 컴포넌트 (읽기 전용) // 현재 페이지의 컴포넌트 (읽기 전용) - 배열인지 확인
const components = currentPage?.components || []; const components = Array.isArray(currentPage?.components) ? currentPage.components : [];
// currentPageId를 ref로 저장하여 클로저 문제 해결 // currentPageId를 ref로 저장하여 클로저 문제 해결
const currentPageIdRef = useRef<string | null>(currentPageId); const currentPageIdRef = useRef<string | null>(currentPageId);
@ -803,9 +804,10 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
const horizontalLines: number[] = []; const horizontalLines: number[] = [];
const threshold = 5; // 5px 오차 허용 const threshold = 5; // 5px 오차 허용
// 캔버스를 픽셀로 변환 (1mm = 3.7795px) // 캔버스를 픽셀로 변환 (고정 스케일 팩터: 1mm = 4px)
const canvasWidthPx = canvasWidth * 3.7795; const MM_TO_PX = 4;
const canvasHeightPx = canvasHeight * 3.7795; const canvasWidthPx = canvasWidth * MM_TO_PX;
const canvasHeightPx = canvasHeight * MM_TO_PX;
const canvasCenterX = canvasWidthPx / 2; const canvasCenterX = canvasWidthPx / 2;
const canvasCenterY = canvasHeightPx / 2; const canvasCenterY = canvasHeightPx / 2;
@ -987,10 +989,19 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
const updatePageSettings = useCallback((pageId: string, settings: Partial<ReportPage>) => { const updatePageSettings = useCallback((pageId: string, settings: Partial<ReportPage>) => {
setLayoutConfig((prev) => ({ setLayoutConfig((prev) => ({
...prev,
pages: prev.pages.map((page) => (page.page_id === pageId ? { ...page, ...settings } : page)), 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 () => { const loadLayout = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@ -1470,6 +1481,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
reorderPages, reorderPages,
selectPage, selectPage,
updatePageSettings, updatePageSettings,
updateWatermark,
// 컴포넌트 (현재 페이지) // 컴포넌트 (현재 페이지)
components, components,

View File

@ -5,7 +5,7 @@
"use client"; "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 type { DataProvidable, DataReceivable } from "@/types/data-transfer";
import { logger } from "@/lib/utils/logger"; import { logger } from "@/lib/utils/logger";
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext"; import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
@ -15,6 +15,10 @@ interface ScreenContextValue {
tableName?: string; tableName?: string;
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right) splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
formData: Record<string, any>;
updateFormData: (fieldName: string, value: any) => void;
// 컴포넌트 등록 // 컴포넌트 등록
registerDataProvider: (componentId: string, provider: DataProvidable) => void; registerDataProvider: (componentId: string, provider: DataProvidable) => void;
unregisterDataProvider: (componentId: string) => void; unregisterDataProvider: (componentId: string) => void;
@ -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 dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
const dataReceiversRef = useRef<Map<string, DataReceivable>>(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) => { const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
dataProvidersRef.current.set(componentId, provider); dataProvidersRef.current.set(componentId, provider);
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType }); logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
@ -83,10 +108,13 @@ export function ScreenContextProvider({ screenId, tableName, splitPanelPosition,
}, []); }, []);
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지) // 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
const value = React.useMemo<ScreenContextValue>(() => ({ const value = React.useMemo<ScreenContextValue>(
() => ({
screenId, screenId,
tableName, tableName,
splitPanelPosition, splitPanelPosition,
formData,
updateFormData,
registerDataProvider, registerDataProvider,
unregisterDataProvider, unregisterDataProvider,
registerDataReceiver, registerDataReceiver,
@ -95,10 +123,13 @@ export function ScreenContextProvider({ screenId, tableName, splitPanelPosition,
getDataReceiver, getDataReceiver,
getAllDataProviders, getAllDataProviders,
getAllDataReceivers, getAllDataReceivers,
}), [ }),
[
screenId, screenId,
tableName, tableName,
splitPanelPosition, splitPanelPosition,
formData,
updateFormData,
registerDataProvider, registerDataProvider,
unregisterDataProvider, unregisterDataProvider,
registerDataReceiver, registerDataReceiver,
@ -107,7 +138,8 @@ export function ScreenContextProvider({ screenId, tableName, splitPanelPosition,
getDataReceiver, getDataReceiver,
getAllDataProviders, getAllDataProviders,
getAllDataReceivers, getAllDataReceivers,
]); ],
);
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>; return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
} }
@ -130,4 +162,3 @@ export function useScreenContext() {
export function useScreenContextOptional() { export function useScreenContextOptional() {
return useContext(ScreenContext); return useContext(ScreenContext);
} }

View File

@ -282,10 +282,6 @@ export function SplitPanelProvider({
* 🆕 * 🆕
*/ */
const handleSetSelectedLeftData = useCallback((data: Record<string, any> | null) => { const handleSetSelectedLeftData = useCallback((data: Record<string, any> | null) => {
logger.info(`[SplitPanelContext] 좌측 선택 데이터 설정:`, {
hasData: !!data,
dataKeys: data ? Object.keys(data) : [],
});
setSelectedLeftData(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; return mappedData;
}, [selectedLeftData, parentDataMapping]); }, [selectedLeftData, parentDataMapping]);
@ -350,7 +341,6 @@ export function SplitPanelProvider({
} }
} }
logger.info(`[SplitPanelContext] 연결 필터 값:`, filterValues);
return filterValues; return filterValues;
}, [selectedLeftData, linkedFilters]); }, [selectedLeftData, linkedFilters]);

View File

@ -3,12 +3,14 @@ import React, {
useContext, useContext,
useState, useState,
useCallback, useCallback,
useMemo,
ReactNode, ReactNode,
} from "react"; } from "react";
import { import {
TableRegistration, TableRegistration,
TableOptionsContextValue, TableOptionsContextValue,
} from "@/types/table-options"; } from "@/types/table-options";
import { useActiveTab } from "./ActiveTabContext";
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>( const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(
undefined undefined
@ -41,25 +43,24 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
/** /**
* *
* :
* 1. selectedTableId를
* 2. unregister가 selectedTableId를
*/ */
const unregisterTable = useCallback( const unregisterTable = useCallback(
(tableId: string) => { (tableId: string) => {
setRegisteredTables((prev) => { setRegisteredTables((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev);
const removed = newMap.delete(tableId); newMap.delete(tableId);
if (removed) {
// 선택된 테이블이 제거되면 첫 번째 테이블 선택
if (selectedTableId === tableId) {
const firstTableId = newMap.keys().next().value;
setSelectedTableId(firstTableId || null);
}
}
return newMap; return newMap;
}); });
// 🚫 selectedTableId를 변경하지 않음
// 이유: useEffect 재실행 시 cleanup → register 순서로 호출되는데,
// cleanup에서 selectedTableId를 null로 만들면 필터 설정이 초기화됨
// 다른 테이블이 선택되어야 하면 TableSearchWidget에서 자동 선택함
}, },
[selectedTableId] [] // 의존성 없음 - 무한 루프 방지
); );
/** /**
@ -83,18 +84,41 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
const updatedTable = { ...table, dataCount: count }; const updatedTable = { ...table, dataCount: count };
const newMap = new Map(prev); const newMap = new Map(prev);
newMap.set(tableId, updatedTable); newMap.set(tableId, updatedTable);
console.log("🔄 [TableOptionsContext] 데이터 건수 업데이트:", {
tableId,
count,
updated: true,
});
return newMap; return newMap;
} }
console.warn("⚠️ [TableOptionsContext] 테이블을 찾을 수 없음:", tableId);
return prev; 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 ( return (
<TableOptionsContext.Provider <TableOptionsContext.Provider
value={{ value={{
@ -105,6 +129,8 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
updateTableDataCount, updateTableDataCount,
selectedTableId, selectedTableId,
setSelectedTableId, setSelectedTableId,
getActiveTabTables,
getTablesForTab,
}} }}
> >
{children} {children}

View File

@ -192,3 +192,7 @@ export function applyAutoFillToFormData(
return result; return result;
} }

View File

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

View File

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

View File

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

View File

@ -26,7 +26,14 @@ export const dataApi = {
size: number; size: number;
totalPages: 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 raw = response.data || {};
const items: any[] = (raw.data ?? raw.items ?? raw.rows ?? []) as any[]; const items: any[] = (raw.data ?? raw.items ?? raw.rows ?? []) as any[];

View File

@ -426,12 +426,29 @@ export class DynamicFormApi {
sortBy?: string; sortBy?: string;
sortOrder?: "asc" | "desc"; sortOrder?: "asc" | "desc";
filters?: Record<string, any>; filters?: Record<string, any>;
autoFilter?: {
enabled: boolean;
filterColumn?: string;
userField?: string;
};
}, },
): Promise<ApiResponse<any[]>> { ): Promise<ApiResponse<any[]>> {
try { try {
console.log("📊 테이블 데이터 조회 요청:", { tableName, params }); 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);
console.log("🔍 response.data 상세:", { console.log("🔍 response.data 상세:", {

View File

@ -32,7 +32,7 @@ export const uploadFiles = async (params: {
files: FileList | File[]; files: FileList | File[];
tableName?: string; tableName?: string;
fieldName?: string; fieldName?: string;
recordId?: string; recordId?: string | number;
docType?: string; docType?: string;
docTypeName?: string; docTypeName?: string;
targetObjid?: string; targetObjid?: string;
@ -43,6 +43,7 @@ export const uploadFiles = async (params: {
columnName?: string; columnName?: string;
isVirtualFileColumn?: boolean; isVirtualFileColumn?: boolean;
companyCode?: string; // 🔒 멀티테넌시: 회사 코드 companyCode?: string; // 🔒 멀티테넌시: 회사 코드
isRecordMode?: boolean; // 🆕 레코드 모드 플래그
}): Promise<FileUploadResponse> => { }): Promise<FileUploadResponse> => {
const formData = new FormData(); const formData = new FormData();
@ -55,7 +56,7 @@ export const uploadFiles = async (params: {
// 추가 파라미터들 추가 // 추가 파라미터들 추가
if (params.tableName) formData.append("tableName", params.tableName); if (params.tableName) formData.append("tableName", params.tableName);
if (params.fieldName) formData.append("fieldName", params.fieldName); if (params.fieldName) formData.append("fieldName", params.fieldName);
if (params.recordId) formData.append("recordId", params.recordId); if (params.recordId) formData.append("recordId", String(params.recordId));
if (params.docType) formData.append("docType", params.docType); if (params.docType) formData.append("docType", params.docType);
if (params.docTypeName) formData.append("docTypeName", params.docTypeName); if (params.docTypeName) formData.append("docTypeName", params.docTypeName);
if (params.targetObjid) formData.append("targetObjid", params.targetObjid); if (params.targetObjid) formData.append("targetObjid", params.targetObjid);
@ -66,6 +67,8 @@ export const uploadFiles = async (params: {
if (params.columnName) formData.append("columnName", params.columnName); if (params.columnName) formData.append("columnName", params.columnName);
if (params.isVirtualFileColumn !== undefined) formData.append("isVirtualFileColumn", params.isVirtualFileColumn.toString()); if (params.isVirtualFileColumn !== undefined) formData.append("isVirtualFileColumn", params.isVirtualFileColumn.toString());
if (params.companyCode) formData.append("companyCode", params.companyCode); // 🔒 멀티테넌시 if (params.companyCode) formData.append("companyCode", params.companyCode); // 🔒 멀티테넌시
// 🆕 레코드 모드 플래그 추가 (백엔드에서 attachments 컬럼 자동 업데이트용)
if (params.isRecordMode !== undefined) formData.append("isRecordMode", params.isRecordMode.toString());
const response = await apiClient.post("/files/upload", formData, { const response = await apiClient.post("/files/upload", formData, {
headers: { headers: {

Some files were not shown because too many files have changed in this diff Show More