Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
commit
c0df38c7ba
|
|
@ -3,6 +3,10 @@
|
||||||
"agent-orchestrator": {
|
"agent-orchestrator": {
|
||||||
"command": "node",
|
"command": "node",
|
||||||
"args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"]
|
"args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"]
|
||||||
|
},
|
||||||
|
"Framelink Figma MCP": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
# Claude Code (로컬 전용 - Git 제외)
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
@ -292,3 +295,7 @@ claude.md
|
||||||
*-test-screenshots/
|
*-test-screenshots/
|
||||||
*-screenshots/
|
*-screenshots/
|
||||||
*-test.mjs
|
*-test.mjs
|
||||||
|
|
||||||
|
# 개인 작업 문서 (popdocs)
|
||||||
|
popdocs/
|
||||||
|
.cursor/rules/popdocs-safety.mdc
|
||||||
436
PLAN.MD
436
PLAN.MD
|
|
@ -1,139 +1,337 @@
|
||||||
# 프로젝트: V2/V2 컴포넌트 설정 스키마 정비
|
# 현재 구현 계획: pop-card-list 입력 필드/계산 필드 구조 개편
|
||||||
|
|
||||||
## 개요
|
> **작성일**: 2026-02-24
|
||||||
|
> **상태**: 계획 완료, 코딩 대기
|
||||||
레거시 컴포넌트를 제거하고, V2/V2 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 한 곳에서 관리한다.
|
> **목적**: 입력 필드 설정 단순화 + 본문 필드에 계산식 통합 + 기존 계산 필드 섹션 제거
|
||||||
|
|
||||||
## 핵심 기능
|
|
||||||
|
|
||||||
1. [x] 레거시 컴포넌트 스키마 제거
|
|
||||||
2. [x] V2 컴포넌트 overrides 스키마 정의 (16개)
|
|
||||||
3. [x] V2 컴포넌트 overrides 스키마 정의 (9개)
|
|
||||||
4. [x] componentConfig.ts 한 파일에서 통합 관리
|
|
||||||
|
|
||||||
## 정의된 V2 컴포넌트 (18개)
|
|
||||||
|
|
||||||
- v2-table-list, v2-button-primary, v2-text-display
|
|
||||||
- v2-split-panel-layout, v2-section-card, v2-section-paper
|
|
||||||
- v2-divider-line, v2-repeat-container, v2-rack-structure
|
|
||||||
- v2-numbering-rule, v2-category-manager, v2-pivot-grid
|
|
||||||
- v2-location-swap-selector, v2-aggregation-widget
|
|
||||||
- v2-card-display, v2-table-search-widget, v2-tabs-widget
|
|
||||||
- v2-v2-repeater
|
|
||||||
|
|
||||||
## 정의된 V2 컴포넌트 (9개)
|
|
||||||
|
|
||||||
- v2-input, v2-select, v2-date
|
|
||||||
- v2-list, v2-layout, v2-group
|
|
||||||
- v2-media, v2-biz, v2-hierarchy
|
|
||||||
|
|
||||||
## 테스트 계획
|
|
||||||
|
|
||||||
### 1단계: 기본 기능
|
|
||||||
|
|
||||||
- [x] V2 레이아웃 저장 시 컴포넌트별 overrides 스키마 검증 통과
|
|
||||||
- [x] V2 컴포넌트 기본값과 스키마가 매칭됨
|
|
||||||
|
|
||||||
### 2단계: 에러 케이스
|
|
||||||
|
|
||||||
- [x] 잘못된 overrides 입력 시 Zod 검증 실패 처리 (safeParse + console.warn + graceful fallback)
|
|
||||||
- [x] 누락된 기본값 컴포넌트 저장 시 안전한 기본값 적용 (레지스트리 조회 → 빈 객체)
|
|
||||||
|
|
||||||
## 에러 처리 계획
|
|
||||||
|
|
||||||
- 스키마 파싱 실패 시 로그/에러 메시지 표준화
|
|
||||||
- 기본값 누락 시 안전한 fallback 적용
|
|
||||||
|
|
||||||
## 진행 상태
|
|
||||||
|
|
||||||
- [x] 레거시 컴포넌트 제거 완료
|
|
||||||
- [x] V2/V2 스키마 정의 완료
|
|
||||||
- [x] 한 파일 통합 관리 완료
|
|
||||||
|
|
||||||
# 프로젝트: 화면 복제 기능 개선 (DB 구조 개편 후)
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
|
|
||||||
채번/카테고리에서 `menu_objid` 의존성 제거 완료 후, 화면 복제 기능을 새 DB 구조에 맞게 수정하고 테스트합니다.
|
|
||||||
|
|
||||||
## 핵심 변경사항
|
|
||||||
|
|
||||||
### DB 구조 변경 (완료)
|
|
||||||
|
|
||||||
- 채번규칙: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반
|
|
||||||
- 카테고리: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반
|
|
||||||
- 복제 순서 의존성 문제 해결
|
|
||||||
|
|
||||||
### 복제 옵션 정리 (완료)
|
|
||||||
|
|
||||||
- [x] **삭제**: 코드 카테고리 + 코드 복사 옵션
|
|
||||||
- [x] **삭제**: 연쇄관계 설정 복사 옵션
|
|
||||||
- [x] **이름 변경**: "카테고리 매핑 + 값 복사" → "카테고리 값 복사"
|
|
||||||
|
|
||||||
### 현재 복제 옵션 (3개)
|
|
||||||
|
|
||||||
1. **채번 규칙 복사** - 채번규칙 복제
|
|
||||||
2. **카테고리 값 복사** - 카테고리 값 복제 (table_column_category_values)
|
|
||||||
3. **테이블 타입관리 입력타입 설정 복사** - table_type_columns 복제
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 테스트 계획
|
## 1. 변경 개요
|
||||||
|
|
||||||
### 1. 화면 간 연결 복제 테스트
|
### 배경
|
||||||
|
- 기존: "입력 필드", "계산 필드", "담기 버튼" 3개가 별도 섹션으로 분리
|
||||||
|
- 문제: 계산 필드가 본문 필드와 동일한 위치에 표시되어야 하는데 별도 영역에 있음
|
||||||
|
- 문제: 입력 필드의 min/max 고정값은 비실용적 (실제로는 DB 컬럼 기준 제한이 필요)
|
||||||
|
- 문제: step, columnName, sourceColumns, resultColumn 등 죽은 코드 존재
|
||||||
|
|
||||||
- [ ] 수주관리 1번→2번→3번→4번 화면 연결 상태에서 복제
|
### 목표
|
||||||
- [ ] 복제 후 연결 관계가 유지되는지 확인
|
1. **본문 필드에 계산식 지원 추가** - 필드별로 "DB 컬럼" 또는 "계산식" 선택
|
||||||
- [ ] 각 화면의 고유 키값이 새로운 화면을 참조하도록 변경되는지 확인
|
2. **입력 필드 설정 단순화** - 고정 min/max 제거, 제한 기준 컬럼 방식으로 변경
|
||||||
|
3. **기존 "계산 필드" 섹션 제거** - 본문 필드에 통합되므로 불필요
|
||||||
### 2. 제어관리 복제 테스트
|
4. **죽은 코드 정리**
|
||||||
|
|
||||||
- [ ] 다른 회사로 제어관리 복제
|
|
||||||
- [ ] 복제된 플로우 스텝/연결이 정상 작동하는지 확인
|
|
||||||
|
|
||||||
### 3. 추가 옵션 복제 테스트
|
|
||||||
|
|
||||||
- [ ] 채번규칙 복사 정상 작동 확인
|
|
||||||
- [ ] 카테고리 값 복사 정상 작동 확인
|
|
||||||
- [ ] 테이블 타입관리 입력타입 설정 복사 정상 작동 확인
|
|
||||||
|
|
||||||
### 4. 기본 복제 테스트
|
|
||||||
|
|
||||||
- [ ] 단일 화면 복제 (모달 포함)
|
|
||||||
- [ ] 그룹 전체 복제 (재귀적)
|
|
||||||
- [ ] 메뉴 동기화 정상 작동
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 관련 파일
|
## 2. 수정 대상 파일 (3개)
|
||||||
|
|
||||||
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
|
### 파일 A: `frontend/lib/registry/pop-components/types.ts`
|
||||||
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
|
|
||||||
- `backend-node/src/services/screenManagementService.ts` - 복제 서비스
|
|
||||||
- `backend-node/src/services/numberingRuleService.ts` - 채번규칙 서비스
|
|
||||||
- `docs/DB_STRUCTURE_DIAGRAM.md` - DB 구조 문서
|
|
||||||
|
|
||||||
## 진행 상태
|
#### 변경 A-1: CardFieldBinding 타입 확장
|
||||||
|
|
||||||
- [완료] DB 구조 개편 (menu_objid 의존성 제거)
|
**현재 코드** (라인 367~372):
|
||||||
- [완료] 복제 옵션 정리 (코드카테고리/연쇄관계 삭제, 이름 변경)
|
```typescript
|
||||||
- [완료] 화면 간 연결 복제 버그 수정 (targetScreenId 매핑 추가)
|
export interface CardFieldBinding {
|
||||||
- [대기] 화면 간 연결 복제 테스트
|
id: string;
|
||||||
- [대기] 제어관리 복제 테스트
|
columnName: string;
|
||||||
- [대기] 추가 옵션 복제 테스트
|
label: string;
|
||||||
|
textColor?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 코드**:
|
||||||
|
```typescript
|
||||||
|
export interface CardFieldBinding {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
textColor?: string;
|
||||||
|
valueType: "column" | "formula"; // 값 유형: DB 컬럼 또는 계산식
|
||||||
|
columnName?: string; // valueType === "column"일 때 사용
|
||||||
|
formula?: string; // valueType === "formula"일 때 사용 (예: "$input - received_qty")
|
||||||
|
unit?: string; // 계산식일 때 단위 표시 (예: "EA")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**주의**: `columnName`이 required에서 optional로 변경됨. 기존 저장 데이터와의 하위 호환 필요.
|
||||||
|
|
||||||
|
#### 변경 A-2: CardInputFieldConfig 단순화
|
||||||
|
|
||||||
|
**현재 코드** (라인 443~453):
|
||||||
|
```typescript
|
||||||
|
export interface CardInputFieldConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
columnName?: string;
|
||||||
|
label?: string;
|
||||||
|
unit?: string;
|
||||||
|
defaultValue?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
maxColumn?: string;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 코드**:
|
||||||
|
```typescript
|
||||||
|
export interface CardInputFieldConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
label?: string;
|
||||||
|
unit?: string;
|
||||||
|
limitColumn?: string; // 제한 기준 컬럼 (해당 행의 이 컬럼 값이 최대값)
|
||||||
|
saveTable?: string; // 저장 대상 테이블
|
||||||
|
saveColumn?: string; // 저장 대상 컬럼
|
||||||
|
showPackageUnit?: boolean; // 포장등록 버튼 표시 여부
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**제거 항목**:
|
||||||
|
- `columnName` -> `saveTable` + `saveColumn`으로 대체 (명확한 네이밍)
|
||||||
|
- `defaultValue` -> 제거 (제한 기준 컬럼 값으로 대체)
|
||||||
|
- `min` -> 제거 (항상 0)
|
||||||
|
- `max` -> 제거 (`limitColumn`으로 대체)
|
||||||
|
- `maxColumn` -> `limitColumn`으로 이름 변경
|
||||||
|
- `step` -> 제거 (키패드 방식에서 미사용)
|
||||||
|
|
||||||
|
#### 변경 A-3: CardCalculatedFieldConfig 제거
|
||||||
|
|
||||||
|
**삭제**: `CardCalculatedFieldConfig` 인터페이스 전체 (라인 457~464)
|
||||||
|
**삭제**: `PopCardListConfig`에서 `calculatedField?: CardCalculatedFieldConfig;` 제거
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 수정 이력
|
### 파일 B: `frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx`
|
||||||
|
|
||||||
### 2026-01-26: 버튼 targetScreenId 매핑 버그 수정
|
#### 변경 B-1: 본문 필드 편집기(FieldEditor)에 값 유형 선택 추가
|
||||||
|
|
||||||
**문제**: 그룹 복제 시 버튼의 `targetScreenId`가 새 화면으로 매핑되지 않음
|
**현재**: 필드 편집 시 라벨, 컬럼, 텍스트색상만 설정 가능
|
||||||
|
|
||||||
- 수주관리 1→2→3→4 화면 복제 시 연결이 깨지는 문제
|
**변경**: 값 유형 라디오("DB 컬럼" / "계산식") 추가
|
||||||
|
- "DB 컬럼" 선택 시: 기존 컬럼 Select 표시
|
||||||
|
- "계산식" 선택 시: 수식 입력란 + 사용 가능한 컬럼/변수 칩 목록 표시
|
||||||
|
- 사용 가능한 변수: DB 컬럼명들 + `$input` (입력 필드 활성화 시)
|
||||||
|
|
||||||
**수정 파일**: `backend-node/src/services/screenManagementService.ts`
|
**하위 호환**: 기존 저장 데이터에 `valueType`이 없으면 `"column"`으로 기본 처리
|
||||||
|
|
||||||
- `updateTabScreenReferences` 함수에 `targetScreenId` 처리 로직 추가
|
#### 변경 B-2: 입력 필드 설정 섹션 개편
|
||||||
- 쿼리에 `targetScreenId` 검색 조건 추가
|
|
||||||
- 문자열/숫자 타입 모두 처리
|
**현재 설정 항목**: 라벨, 단위, 기본값, 최소/최대, 최대값 컬럼, 저장 컬럼
|
||||||
|
|
||||||
|
**변경 설정 항목**:
|
||||||
|
```
|
||||||
|
라벨 [입고 수량 ]
|
||||||
|
단위 [EA ]
|
||||||
|
제한 기준 컬럼 [ order_qty v ]
|
||||||
|
저장 대상 테이블 [ 선택 v ]
|
||||||
|
저장 대상 컬럼 [ 선택 v ]
|
||||||
|
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
포장등록 버튼 [on/off]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 변경 B-3: "계산 필드" 섹션 제거
|
||||||
|
|
||||||
|
**삭제**: `CalculatedFieldSettingsSection` 함수 전체
|
||||||
|
**삭제**: 카드 템플릿 탭에서 "계산 필드" CollapsibleSection 제거
|
||||||
|
|
||||||
|
#### 변경 B-4: import 정리
|
||||||
|
|
||||||
|
**삭제**: `CardCalculatedFieldConfig` import
|
||||||
|
**추가**: 없음 (기존 import 재사용)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 파일 C: `frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx`
|
||||||
|
|
||||||
|
#### 변경 C-1: FieldRow에서 계산식 필드 지원
|
||||||
|
|
||||||
|
**현재**: `const value = row[field.columnName]` 로 DB 값만 표시
|
||||||
|
|
||||||
|
**변경**:
|
||||||
|
```typescript
|
||||||
|
function FieldRow({ field, row, scaled, inputValue }: {
|
||||||
|
field: CardFieldBinding;
|
||||||
|
row: RowData;
|
||||||
|
scaled: ScaledConfig;
|
||||||
|
inputValue?: number; // 입력 필드 값 (계산식에서 $input으로 참조)
|
||||||
|
}) {
|
||||||
|
const value = field.valueType === "formula" && field.formula
|
||||||
|
? evaluateFormula(field.formula, row, inputValue ?? 0)
|
||||||
|
: row[field.columnName ?? ""];
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**주의**: `inputValue`를 FieldRow까지 전달해야 하므로 CardItem -> FieldRow 경로에 prop 추가 필요
|
||||||
|
|
||||||
|
#### 변경 C-2: 계산식 필드 실시간 갱신
|
||||||
|
|
||||||
|
**현재**: 별도 `calculatedValue` useMemo가 `[calculatedField, row, inputValue]`에 반응
|
||||||
|
|
||||||
|
**변경**: FieldRow가 `inputValue` prop을 받으므로, `inputValue`가 변경될 때 계산식 필드가 자동으로 리렌더링됨. 별도 useMemo 불필요.
|
||||||
|
|
||||||
|
#### 변경 C-3: 기존 calculatedField 관련 코드 제거
|
||||||
|
|
||||||
|
**삭제 대상**:
|
||||||
|
- `calculatedField` prop 전달 (CardItem)
|
||||||
|
- `calculatedValue` useMemo
|
||||||
|
- 계산 필드 렌더링 블록 (`{calculatedField?.enabled && calculatedValue !== null && (...)}`
|
||||||
|
|
||||||
|
#### 변경 C-4: 입력 필드 로직 단순화
|
||||||
|
|
||||||
|
**변경 대상**:
|
||||||
|
- `effectiveMax`: `limitColumn` 사용, 미설정 시 999999 폴백
|
||||||
|
- `defaultValue` 자동 초기화 로직 제거 (불필요)
|
||||||
|
- `NumberInputModal`에 포장등록 on/off 전달
|
||||||
|
|
||||||
|
#### 변경 C-5: NumberInputModal에 포장등록 on/off 전달
|
||||||
|
|
||||||
|
**현재**: 포장등록 버튼 항상 표시
|
||||||
|
**변경**: `showPackageUnit` prop 추가, false이면 포장등록 버튼 숨김
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 파일 D: `frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx`
|
||||||
|
|
||||||
|
#### 변경 D-1: showPackageUnit prop 추가
|
||||||
|
|
||||||
|
**현재 props**: open, onOpenChange, unit, initialValue, initialPackageUnit, min, maxValue, onConfirm
|
||||||
|
|
||||||
|
**추가 prop**: `showPackageUnit?: boolean` (기본값 true)
|
||||||
|
|
||||||
|
**변경**: `showPackageUnit === false`이면 포장등록 버튼 숨김
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 구현 순서 (의존성 기반)
|
||||||
|
|
||||||
|
| 순서 | 작업 | 파일 | 의존성 | 상태 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| 1 | A-1: CardFieldBinding 타입 확장 | types.ts | 없음 | [ ] |
|
||||||
|
| 2 | A-2: CardInputFieldConfig 단순화 | types.ts | 없음 | [ ] |
|
||||||
|
| 3 | A-3: CardCalculatedFieldConfig 제거 | types.ts | 없음 | [ ] |
|
||||||
|
| 4 | B-1: FieldEditor에 값 유형 선택 추가 | PopCardListConfig.tsx | 순서 1 | [ ] |
|
||||||
|
| 5 | B-2: 입력 필드 설정 섹션 개편 | PopCardListConfig.tsx | 순서 2 | [ ] |
|
||||||
|
| 6 | B-3: 계산 필드 섹션 제거 | PopCardListConfig.tsx | 순서 3 | [ ] |
|
||||||
|
| 7 | B-4: import 정리 | PopCardListConfig.tsx | 순서 6 | [ ] |
|
||||||
|
| 8 | D-1: NumberInputModal showPackageUnit 추가 | NumberInputModal.tsx | 없음 | [ ] |
|
||||||
|
| 9 | C-1: FieldRow 계산식 지원 | PopCardListComponent.tsx | 순서 1 | [ ] |
|
||||||
|
| 10 | C-3: calculatedField 관련 코드 제거 | PopCardListComponent.tsx | 순서 9 | [ ] |
|
||||||
|
| 11 | C-4: 입력 필드 로직 단순화 | PopCardListComponent.tsx | 순서 2, 8 | [ ] |
|
||||||
|
| 12 | 린트 검사 | 전체 | 순서 1~11 | [ ] |
|
||||||
|
|
||||||
|
순서 1, 2, 3은 독립이므로 병렬 가능.
|
||||||
|
순서 8은 독립이므로 병렬 가능.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 사전 충돌 검사 결과
|
||||||
|
|
||||||
|
### 새로 추가할 식별자 목록
|
||||||
|
|
||||||
|
| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 |
|
||||||
|
|--------|------|-----------|-----------|-----------|
|
||||||
|
| `valueType` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 |
|
||||||
|
| `formula` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 (기존 CardCalculatedFieldConfig.formula와 다른 인터페이스) |
|
||||||
|
| `limitColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 |
|
||||||
|
| `saveTable` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 |
|
||||||
|
| `saveColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 |
|
||||||
|
| `showPackageUnit` | CardInputFieldConfig 속성 / NumberInputModal prop | types.ts, NumberInputModal.tsx | PopCardListComponent.tsx | 충돌 없음 |
|
||||||
|
|
||||||
|
### 기존 타입/함수 재사용 목록
|
||||||
|
|
||||||
|
| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 |
|
||||||
|
|------------|-----------|------------------------|
|
||||||
|
| `evaluateFormula()` | PopCardListComponent.tsx 라인 1026 | C-1: FieldRow에서 호출 (기존 함수 그대로 재사용) |
|
||||||
|
| `CardFieldBinding` | types.ts 라인 367 | A-1에서 수정, B-1/C-1에서 사용 |
|
||||||
|
| `CardInputFieldConfig` | types.ts 라인 443 | A-2에서 수정, B-2/C-4에서 사용 |
|
||||||
|
| `GroupedColumnSelect` | PopCardListConfig.tsx | B-1: 계산식 모드에서 컬럼 칩 표시에 재사용 가능 |
|
||||||
|
|
||||||
|
**사용처 있는데 정의 누락된 항목: 없음**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 에러 함정 경고
|
||||||
|
|
||||||
|
### 함정 1: 기존 저장 데이터 하위 호환
|
||||||
|
기존에 저장된 `CardFieldBinding`에는 `valueType`이 없고 `columnName`이 필수였음.
|
||||||
|
**반드시** 런타임에서 `field.valueType || "column"` 폴백 처리해야 함.
|
||||||
|
Config UI에서도 `valueType` 미존재 시 `"column"` 기본값 적용 필요.
|
||||||
|
|
||||||
|
### 함정 2: CardInputFieldConfig 하위 호환
|
||||||
|
기존 `maxColumn`이 `limitColumn`으로 이름 변경됨.
|
||||||
|
기존 저장 데이터의 `maxColumn`을 `limitColumn`으로 읽어야 함.
|
||||||
|
런타임: `inputField?.limitColumn || (inputField as any)?.maxColumn` 폴백 필요.
|
||||||
|
|
||||||
|
### 함정 3: evaluateFormula의 inputValue 전달
|
||||||
|
FieldRow에 `inputValue`를 전달하려면, CardItem -> body.fields.map -> FieldRow 경로에서 `inputValue` prop을 추가해야 함.
|
||||||
|
입력 필드가 비활성화된 경우 `inputValue`는 0으로 전달.
|
||||||
|
|
||||||
|
### 함정 4: calculatedField 제거 시 기존 데이터
|
||||||
|
기존 config에 `calculatedField` 데이터가 남아 있을 수 있음.
|
||||||
|
타입에서 제거하더라도 런타임 에러는 나지 않음 (unknown 속성은 무시됨).
|
||||||
|
다만 이전에 계산 필드로 설정한 내용은 사라짐 - 마이그레이션 없이 제거.
|
||||||
|
|
||||||
|
### 함정 5: columnName optional 변경
|
||||||
|
`CardFieldBinding.columnName`이 optional이 됨.
|
||||||
|
기존에 `row[field.columnName]`으로 직접 접근하던 코드 전부 수정 필요.
|
||||||
|
`field.columnName ?? ""` 또는 valueType 분기 처리.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 검증 방법
|
||||||
|
|
||||||
|
### 시나리오 1: 기존 본문 필드 (하위 호환)
|
||||||
|
1. 기존 저장된 카드리스트 열기
|
||||||
|
2. 본문 필드에 기존 DB 컬럼 필드가 정상 표시되는지 확인
|
||||||
|
3. 설정 패널에서 기존 필드가 "DB 컬럼" 유형으로 표시되는지 확인
|
||||||
|
|
||||||
|
### 시나리오 2: 계산식 본문 필드 추가
|
||||||
|
1. 본문 필드 추가 -> 값 유형 "계산식" 선택
|
||||||
|
2. 수식: `order_qty - received_qty` 입력
|
||||||
|
3. 카드에서 계산 결과가 정상 표시되는지 확인
|
||||||
|
|
||||||
|
### 시나리오 3: $input 참조 계산식
|
||||||
|
1. 입력 필드 활성화
|
||||||
|
2. 본문 필드 추가 -> 값 유형 "계산식" -> 수식: `$input - received_qty`
|
||||||
|
3. 키패드에서 수량 입력 시 계산 결과가 실시간 갱신되는지 확인
|
||||||
|
|
||||||
|
### 시나리오 4: 제한 기준 컬럼
|
||||||
|
1. 입력 필드 -> 제한 기준 컬럼: `order_qty`
|
||||||
|
2. order_qty=1000인 카드에서 키패드 열기
|
||||||
|
3. MAX 버튼 클릭 시 1000이 입력되고, 1001 이상 입력 불가 확인
|
||||||
|
|
||||||
|
### 시나리오 5: 포장등록 on/off
|
||||||
|
1. 입력 필드 -> 포장등록 버튼: off
|
||||||
|
2. 키패드 모달에서 포장등록 버튼이 숨겨지는지 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이전 완료 계획 (아카이브)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>pop-dashboard 4가지 아이템 모드 완성 (완료)</summary>
|
||||||
|
|
||||||
|
- [x] groupBy UI 추가
|
||||||
|
- [x] xAxisColumn 입력 UI 추가
|
||||||
|
- [x] 통계카드 카테고리 설정 UI 추가
|
||||||
|
- [x] 차트 xAxisColumn 자동 보정 로직
|
||||||
|
- [x] 통계카드 카테고리별 필터 적용
|
||||||
|
- [x] SQL 빌더 방어 로직
|
||||||
|
- [x] refreshInterval 최소값 강제
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>POP 뷰어 스크롤 수정 (완료)</summary>
|
||||||
|
|
||||||
|
- [x] overflow-hidden 제거
|
||||||
|
- [x] overflow-auto 공통 적용
|
||||||
|
- [x] 일반 모드 min-h-full 추가
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>POP 뷰어 실제 컴포넌트 렌더링 (완료)</summary>
|
||||||
|
|
||||||
|
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가
|
||||||
|
- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,696 @@
|
||||||
|
# POP 컴포넌트 정의서 v8.0
|
||||||
|
|
||||||
|
## POP 헌법 (공통 규칙)
|
||||||
|
|
||||||
|
### 제1조. 컴포넌트의 정의
|
||||||
|
|
||||||
|
- 컴포넌트란 디자이너가 그리드에 배치하는 것이다
|
||||||
|
- 그리드에 배치하지 않는 것은 컴포넌트가 아니다
|
||||||
|
|
||||||
|
### 제2조. 컴포넌트의 독립성
|
||||||
|
|
||||||
|
- 모든 컴포넌트는 독립적으로 동작한다
|
||||||
|
- 컴포넌트는 다른 컴포넌트의 존재를 직접 알지 못한다 (이벤트 버스로만 통신)
|
||||||
|
|
||||||
|
### 제3조. 데이터의 자유
|
||||||
|
|
||||||
|
- 모든 컴포넌트는 자신의 테이블 + 외부 테이블을 자유롭게 조인할 수 있다
|
||||||
|
- 컬럼별로 read/write/readwrite/hidden을 개별 설정할 수 있다
|
||||||
|
- 보유 데이터 중 원하는 컬럼만 골라서 저장할 수 있다
|
||||||
|
|
||||||
|
### 제4조. 통신의 규칙
|
||||||
|
|
||||||
|
- 컴포넌트 간 통신은 반드시 이벤트 버스(usePopEvent)를 통한다
|
||||||
|
- 컴포넌트가 다른 컴포넌트를 직접 참조하거나 호출하지 않는다
|
||||||
|
- 이벤트는 화면 단위로 격리된다 (다른 POP 화면의 이벤트를 받지 않는다)
|
||||||
|
- 같은 화면 안에서는 이벤트를 통해 자유롭게 데이터를 주고받을 수 있다
|
||||||
|
|
||||||
|
### 제5조. 역할의 분리
|
||||||
|
|
||||||
|
- 조회용 입력(pop-search)과 저장용 입력(pop-field)은 다른 컴포넌트다
|
||||||
|
- 이동/실행(pop-icon)과 값 선택 후 반환(pop-lookup)은 다른 컴포넌트다
|
||||||
|
- 자주 쓰는 패턴은 하나로 합치되, 흐름 자체는 강제하고 보이는 방식만 옵션으로 제공한다
|
||||||
|
|
||||||
|
### 제6조. 시스템 설정도 컴포넌트다
|
||||||
|
|
||||||
|
- 프로필, 테마, 대시보드 보이기/숨기기 같은 시스템 설정도 컴포넌트(pop-system)로 만든다
|
||||||
|
- 디자이너가 pop-system을 배치하지 않으면 해당 화면에 설정 기능이 없다
|
||||||
|
- 이를 통해 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정한다
|
||||||
|
|
||||||
|
### 제7조. 디자이너의 권한
|
||||||
|
|
||||||
|
- 디자이너는 컴포넌트를 배치하고 설정한다
|
||||||
|
- 디자이너는 사용자에게 커스텀을 허용할지 말지 결정한다 (userConfigurable)
|
||||||
|
- 디자이너가 "사용자 커스텀 허용 = OFF"로 설정하면, 사용자는 변경할 수 없다
|
||||||
|
- 컴포넌트의 옵션 설정(어떻게 저장하고 어떻게 조회하는지 등)은 디자이너가 결정한다
|
||||||
|
|
||||||
|
### 제8조. 컴포넌트의 구성
|
||||||
|
|
||||||
|
- 모든 컴포넌트는 3개 파일로 구성된다: 실제 컴포넌트, 디자인 미리보기, 설정 패널
|
||||||
|
- 모든 컴포넌트는 레지스트리에 등록해야 디자이너에 나타난다
|
||||||
|
- 모든 컴포넌트 인스턴스는 userConfigurable, displayName 공통 속성을 가진다
|
||||||
|
|
||||||
|
### 제9조. 모달 화면의 설계
|
||||||
|
|
||||||
|
- 모달은 인라인(컴포넌트 설정만으로 구성)과 외부 참조(별도 POP 화면 연결) 두 가지 방식이 있다
|
||||||
|
- 단순한 목록 선택은 인라인 모달을 사용한다 (설정만으로 완결)
|
||||||
|
- 복잡한 검색/필터가 필요하거나 여러 곳에서 재사용하는 모달은 별도 POP 화면을 만들어 참조한다
|
||||||
|
- 모달 안의 화면도 동일한 POP 컴포넌트 시스템으로 구성된다 (같은 그리드, 같은 컴포넌트)
|
||||||
|
- 모달 화면의 layout_data는 기존 screen_layouts_pop 테이블에 저장한다 (DB 변경 불필요)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
- 그리드 시스템 (v5.2): 완성
|
||||||
|
- 컴포넌트 레지스트리: 완성 (PopComponentRegistry.ts)
|
||||||
|
- 구현 완료: `pop-text` 1개 (pop-text.tsx)
|
||||||
|
- 기존 `components-spec.md`는 v4 기준이라 갱신 필요
|
||||||
|
|
||||||
|
## 아키텍처 개요
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph designer [디자이너]
|
||||||
|
Palette[컴포넌트 팔레트]
|
||||||
|
Grid[CSS Grid 캔버스]
|
||||||
|
ConfigPanel[속성 설정 패널]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph registry [레지스트리]
|
||||||
|
Registry[PopComponentRegistry]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph infra [공통 인프라]
|
||||||
|
DataSource[useDataSource 훅]
|
||||||
|
EventBus[usePopEvent 훅]
|
||||||
|
ActionRunner[usePopAction 훅]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph components [9개 컴포넌트]
|
||||||
|
Text[pop-text - 완성]
|
||||||
|
Dashboard[pop-dashboard]
|
||||||
|
Table[pop-table]
|
||||||
|
Button[pop-button]
|
||||||
|
Icon[pop-icon]
|
||||||
|
Search[pop-search]
|
||||||
|
Field[pop-field]
|
||||||
|
Lookup[pop-lookup]
|
||||||
|
System[pop-system]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph backend [기존 백엔드 API]
|
||||||
|
DataAPI[dataApi - 동적 CRUD]
|
||||||
|
DashAPI[dashboardApi - 통계 쿼리]
|
||||||
|
CodeAPI[commonCodeApi - 공통코드]
|
||||||
|
NumberAPI[numberingRuleApi - 채번]
|
||||||
|
end
|
||||||
|
|
||||||
|
Palette --> Grid
|
||||||
|
Grid --> ConfigPanel
|
||||||
|
ConfigPanel --> Registry
|
||||||
|
|
||||||
|
Registry --> components
|
||||||
|
components --> infra
|
||||||
|
infra --> backend
|
||||||
|
EventBus -.->|컴포넌트 간 통신| components
|
||||||
|
System -.->|보이기/숨기기 제어| components
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 공통 인프라 (모든 컴포넌트가 공유)
|
||||||
|
|
||||||
|
### 핵심 원칙: 모든 컴포넌트는 데이터를 자유롭게 다룬다
|
||||||
|
|
||||||
|
1. **데이터 전달**: 모든 컴포넌트는 자신이 보유한 데이터를 다른 컴포넌트에 전달/수신 가능
|
||||||
|
2. **테이블 조인**: 자신의 테이블 + 외부 테이블 자유롭게 조인하여 데이터 구성
|
||||||
|
3. **컬럼별 CRUD 제어**: 컬럼 단위로 "조회만" / "저장 대상" / "숨김"을 개별 설정 가능
|
||||||
|
4. **선택적 저장**: 보유 데이터 중 원하는 컬럼만 골라서 저장/수정/삭제 가능
|
||||||
|
|
||||||
|
### 공통 인스턴스 속성 (모든 컴포넌트 배치 시 설정 가능)
|
||||||
|
|
||||||
|
디자이너가 컴포넌트를 그리드에 배치할 때 설정하는 공통 속성:
|
||||||
|
|
||||||
|
- `userConfigurable`: boolean - 사용자가 이 컴포넌트를 숨길 수 있는지 (개인 설정 패널에 노출)
|
||||||
|
- `displayName`: string - 개인 설정 패널에 보여줄 이름 (예: "금일 생산실적")
|
||||||
|
|
||||||
|
### 1. DataSourceConfig (데이터 소스 설정 타입)
|
||||||
|
|
||||||
|
모든 데이터 연동 컴포넌트가 사용하는 표준 설정 구조:
|
||||||
|
|
||||||
|
- `tableName`: 대상 테이블
|
||||||
|
- `columns`: 컬럼 바인딩 목록 (ColumnBinding 배열)
|
||||||
|
- `filters`: 필터 조건 배열
|
||||||
|
- `sort`: 정렬 설정
|
||||||
|
- `aggregation`: 집계 함수 (count, sum, avg, min, max)
|
||||||
|
- `joins`: 테이블 조인 설정 (JoinConfig 배열)
|
||||||
|
- `refreshInterval`: 자동 새로고침 주기 (초)
|
||||||
|
- `limit`: 조회 건수 제한
|
||||||
|
|
||||||
|
### 1-1. ColumnBinding (컬럼별 읽기/쓰기 제어)
|
||||||
|
|
||||||
|
각 컬럼이 컴포넌트에서 어떤 역할을 하는지 개별 설정:
|
||||||
|
|
||||||
|
- `columnName`: 컬럼명
|
||||||
|
- `sourceTable`: 소속 테이블 (조인된 외부 테이블 포함)
|
||||||
|
- `mode`: "read" | "write" | "readwrite" | "hidden"
|
||||||
|
- read: 조회만 (화면에 표시하되 저장 안 함)
|
||||||
|
- write: 저장 대상 (사용자 입력 -> DB 저장)
|
||||||
|
- readwrite: 조회 + 저장 모두
|
||||||
|
- hidden: 내부 참조용 (화면에 안 보이지만 다른 컴포넌트에 전달 가능)
|
||||||
|
- `label`: 화면 표시 라벨
|
||||||
|
- `defaultValue`: 기본값
|
||||||
|
|
||||||
|
예시: 발주 품목 카드에서 5개 컬럼 중 3개만 저장
|
||||||
|
|
||||||
|
```
|
||||||
|
columns: [
|
||||||
|
{ columnName: "item_code", sourceTable: "order_items", mode: "read" },
|
||||||
|
{ columnName: "item_name", sourceTable: "item_info", mode: "read" },
|
||||||
|
{ columnName: "inbound_qty", sourceTable: "order_items", mode: "readwrite" },
|
||||||
|
{ columnName: "warehouse", sourceTable: "order_items", mode: "write" },
|
||||||
|
{ columnName: "memo", sourceTable: "order_items", mode: "write" },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1-2. JoinConfig (테이블 조인 설정)
|
||||||
|
|
||||||
|
외부 테이블과 자유롭게 조인:
|
||||||
|
|
||||||
|
- `targetTable`: 조인할 외부 테이블명
|
||||||
|
- `joinType`: "inner" | "left" | "right"
|
||||||
|
- `on`: 조인 조건 { sourceColumn, targetColumn }
|
||||||
|
- `columns`: 가져올 컬럼 목록
|
||||||
|
|
||||||
|
### 2. useDataSource 훅
|
||||||
|
|
||||||
|
DataSourceConfig를 받아서 기존 API를 호출하고 결과를 반환:
|
||||||
|
|
||||||
|
- 로딩/에러/데이터 상태 관리
|
||||||
|
- 자동 새로고침 타이머
|
||||||
|
- 필터 변경 시 자동 재조회
|
||||||
|
- 기존 `dataApi`, `dashboardApi` 활용
|
||||||
|
- **CRUD 함수 제공**: save(data), update(id, data), delete(id)
|
||||||
|
- ColumnBinding의 mode가 "write" 또는 "readwrite"인 컬럼만 저장 대상에 포함
|
||||||
|
- "read" 컬럼은 저장 시 자동 제외
|
||||||
|
|
||||||
|
### 3. usePopEvent 훅 (이벤트 버스 - 데이터 전달 포함)
|
||||||
|
|
||||||
|
컴포넌트 간 통신 (단순 이벤트 + 데이터 페이로드):
|
||||||
|
|
||||||
|
- `publish(eventName, payload)`: 이벤트 발행
|
||||||
|
- `subscribe(eventName, callback)`: 이벤트 구독
|
||||||
|
- `getSharedData(key)`: 공유 데이터 직접 읽기
|
||||||
|
- `setSharedData(key, value)`: 공유 데이터 직접 쓰기
|
||||||
|
- 화면 단위 스코프 (다른 POP 화면과 격리)
|
||||||
|
|
||||||
|
### 4. PopActionConfig (액션 설정 타입)
|
||||||
|
|
||||||
|
모든 컴포넌트가 사용할 수 있는 액션 표준 구조:
|
||||||
|
|
||||||
|
- `type`: "navigate" | "modal" | "save" | "delete" | "api" | "event" | "refresh"
|
||||||
|
- `navigate`: { screenId, url }
|
||||||
|
- `modal`: { mode, title, screenId, inlineConfig, modalSize }
|
||||||
|
- mode: "inline" (설정만으로 구성) | "screen-ref" (별도 화면 참조)
|
||||||
|
- title: 모달 제목
|
||||||
|
- screenId: mode가 "screen-ref"일 때 참조할 POP 화면 ID
|
||||||
|
- inlineConfig: mode가 "inline"일 때 사용할 DataSourceConfig + 표시 설정
|
||||||
|
- modalSize: { width, height } 모달 크기
|
||||||
|
- `save`: { targetColumns }
|
||||||
|
- `delete`: { confirmMessage }
|
||||||
|
- `api`: { method, endpoint, body }
|
||||||
|
- `event`: { eventName, payload }
|
||||||
|
- `refresh`: { targetComponents }
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컴포넌트 정의 (9개)
|
||||||
|
|
||||||
|
### 1. pop-text (완성)
|
||||||
|
|
||||||
|
- **한 줄 정의**: 보여주기만 함
|
||||||
|
- **카테고리**: display
|
||||||
|
- **역할**: 정적 표시 전용 (이벤트 없음)
|
||||||
|
- **서브타입**: text, datetime, image, title
|
||||||
|
- **데이터**: 없음 (정적 콘텐츠)
|
||||||
|
- **이벤트**: 발행 없음, 수신 없음
|
||||||
|
- **설정**: 내용, 폰트 크기/굵기, 좌우/상하 정렬, 이미지 URL/맞춤/크기, 날짜 포맷 빌더
|
||||||
|
|
||||||
|
### 2. pop-dashboard (신규 - 2026-02-09 토의 결과 반영)
|
||||||
|
|
||||||
|
- **한 줄 정의**: 여러 집계 아이템을 묶어서 다양한 방식으로 보여줌
|
||||||
|
- **카테고리**: display
|
||||||
|
- **역할**: 숫자 데이터를 집계/계산하여 시각화. 하나의 컴포넌트 안에 여러 집계 아이템을 담는 컨테이너
|
||||||
|
- **구조**: 1개 pop-dashboard = 여러 DashboardItem의 묶음. 각 아이템은 독립적으로 데이터 소스/서브타입/보이기숨기기 설정 가능
|
||||||
|
- **서브타입** (아이템별로 선택, 한 묶음에 혼합 가능):
|
||||||
|
- kpi-card: 숫자 + 단위 + 라벨 + 증감 표시
|
||||||
|
- chart: 막대/원형/라인 차트
|
||||||
|
- gauge: 게이지 (목표 대비 달성률)
|
||||||
|
- stat-card: 통계 카드 (건수 + 대기 + 링크)
|
||||||
|
- **표시 모드** (디자이너가 선택):
|
||||||
|
- arrows: 좌우 버튼으로 아이템 넘기기
|
||||||
|
- auto-slide: 전광판처럼 자동 전환 (터치 시 멈춤, 일정 시간 후 재개)
|
||||||
|
- grid: 컴포넌트 영역 내부를 행/열로 쪼개서 여러 아이템 동시 표시 (디자이너가 각 아이템 위치 직접 지정)
|
||||||
|
- scroll: 좌우 또는 상하 스와이프
|
||||||
|
- **데이터**: 각 아이템별 독립 DataSourceConfig (조인/집계 자유)
|
||||||
|
- **계산식 지원**: "생산량/총재고량", "출고량/현재고량" 같은 복합 표현 가능
|
||||||
|
- 값 A, B를 각각 다른 테이블/집계로 설정
|
||||||
|
- 표시 형태: 분수(1,234/5,678), 퍼센트(21.7%), 비율(1,234:5,678)
|
||||||
|
- **CRUD**: 주로 읽기. 목표값 수정 등 필요 시 write 컬럼으로 저장 가능
|
||||||
|
- **이벤트**:
|
||||||
|
- 수신: filter_changed, data_ready
|
||||||
|
- 발행: kpi_clicked (아이템 클릭 시 상세 데이터 전달)
|
||||||
|
- **설정**: 데이터 소스(드롭다운 기반 쉬운 집계), 집계 함수, 계산식, 라벨, 단위, 색상 구간, 차트 타입, 새로고침 주기, 목표값, 표시 모드, 아이템별 보이기/숨기기
|
||||||
|
- **보이기/숨기기**: 각 아이템별로 pop-system에서 개별 on/off 가능 (userConfigurable)
|
||||||
|
- **기존 POP 대시보드 폐기**: `frontend/components/pop/dashboard/` 폴더 전체를 이 컴포넌트로 대체 예정 (Phase 1~3 완료 후)
|
||||||
|
|
||||||
|
#### pop-dashboard 데이터 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
PopDashboardConfig {
|
||||||
|
items: DashboardItem[] // 아이템 목록 (각각 독립 설정)
|
||||||
|
displayMode: "arrows" | "auto-slide" | "grid" | "scroll"
|
||||||
|
autoSlideInterval: number // 자동 슬라이드 간격(초)
|
||||||
|
gridLayout: { columns: number, rows: number } // 행열 그리드 설정
|
||||||
|
showIndicator: boolean // 페이지 인디케이터 표시
|
||||||
|
gap: number // 아이템 간 간격
|
||||||
|
}
|
||||||
|
|
||||||
|
DashboardItem {
|
||||||
|
id: string
|
||||||
|
label: string // pop-system에서 보이기/숨기기용 이름
|
||||||
|
visible: boolean // 보이기/숨기기
|
||||||
|
subType: "kpi-card" | "chart" | "gauge" | "stat-card"
|
||||||
|
dataSource: DataSourceConfig // 각 아이템별 독립 데이터 소스
|
||||||
|
|
||||||
|
// 행열 그리드 모드에서의 위치 (디자이너가 직접 지정)
|
||||||
|
gridPosition: { col: number, row: number, colSpan: number, rowSpan: number }
|
||||||
|
|
||||||
|
// 계산식 (선택사항)
|
||||||
|
formula?: {
|
||||||
|
enabled: boolean
|
||||||
|
values: [
|
||||||
|
{ id: "A", dataSource: DataSourceConfig, label: "생산량" },
|
||||||
|
{ id: "B", dataSource: DataSourceConfig, label: "총재고량" },
|
||||||
|
]
|
||||||
|
expression: string // "A / B", "A + B", "A / B * 100"
|
||||||
|
displayFormat: "value" | "fraction" | "percent" | "ratio"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서브타입별 설정
|
||||||
|
kpiConfig?: { unit, colorRanges, showTrend, trendPeriod }
|
||||||
|
chartConfig?: { chartType, xAxis, yAxis, colors }
|
||||||
|
gaugeConfig?: { min, max, target, colorRanges }
|
||||||
|
statConfig?: { categories, showLink }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 설정 패널 흐름 (드롭다운 기반 쉬운 집계)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. [+ 아이템 추가] 버튼 클릭
|
||||||
|
2. 서브타입 선택: kpi-card / chart / gauge / stat-card
|
||||||
|
3. 데이터 모드 선택: [단일 집계] 또는 [계산식]
|
||||||
|
|
||||||
|
[단일 집계]
|
||||||
|
- 테이블 선택 (table-schema API로 목록)
|
||||||
|
- 조인할 테이블 추가 (선택사항)
|
||||||
|
- 컬럼 선택 → 집계 함수 선택 (합계/건수/평균/최소/최대)
|
||||||
|
- 필터 조건 추가
|
||||||
|
|
||||||
|
[계산식] (예: 생산량/총재고량)
|
||||||
|
- 값 A: 테이블 -> 컬럼 -> 집계함수
|
||||||
|
- 값 B: 테이블 -> 컬럼 -> 집계함수 (다른 테이블도 가능)
|
||||||
|
- 계산식: A / B
|
||||||
|
- 표시 형태: 분수 / 퍼센트 / 비율
|
||||||
|
|
||||||
|
4. 라벨, 단위, 색상 등 외형 설정
|
||||||
|
5. 행열 그리드 위치 설정 (grid 모드일 때)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. pop-table (신규 - 가장 복잡)
|
||||||
|
|
||||||
|
- **한 줄 정의**: 데이터 목록을 보여주고 편집함
|
||||||
|
- **카테고리**: display
|
||||||
|
- **역할**: 데이터 목록 표시 + 편집 (카드형/테이블형)
|
||||||
|
- **서브타입**:
|
||||||
|
- card-list: 카드 형태
|
||||||
|
- table-list: 테이블 형태 (행/열 장부)
|
||||||
|
- **데이터**: DataSourceConfig (조인/컬럼별 읽기쓰기 자유)
|
||||||
|
- **CRUD**: useDataSource의 save/update/delete 사용. write/readwrite 컬럼만 자동 추출
|
||||||
|
- **카드 템플릿** (card-list 전용): 카드 내부 미니 그리드로 요소 배치, 요소별 데이터 바인딩
|
||||||
|
- **이벤트**:
|
||||||
|
- 수신: filter_changed, refresh, data_ready
|
||||||
|
- 발행: row_selected, row_action, save_complete, delete_complete
|
||||||
|
- **설정**: 데이터 소스, 표시 모드, 카드 템플릿, 컬럼 정의, 행 선택 방식, 페이징, 정렬, 인라인 편집 여부
|
||||||
|
|
||||||
|
### 4. pop-button (신규)
|
||||||
|
|
||||||
|
- **한 줄 정의**: 누르면 액션 실행 (저장, 삭제 등)
|
||||||
|
- **카테고리**: action
|
||||||
|
- **역할**: 액션 실행 (저장, 삭제, API 호출, 모달 열기 등)
|
||||||
|
- **데이터**: 이벤트로 수신한 데이터를 액션에 활용
|
||||||
|
- **CRUD**: 버튼 클릭 시 수신 데이터 기반으로 save/update/delete 실행
|
||||||
|
- **이벤트**:
|
||||||
|
- 수신: data_ready, row_selected
|
||||||
|
- 발행: save_complete, delete_complete 등
|
||||||
|
- **설정**: 라벨, 아이콘, 크기, 스타일, 액션 설정(PopActionConfig), 확인 다이얼로그, 로딩 상태
|
||||||
|
|
||||||
|
### 5. pop-icon (신규)
|
||||||
|
|
||||||
|
- **한 줄 정의**: 누르면 어딘가로 이동 (돌아오는 값 없음)
|
||||||
|
- **카테고리**: action
|
||||||
|
- **역할**: 네비게이션 (화면 이동, URL 이동)
|
||||||
|
- **데이터**: 없음
|
||||||
|
- **이벤트**: 없음 (네비게이션은 이벤트가 아닌 직접 실행)
|
||||||
|
- **설정**: 아이콘 종류(lucide-icon), 라벨, 배경색/그라디언트, 크기, 클릭 액션(PopActionConfig), 뱃지 표시
|
||||||
|
- **pop-lookup과의 차이**: pop-icon은 이동/실행만 함. 값을 선택해서 돌려주지 않음
|
||||||
|
|
||||||
|
### 6. pop-search (신규)
|
||||||
|
|
||||||
|
- **한 줄 정의**: 조건을 입력해서 다른 컴포넌트를 조회/필터링
|
||||||
|
- **카테고리**: input
|
||||||
|
- **역할**: 다른 컴포넌트에 필터 조건 전달 + 자체 데이터 조회
|
||||||
|
- **서브타입**:
|
||||||
|
- text-search: 텍스트 검색
|
||||||
|
- date-range: 날짜 범위
|
||||||
|
- select-filter: 드롭다운 선택 (공통코드 연동)
|
||||||
|
- combo-filter: 복합 필터 (여러 조건 조합)
|
||||||
|
- **실행 방식**: auto(값 변경 즉시) 또는 button(검색 버튼 클릭 시)
|
||||||
|
- **데이터**: 공통코드/카테고리 API로 선택 항목 조회
|
||||||
|
- **이벤트**:
|
||||||
|
- 수신: 없음
|
||||||
|
- 발행: filter_changed (필터 값 변경 시)
|
||||||
|
- **설정**: 필터 타입, 대상 컬럼, 공통코드 연결, 플레이스홀더, 실행 방식(auto/button), 발행할 이벤트 이름
|
||||||
|
- **pop-field와의 차이**: pop-search 입력값은 조회용(DB에 안 들어감). pop-field 입력값은 저장용(DB에 들어감)
|
||||||
|
|
||||||
|
### 7. pop-field (신규)
|
||||||
|
|
||||||
|
- **한 줄 정의**: 저장할 값을 입력
|
||||||
|
- **카테고리**: input
|
||||||
|
- **역할**: 단일 데이터 입력 (폼 필드) - 입력한 값이 DB에 저장되는 것이 목적
|
||||||
|
- **서브타입**:
|
||||||
|
- text: 텍스트 입력
|
||||||
|
- number: 숫자 입력 (수량, 금액)
|
||||||
|
- date: 날짜 선택
|
||||||
|
- select: 드롭다운 선택
|
||||||
|
- numpad: 큰 숫자패드 (현장용)
|
||||||
|
- **데이터**: DataSourceConfig (선택적)
|
||||||
|
- select 옵션을 DB에서 조회 가능
|
||||||
|
- ColumnBinding으로 입력값의 저장 대상 테이블/컬럼 지정
|
||||||
|
- **CRUD**: 자체 저장은 보통 하지 않음. value_changed 이벤트로 pop-button 등에 전달
|
||||||
|
- **이벤트**:
|
||||||
|
- 수신: set_value (외부에서 값 설정)
|
||||||
|
- 발행: value_changed (값 + 컬럼명 + 모드 정보)
|
||||||
|
- **설정**: 입력 타입, 라벨, 플레이스홀더, 필수 여부, 유효성 검증, 최소/최대값, 단위 표시, 바인딩 컬럼
|
||||||
|
|
||||||
|
### 8. pop-lookup (신규)
|
||||||
|
|
||||||
|
- **한 줄 정의**: 모달에서 값을 골라서 반환
|
||||||
|
- **카테고리**: input
|
||||||
|
- **역할**: 필드를 클릭하면 모달이 열리고, 목록에서 선택하면 값이 반환되는 컴포넌트
|
||||||
|
- **서브타입 (모달 안 표시 방식)**:
|
||||||
|
- card: 카드형 목록
|
||||||
|
- table: 테이블형 목록
|
||||||
|
- icon-grid: 아이콘 그리드 (참조 화면의 거래처 선택처럼)
|
||||||
|
- **동작 흐름**: 필드 클릭 -> 모달 열림 -> 목록에서 선택 -> 모달 닫힘 -> 필드에 값 표시 + 이벤트 발행
|
||||||
|
- **데이터**: DataSourceConfig (모달 안 목록의 데이터 소스)
|
||||||
|
- **이벤트**:
|
||||||
|
- 수신: set_value (외부에서 값 초기화)
|
||||||
|
- 발행: value_selected (선택한 레코드 전체 데이터 전달), filter_changed (선택 값을 필터로 전달)
|
||||||
|
- **설정**: 라벨, 플레이스홀더, 데이터 소스, 모달 표시 방식(card/table/icon-grid), 표시 컬럼(모달 목록에 보여줄 컬럼), 반환 컬럼(선택 시 돌려줄 값), 발행할 이벤트 이름
|
||||||
|
- **pop-icon과의 차이**: pop-icon은 이동/실행만 하고 값이 안 돌아옴. pop-lookup은 값을 골라서 돌려줌
|
||||||
|
- **pop-search와의 차이**: pop-search는 텍스트/날짜/드롭다운으로 필터링. pop-lookup은 모달을 열어서 목록에서 선택
|
||||||
|
|
||||||
|
#### pop-lookup 모달 화면 설계 방식
|
||||||
|
|
||||||
|
pop-lookup이 열리는 모달의 내부 화면은 **두 가지 방식** 중 선택할 수 있다:
|
||||||
|
|
||||||
|
**방식 A: 인라인 모달 (기본)**
|
||||||
|
- pop-lookup 컴포넌트의 설정 패널에서 직접 모달 내부 화면을 구성
|
||||||
|
- DataSourceConfig + 표시 컬럼 + 검색 필터 설정만으로 동작
|
||||||
|
- 별도 화면 생성 없이 컴포넌트 설정만으로 완결
|
||||||
|
- 적합한 경우: 단순 목록 선택 (거래처 목록, 품목 목록 등)
|
||||||
|
|
||||||
|
**방식 B: 외부 화면 참조 (고급)**
|
||||||
|
- 별도의 POP 화면(screen_id)을 모달로 연결
|
||||||
|
- 모달 안에서 검색/필터/테이블 등 복잡한 화면을 디자이너로 자유롭게 구성
|
||||||
|
- 여러 pop-lookup에서 같은 모달 화면을 재사용 가능
|
||||||
|
- 적합한 경우: 복잡한 검색/필터가 필요한 선택 화면, 여러 화면에서 공유하는 모달
|
||||||
|
|
||||||
|
**설정 구조:**
|
||||||
|
|
||||||
|
```
|
||||||
|
modalConfig: {
|
||||||
|
mode: "inline" | "screen-ref"
|
||||||
|
|
||||||
|
// mode = "inline"일 때 사용
|
||||||
|
dataSource: DataSourceConfig
|
||||||
|
displayColumns: ColumnBinding[]
|
||||||
|
searchFilter: { enabled: boolean, targetColumns: string[] }
|
||||||
|
modalSize: { width: number, height: number }
|
||||||
|
|
||||||
|
// mode = "screen-ref"일 때 사용
|
||||||
|
screenId: number // 참조할 POP 화면 ID
|
||||||
|
returnMapping: { // 모달 화면에서 선택된 값을 어떻게 매핑할지
|
||||||
|
sourceColumn: string // 모달 화면에서 반환하는 컬럼
|
||||||
|
targetField: string // pop-lookup 필드에 표시할 값
|
||||||
|
}[]
|
||||||
|
modalSize: { width: number, height: number }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**기존 시스템과의 호환성 (검증 완료):**
|
||||||
|
|
||||||
|
| 항목 | 현재 상태 | pop-lookup 지원 여부 |
|
||||||
|
|------|-----------|---------------------|
|
||||||
|
| DB: layout_data JSONB | 유연한 JSON 구조 | modalConfig를 layout_data에 저장 가능 (스키마 변경 불필요) |
|
||||||
|
| DB: screen_layouts_pop 테이블 | screen_id + company_code 기반 | 모달 화면도 별도 screen_id로 저장 가능 |
|
||||||
|
| 프론트: TabsWidget | screenId로 외부 화면 참조 지원 | 같은 패턴으로 모달에서 외부 화면 로드 가능 |
|
||||||
|
| 프론트: detectLinkedModals API | 연결된 모달 화면 감지 기능 있음 | 화면 간 참조 관계 추적에 활용 가능 |
|
||||||
|
| 백엔드: saveLayoutPop/getLayoutPop | POP 전용 저장/조회 API 있음 | 모달 화면도 동일 API로 저장/조회 가능 |
|
||||||
|
| 레이어 시스템 | layer_id 기반 다중 레이어 지원 | 모달 내부 레이아웃을 레이어로 관리 가능 |
|
||||||
|
|
||||||
|
**DB 마이그레이션 불필요**: layout_data가 JSONB이므로 modalConfig를 컴포넌트 overrides에 포함하면 됨.
|
||||||
|
**백엔드 변경 불필요**: 기존 saveLayoutPop/getLayoutPop API가 그대로 사용 가능.
|
||||||
|
**프론트엔드 참고 패턴**: TabsWidget의 screenId 참조 방식을 그대로 차용.
|
||||||
|
|
||||||
|
### 9. pop-system (신규)
|
||||||
|
|
||||||
|
- **한 줄 정의**: 시스템 설정을 하나로 통합한 컴포넌트 (프로필, 테마, 보이기/숨기기)
|
||||||
|
- **카테고리**: system
|
||||||
|
- **역할**: 사용자 개인 설정 기능을 제공하는 통합 컴포넌트
|
||||||
|
- **내부 포함 기능**:
|
||||||
|
- 프로필 표시 (사용자명, 부서)
|
||||||
|
- 테마 선택 (기본/다크/블루/그린)
|
||||||
|
- 대시보드 보이기/숨기기 체크박스 (같은 화면의 userConfigurable=true 컴포넌트를 자동 수집)
|
||||||
|
- 하단 메뉴 보이기/숨기기
|
||||||
|
- 드래그앤드롭으로 순서 변경
|
||||||
|
- **디자이너가 설정하는 것**: 크기(그리드에서 차지하는 영역), 내부 라벨/아이콘 크기와 위치
|
||||||
|
- **사용자가 하는 것**: 체크박스로 컴포넌트 보이기/숨기기, 테마 선택, 순서 변경
|
||||||
|
- **데이터**: 같은 화면의 layout_data에서 컴포넌트 목록을 자동 수집
|
||||||
|
- **저장**: 사용자별 설정을 localStorage에 저장 (데스크탑 패턴 따름)
|
||||||
|
- **이벤트**:
|
||||||
|
- 수신: 없음
|
||||||
|
- 발행: visibility_changed (컴포넌트 보이기/숨기기 변경 시), theme_changed (테마 변경 시)
|
||||||
|
- **설정**: 내부 라벨 크기, 아이콘 크기, 위치 정도만
|
||||||
|
- **특이사항**:
|
||||||
|
- 디자이너가 이 컴포넌트를 배치하지 않으면 해당 화면에 개인 설정 기능이 없다
|
||||||
|
- 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정하는 구조
|
||||||
|
- 메인 홈에는 배치, 업무 화면(입고 등)에는 안 배치하는 식으로 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컴포넌트 간 통신 예시
|
||||||
|
|
||||||
|
### 예시 1: 검색 -> 필터 연동
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Search as pop-search
|
||||||
|
participant Dashboard as pop-dashboard
|
||||||
|
participant Table as pop-table
|
||||||
|
|
||||||
|
Note over Search: 사용자가 창고 WH01 선택
|
||||||
|
Search->>Dashboard: filter_changed
|
||||||
|
Search->>Table: filter_changed
|
||||||
|
Note over Dashboard: DataSource 재조회
|
||||||
|
Note over Table: DataSource 재조회
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: 데이터 전달 + 선택적 저장
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Table as pop-table
|
||||||
|
participant Field as pop-field
|
||||||
|
participant Button as pop-button
|
||||||
|
|
||||||
|
Note over Table: 사용자가 발주 행 선택
|
||||||
|
Table->>Field: row_selected
|
||||||
|
Table->>Button: row_selected
|
||||||
|
Note over Field: 사용자가 qty를 500으로 입력
|
||||||
|
Field->>Button: value_changed
|
||||||
|
Note over Button: 사용자가 저장 클릭
|
||||||
|
Note over Button: write/readwrite 컬럼만 추출하여 저장
|
||||||
|
Button->>Table: save_complete
|
||||||
|
Note over Table: 데이터 새로고침
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 3: pop-lookup 거래처 선택 -> 품목 조회
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Lookup as pop-lookup
|
||||||
|
participant Table as pop-table
|
||||||
|
|
||||||
|
Note over Lookup: 사용자가 거래처 필드 클릭
|
||||||
|
Note over Lookup: 모달 열림 - 거래처 목록 표시
|
||||||
|
Note over Lookup: 사용자가 대한금속 선택
|
||||||
|
Note over Lookup: 모달 닫힘 - 필드에 대한금속 표시
|
||||||
|
Lookup->>Table: filter_changed { company: "대한금속" }
|
||||||
|
Note over Table: company=대한금속 필터로 재조회
|
||||||
|
Note over Table: 발주 품목 3건 표시
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 4: pop-lookup 인라인 모달 vs 외부 화면 참조
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as 사용자
|
||||||
|
participant Lookup as pop-lookup (거래처)
|
||||||
|
participant Modal as 모달
|
||||||
|
|
||||||
|
Note over User,Modal: [방식 A: 인라인 모달]
|
||||||
|
User->>Lookup: 거래처 필드 클릭
|
||||||
|
Lookup->>Modal: 인라인 모달 열림 (DataSourceConfig 기반)
|
||||||
|
Note over Modal: supplier 테이블에서 목록 조회
|
||||||
|
Note over Modal: 테이블형 목록 표시
|
||||||
|
User->>Modal: "대한금속" 선택
|
||||||
|
Modal->>Lookup: value_selected { supplier_code: "DH001", name: "대한금속" }
|
||||||
|
Note over Lookup: 필드에 "대한금속" 표시
|
||||||
|
|
||||||
|
Note over User,Modal: [방식 B: 외부 화면 참조]
|
||||||
|
User->>Lookup: 거래처 필드 클릭
|
||||||
|
Lookup->>Modal: 모달 열림 (screenId=42 화면 로드)
|
||||||
|
Note over Modal: 별도 POP 화면 렌더링
|
||||||
|
Note over Modal: pop-search(검색) + pop-table(목록) 등 배치된 컴포넌트 동작
|
||||||
|
User->>Modal: 검색 후 "대한금속" 선택
|
||||||
|
Modal->>Lookup: returnMapping 기반으로 값 반환
|
||||||
|
Note over Lookup: 필드에 "대한금속" 표시
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 5: 컬럼별 읽기/쓰기 분리 동작
|
||||||
|
|
||||||
|
5개 컬럼이 있는 발주 화면:
|
||||||
|
|
||||||
|
- item_code (read) -> 화면에 표시, 저장 안 함
|
||||||
|
- item_name (read, 조인) -> item_info 테이블에서 가져옴, 저장 안 함
|
||||||
|
- inbound_qty (readwrite) -> 화면에 표시 + 사용자 수정 + 저장
|
||||||
|
- warehouse (write) -> 사용자 입력 + 저장
|
||||||
|
- memo (write) -> 사용자 입력 + 저장
|
||||||
|
|
||||||
|
저장 API 호출 시: `{ inbound_qty: 500, warehouse: "WH01", memo: "긴급" }` 만 전달
|
||||||
|
조회 API 호출 시: 5개 컬럼 전부 + 조인된 item_name까지 조회
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 우선순위
|
||||||
|
|
||||||
|
- Phase 0 (공통 인프라): ColumnBinding, JoinConfig, DataSourceConfig 타입, useDataSource 훅 (CRUD 포함), usePopEvent 훅 (데이터 전달 포함), PopActionConfig 타입
|
||||||
|
- Phase 1 (기본 표시): pop-dashboard (4개 서브타입 전부 + 멀티 아이템 컨테이너 + 4개 표시 모드 + 계산식)
|
||||||
|
- Phase 2 (기본 액션): pop-button, pop-icon
|
||||||
|
- Phase 3 (데이터 목록): pop-table (테이블형부터, 카드형은 후순위)
|
||||||
|
- Phase 4 (입력/연동): pop-search, pop-field, pop-lookup
|
||||||
|
- Phase 5 (고도화): pop-table 카드 템플릿
|
||||||
|
- Phase 6 (시스템): pop-system (프로필, 테마, 대시보드 보이기/숨기기 통합)
|
||||||
|
|
||||||
|
### Phase 1 상세 변경 (2026-02-09 토의 결정)
|
||||||
|
|
||||||
|
기존 계획에서 "KPI 카드 우선"이었으나, 토의 결과 **4개 서브타입 전부를 Phase 1에서 구현**으로 변경:
|
||||||
|
- kpi-card, chart, gauge, stat-card 모두 Phase 1
|
||||||
|
- 멀티 아이템 컨테이너 (arrows, auto-slide, grid, scroll)
|
||||||
|
- 계산식 지원 (formula)
|
||||||
|
- 드롭다운 기반 쉬운 집계 설정
|
||||||
|
- 기존 `frontend/components/pop/dashboard/` 폴더는 Phase 1 완료 후 폐기/삭제
|
||||||
|
|
||||||
|
### 백엔드 API 현황 (호환성 점검 완료)
|
||||||
|
|
||||||
|
기존 백엔드에 이미 구현되어 있어 새로 만들 필요 없는 API:
|
||||||
|
|
||||||
|
| API | 용도 | 비고 |
|
||||||
|
|-----|------|------|
|
||||||
|
| `dataApi.getTableData()` | 동적 테이블 조회 | 페이징, 검색, 정렬, 필터 |
|
||||||
|
| `dataApi.getJoinedData()` | 2개 테이블 조인 | Entity 조인, 필터링, 중복제거 |
|
||||||
|
| `entityJoinApi.getTableDataWithJoins()` | Entity 조인 전용 | ID->이름 자동 변환 |
|
||||||
|
| `dataApi.createRecord/updateRecord/deleteRecord()` | 동적 CRUD | - |
|
||||||
|
| `dataApi.upsertGroupedRecords()` | 그룹 UPSERT | - |
|
||||||
|
| `dashboardApi.executeQuery()` | SELECT SQL 직접 실행 | 집계/복합조인용 |
|
||||||
|
| `dashboardApi.getTableSchema()` | 테이블/컬럼 목록 | 설정 패널 드롭다운용 |
|
||||||
|
|
||||||
|
**백엔드 신규 개발 불필요** - 기존 API만으로 모든 데이터 연동 가능
|
||||||
|
|
||||||
|
### useDataSource의 API 선택 전략
|
||||||
|
|
||||||
|
```
|
||||||
|
단순 조회 (조인/집계 없음) -> dataApi.getTableData() 또는 entityJoinApi
|
||||||
|
2개 테이블 조인 -> dataApi.getJoinedData()
|
||||||
|
3개+ 테이블 조인 또는 집계 -> DataSourceConfig를 SQL로 변환 -> dashboardApi.executeQuery()
|
||||||
|
CRUD -> dataApi.createRecord/updateRecord/deleteRecord()
|
||||||
|
```
|
||||||
|
|
||||||
|
### POP 전용 훅 분리 (2026-02-09 결정)
|
||||||
|
|
||||||
|
데스크탑과의 완전 분리를 위해 POP 전용 훅은 별도 폴더:
|
||||||
|
- `frontend/hooks/pop/usePopEvent.ts` (POP 전용)
|
||||||
|
- `frontend/hooks/pop/useDataSource.ts` (POP 전용)
|
||||||
|
|
||||||
|
## 기존 시스템 호환성 검증 결과 (v8.0 추가)
|
||||||
|
|
||||||
|
v8.0에서 추가된 모달 설계 방식에 대해 기존 시스템과의 호환성을 검증한 결과:
|
||||||
|
|
||||||
|
### DB 스키마 (변경 불필요)
|
||||||
|
|
||||||
|
| 테이블 | 현재 구조 | 호환성 |
|
||||||
|
|--------|-----------|--------|
|
||||||
|
| screen_layouts_v2 | layout_data JSONB + screen_id + company_code + layer_id | modalConfig를 컴포넌트 overrides에 포함하면 됨 |
|
||||||
|
| screen_layouts_pop | 동일 구조 (POP 전용) | 모달 화면도 별도 screen_id로 저장 가능 |
|
||||||
|
|
||||||
|
- layout_data가 JSONB 타입이므로 어떤 JSON 구조든 저장 가능
|
||||||
|
- 모달 화면을 별도 screen_id로 만들어도 기존 UNIQUE(screen_id, company_code, layer_id) 제약조건과 충돌 없음
|
||||||
|
- DB 마이그레이션 불필요
|
||||||
|
|
||||||
|
### 백엔드 API (변경 불필요)
|
||||||
|
|
||||||
|
| API | 엔드포인트 | 호환성 |
|
||||||
|
|-----|-----------|--------|
|
||||||
|
| POP 레이아웃 저장 | POST /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 저장 |
|
||||||
|
| POP 레이아웃 조회 | GET /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 조회 |
|
||||||
|
| 연결 모달 감지 | detectLinkedModals(screenId) | 화면 간 참조 관계 추적에 활용 |
|
||||||
|
|
||||||
|
### 프론트엔드 (참고 패턴 존재)
|
||||||
|
|
||||||
|
| 기존 기능 | 위치 | 활용 방안 |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| TabsWidget screenId 참조 | frontend/components/screen/widgets/TabsWidget.tsx | 같은 패턴으로 모달에서 외부 화면 로드 |
|
||||||
|
| TabsConfigPanel | frontend/components/screen/config-panels/TabsConfigPanel.tsx | pop-lookup 설정 패널의 모달 화면 선택 UI 참조 |
|
||||||
|
| ScreenDesigner 탭 내부 컴포넌트 | frontend/components/screen/ScreenDesigner.tsx | 모달 내부 컴포넌트 편집 패턴 참조 |
|
||||||
|
|
||||||
|
### 결론
|
||||||
|
|
||||||
|
- DB 마이그레이션: 불필요
|
||||||
|
- 백엔드 변경: 불필요
|
||||||
|
- 프론트엔드: pop-lookup 컴포넌트 구현 시 기존 TabsWidget의 screenId 참조 패턴을 그대로 차용
|
||||||
|
- 새로운 API: 불필요 (기존 saveLayoutPop/getLayoutPop로 충분)
|
||||||
|
|
||||||
|
## 참고 파일
|
||||||
|
|
||||||
|
- 레지스트리: `frontend/lib/registry/PopComponentRegistry.ts`
|
||||||
|
- 기존 텍스트 컴포넌트: `frontend/lib/registry/pop-components/pop-text.tsx`
|
||||||
|
- 공통 스타일 타입: `frontend/lib/registry/pop-components/types.ts`
|
||||||
|
- POP 타입 정의: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||||
|
- 기존 스펙 (v4): `popdocs/components-spec.md`
|
||||||
|
- 탭 위젯 (모달 참조 패턴): `frontend/components/screen/widgets/TabsWidget.tsx`
|
||||||
|
- POP 레이아웃 API: `frontend/lib/api/screen.ts` (saveLayoutPop, getLayoutPop)
|
||||||
|
- 백엔드 화면관리: `backend-node/src/controllers/screenManagementController.ts`
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
# 프로젝트 상태 추적
|
||||||
|
|
||||||
|
> **최종 업데이트**: 2026-02-11
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 진행 중
|
||||||
|
|
||||||
|
### pop-dashboard 스타일 정리
|
||||||
|
**상태**: 코딩 완료, 브라우저 확인 대기
|
||||||
|
**계획서**: [popdocs/PLAN.md](./popdocs/PLAN.md)
|
||||||
|
**내용**: 글자 크기 커스텀 제거 + 라벨 정렬만 유지 + stale closure 수정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 작업
|
||||||
|
|
||||||
|
| 순서 | 작업 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | pop-card-list 입력 필드/계산 필드 구조 개편 (PLAN.MD 참고) | [ ] 코딩 대기 |
|
||||||
|
| 2 | pop-card-list 담기 버튼 독립화 (보류) | [ ] 대기 |
|
||||||
|
| 3 | pop-card-list 반응형 표시 런타임 적용 | [ ] 대기 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료된 작업 (최근)
|
||||||
|
|
||||||
|
| 날짜 | 작업 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| 2026-02-11 | 대시보드 스타일 정리 | FONT_SIZE_PX/글자 크기 Select 삭제, ItemStyleConfig -> labelAlign만, stale closure 수정 |
|
||||||
|
| 2026-02-10 | 디자이너 캔버스 UX 개선 | 헤더 제거, 실제 데이터 렌더링, 컴포넌트 목록 |
|
||||||
|
| 2026-02-10 | 차트/게이지/네비게이션/정렬 디자인 개선 | CartesianGrid, abbreviateNumber, 오버레이 화살표/인디케이터 |
|
||||||
|
| 2026-02-10 | 대시보드 4가지 아이템 모드 완성 | groupBy UI, xAxisColumn, 통계카드 카테고리, 필터 버그 수정 |
|
||||||
|
| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 |
|
||||||
|
| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent |
|
||||||
|
| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 알려진 이슈
|
||||||
|
|
||||||
|
| # | 이슈 | 심각도 | 상태 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| 1 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 |
|
||||||
|
| 2 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 |
|
||||||
|
| 3 | 기존 저장 데이터의 `itemStyle.align`이 `labelAlign`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 |
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# PLM System Backend - Node.js + TypeScript
|
re# PLM System Backend - Node.js + TypeScript
|
||||||
|
|
||||||
Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백엔드입니다.
|
Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백엔드입니다.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@ import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRou
|
||||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||||
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
||||||
|
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
|
||||||
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"; // 임시 주석
|
||||||
|
|
@ -306,6 +307,7 @@ 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/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||||
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
|
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
|
||||||
|
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
|
||||||
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); // 임시 주석
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,70 @@ export async function initializeBomVersion(req: Request, res: Response) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── BOM 엑셀 업로드/다운로드 ─────────────────────────
|
||||||
|
|
||||||
|
export async function createBomFromExcel(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = (req as any).user?.companyCode || "*";
|
||||||
|
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||||
|
const { rows } = req.body;
|
||||||
|
|
||||||
|
if (!rows || !Array.isArray(rows) || rows.length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await bomService.createBomFromExcel(companyCode, userId, rows);
|
||||||
|
if (!result.success) {
|
||||||
|
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("BOM 엑셀 업로드 실패", { error: error.message });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBomVersionFromExcel(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { bomId } = req.params;
|
||||||
|
const companyCode = (req as any).user?.companyCode || "*";
|
||||||
|
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||||
|
const { rows, versionName } = req.body;
|
||||||
|
|
||||||
|
if (!rows || !Array.isArray(rows) || rows.length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await bomService.createBomVersionFromExcel(bomId, companyCode, userId, rows, versionName);
|
||||||
|
if (!result.success) {
|
||||||
|
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("BOM 버전 엑셀 업로드 실패", { error: error.message });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadBomExcelData(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { bomId } = req.params;
|
||||||
|
const companyCode = (req as any).user?.companyCode || "*";
|
||||||
|
|
||||||
|
const data = await bomService.downloadBomExcelData(bomId, companyCode);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("BOM 엑셀 다운로드 데이터 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteBomVersion(req: Request, res: Response) {
|
export async function deleteBomVersion(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { bomId, versionId } = req.params;
|
const { bomId, versionId } = req.params;
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,115 @@ import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { getPool } from "../database/db";
|
import { getPool } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 조건을 WHERE절에 적용하는 공통 헬퍼
|
||||||
|
* filters JSON 배열: [{ column, operator, value }]
|
||||||
|
*/
|
||||||
|
function applyFilters(
|
||||||
|
filtersJson: string | undefined,
|
||||||
|
existingColumns: Set<string>,
|
||||||
|
whereConditions: string[],
|
||||||
|
params: any[],
|
||||||
|
startParamIndex: number,
|
||||||
|
tableName: string,
|
||||||
|
): number {
|
||||||
|
let paramIndex = startParamIndex;
|
||||||
|
|
||||||
|
if (!filtersJson) return paramIndex;
|
||||||
|
|
||||||
|
let filters: Array<{ column: string; operator: string; value: unknown }>;
|
||||||
|
try {
|
||||||
|
filters = JSON.parse(filtersJson as string);
|
||||||
|
} catch {
|
||||||
|
logger.warn("filters JSON 파싱 실패", { tableName, filtersJson });
|
||||||
|
return paramIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(filters)) return paramIndex;
|
||||||
|
|
||||||
|
for (const filter of filters) {
|
||||||
|
const { column, operator = "=", value } = filter;
|
||||||
|
if (!column || !existingColumns.has(column)) {
|
||||||
|
logger.warn("필터 컬럼 미존재 제외", { tableName, column });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case "=":
|
||||||
|
whereConditions.push(`"${column}" = $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
case "!=":
|
||||||
|
whereConditions.push(`"${column}" != $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
case ">":
|
||||||
|
case "<":
|
||||||
|
case ">=":
|
||||||
|
case "<=":
|
||||||
|
whereConditions.push(`"${column}" ${operator} $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
case "in": {
|
||||||
|
const inVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||||
|
if (inVals.length > 0) {
|
||||||
|
const ph = inVals.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||||
|
whereConditions.push(`"${column}" IN (${ph})`);
|
||||||
|
params.push(...inVals);
|
||||||
|
paramIndex += inVals.length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "notIn": {
|
||||||
|
const notInVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||||
|
if (notInVals.length > 0) {
|
||||||
|
const ph = notInVals.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||||
|
whereConditions.push(`"${column}" NOT IN (${ph})`);
|
||||||
|
params.push(...notInVals);
|
||||||
|
paramIndex += notInVals.length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "like":
|
||||||
|
whereConditions.push(`"${column}"::text ILIKE $${paramIndex}`);
|
||||||
|
params.push(`%${value}%`);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
case "isNull":
|
||||||
|
whereConditions.push(`"${column}" IS NULL`);
|
||||||
|
break;
|
||||||
|
case "isNotNull":
|
||||||
|
whereConditions.push(`"${column}" IS NOT NULL`);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
whereConditions.push(`"${column}" = $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paramIndex;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용)
|
* 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용)
|
||||||
* GET /api/entity/:tableName/distinct/:columnName
|
* GET /api/entity/:tableName/distinct/:columnName
|
||||||
*
|
*
|
||||||
* 해당 테이블의 해당 컬럼에서 DISTINCT 값을 조회하여 선택박스 옵션으로 반환
|
* 해당 테이블의 해당 컬럼에서 DISTINCT 값을 조회하여 선택박스 옵션으로 반환
|
||||||
|
*
|
||||||
|
* Query Params:
|
||||||
|
* - labelColumn: 별도의 라벨 컬럼 (선택)
|
||||||
|
* - filters: JSON 배열 형태의 필터 조건 (선택)
|
||||||
|
* 예: [{"column":"status","operator":"=","value":"active"}]
|
||||||
*/
|
*/
|
||||||
export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) {
|
export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { tableName, columnName } = req.params;
|
const { tableName, columnName } = req.params;
|
||||||
const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼
|
const { labelColumn, filters: filtersParam } = req.query;
|
||||||
|
|
||||||
// 유효성 검증
|
// 유효성 검증
|
||||||
if (!tableName || tableName === "undefined" || tableName === "null") {
|
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||||
|
|
@ -68,6 +167,16 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
||||||
whereConditions.push(`"${columnName}" IS NOT NULL`);
|
whereConditions.push(`"${columnName}" IS NOT NULL`);
|
||||||
whereConditions.push(`"${columnName}" != ''`);
|
whereConditions.push(`"${columnName}" != ''`);
|
||||||
|
|
||||||
|
// 필터 조건 적용
|
||||||
|
paramIndex = applyFilters(
|
||||||
|
filtersParam as string | undefined,
|
||||||
|
existingColumns,
|
||||||
|
whereConditions,
|
||||||
|
params,
|
||||||
|
paramIndex,
|
||||||
|
tableName,
|
||||||
|
);
|
||||||
|
|
||||||
const whereClause = whereConditions.length > 0
|
const whereClause = whereConditions.length > 0
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
: "";
|
: "";
|
||||||
|
|
@ -88,6 +197,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
||||||
columnName,
|
columnName,
|
||||||
labelColumn: effectiveLabelColumn,
|
labelColumn: effectiveLabelColumn,
|
||||||
companyCode,
|
companyCode,
|
||||||
|
hasFilters: !!filtersParam,
|
||||||
rowCount: result.rowCount,
|
rowCount: result.rowCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -111,11 +221,14 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
||||||
* Query Params:
|
* Query Params:
|
||||||
* - value: 값 컬럼 (기본: id)
|
* - value: 값 컬럼 (기본: id)
|
||||||
* - label: 표시 컬럼 (기본: name)
|
* - label: 표시 컬럼 (기본: name)
|
||||||
|
* - fields: 추가 반환 컬럼 (콤마 구분)
|
||||||
|
* - filters: JSON 배열 형태의 필터 조건 (선택)
|
||||||
|
* 예: [{"column":"status","operator":"=","value":"active"}]
|
||||||
*/
|
*/
|
||||||
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
|
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { tableName } = req.params;
|
const { tableName } = req.params;
|
||||||
const { value = "id", label = "name", fields } = req.query;
|
const { value = "id", label = "name", fields, filters: filtersParam } = req.query;
|
||||||
|
|
||||||
// tableName 유효성 검증
|
// tableName 유효성 검증
|
||||||
if (!tableName || tableName === "undefined" || tableName === "null") {
|
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||||
|
|
@ -163,6 +276,16 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 필터 조건 적용
|
||||||
|
paramIndex = applyFilters(
|
||||||
|
filtersParam as string | undefined,
|
||||||
|
existingColumns,
|
||||||
|
whereConditions,
|
||||||
|
params,
|
||||||
|
paramIndex,
|
||||||
|
tableName,
|
||||||
|
);
|
||||||
|
|
||||||
const whereClause = whereConditions.length > 0
|
const whereClause = whereConditions.length > 0
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
: "";
|
: "";
|
||||||
|
|
@ -195,6 +318,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
|
||||||
valueColumn,
|
valueColumn,
|
||||||
labelColumn: effectiveLabelColumn,
|
labelColumn: effectiveLabelColumn,
|
||||||
companyCode,
|
companyCode,
|
||||||
|
hasFilters: !!filtersParam,
|
||||||
rowCount: result.rowCount,
|
rowCount: result.rowCount,
|
||||||
extraFields: extraColumns ? true : false,
|
extraFields: extraColumns ? true : false,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,713 @@
|
||||||
|
/**
|
||||||
|
* 공정 작업기준 컨트롤러
|
||||||
|
* 품목별 라우팅/공정에 대한 작업 항목 및 상세 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Response } from "express";
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 품목/라우팅/공정 조회 (좌측 트리 데이터)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라우팅이 있는 품목 목록 조회
|
||||||
|
* 요청 쿼리: tableName(품목테이블), nameColumn, codeColumn
|
||||||
|
*/
|
||||||
|
export async function getItemsWithRouting(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
tableName = "item_info",
|
||||||
|
nameColumn = "item_name",
|
||||||
|
codeColumn = "item_number",
|
||||||
|
routingTable = "item_routing_version",
|
||||||
|
routingFkColumn = "item_code",
|
||||||
|
search = "",
|
||||||
|
} = req.query as Record<string, string>;
|
||||||
|
|
||||||
|
const searchCondition = search
|
||||||
|
? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)`
|
||||||
|
: "";
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
if (search) params.push(`%${search}%`);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.${nameColumn} AS item_name,
|
||||||
|
i.${codeColumn} AS item_code,
|
||||||
|
COUNT(rv.id) AS routing_count
|
||||||
|
FROM ${tableName} i
|
||||||
|
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||||
|
AND rv.company_code = i.company_code
|
||||||
|
WHERE i.company_code = $1
|
||||||
|
${searchCondition}
|
||||||
|
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
|
||||||
|
ORDER BY i.created_date DESC NULLS LAST
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, params);
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("품목 목록 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 품목별 라우팅 버전 + 공정 목록 조회 (트리 하위 데이터)
|
||||||
|
*/
|
||||||
|
export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { itemCode } = req.params;
|
||||||
|
const {
|
||||||
|
routingVersionTable = "item_routing_version",
|
||||||
|
routingDetailTable = "item_routing_detail",
|
||||||
|
routingFkColumn = "item_code",
|
||||||
|
processTable = "process_mng",
|
||||||
|
processNameColumn = "process_name",
|
||||||
|
processCodeColumn = "process_code",
|
||||||
|
} = req.query as Record<string, string>;
|
||||||
|
|
||||||
|
// 라우팅 버전 목록
|
||||||
|
const versionsQuery = `
|
||||||
|
SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default
|
||||||
|
FROM ${routingVersionTable}
|
||||||
|
WHERE ${routingFkColumn} = $1 AND company_code = $2
|
||||||
|
ORDER BY is_default DESC, created_date DESC
|
||||||
|
`;
|
||||||
|
const versionsResult = await getPool().query(versionsQuery, [
|
||||||
|
itemCode,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 각 버전별 공정 목록
|
||||||
|
const routings = [];
|
||||||
|
for (const version of versionsResult.rows) {
|
||||||
|
const detailsQuery = `
|
||||||
|
SELECT
|
||||||
|
rd.id AS routing_detail_id,
|
||||||
|
rd.seq_no,
|
||||||
|
rd.process_code,
|
||||||
|
rd.is_required,
|
||||||
|
rd.work_type,
|
||||||
|
p.${processNameColumn} AS process_name
|
||||||
|
FROM ${routingDetailTable} rd
|
||||||
|
LEFT JOIN ${processTable} p ON p.${processCodeColumn} = rd.process_code
|
||||||
|
AND p.company_code = rd.company_code
|
||||||
|
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
|
||||||
|
ORDER BY rd.seq_no::integer
|
||||||
|
`;
|
||||||
|
const detailsResult = await getPool().query(detailsQuery, [
|
||||||
|
version.id,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
routings.push({
|
||||||
|
...version,
|
||||||
|
processes: detailsResult.rows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: routings });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("라우팅/공정 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 기본 버전 설정
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라우팅 버전을 기본 버전으로 설정
|
||||||
|
* 같은 품목의 다른 버전은 기본 해제
|
||||||
|
*/
|
||||||
|
export async function setDefaultVersion(req: AuthenticatedRequest, res: Response) {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { versionId } = req.params;
|
||||||
|
const {
|
||||||
|
routingVersionTable = "item_routing_version",
|
||||||
|
routingFkColumn = "item_code",
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const versionResult = await client.query(
|
||||||
|
`SELECT ${routingFkColumn} AS item_code FROM ${routingVersionTable} WHERE id = $1 AND company_code = $2`,
|
||||||
|
[versionId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (versionResult.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemCode = versionResult.rows[0].item_code;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ${routingVersionTable} SET is_default = false WHERE ${routingFkColumn} = $1 AND company_code = $2`,
|
||||||
|
[itemCode, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ${routingVersionTable} SET is_default = true WHERE id = $1 AND company_code = $2`,
|
||||||
|
[versionId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
|
||||||
|
logger.info("기본 버전 설정", { companyCode, versionId, itemCode });
|
||||||
|
return res.json({ success: true, message: "기본 버전이 설정되었습니다" });
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("기본 버전 설정 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 버전 해제
|
||||||
|
*/
|
||||||
|
export async function unsetDefaultVersion(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { versionId } = req.params;
|
||||||
|
const { routingVersionTable = "item_routing_version" } = req.body;
|
||||||
|
|
||||||
|
await getPool().query(
|
||||||
|
`UPDATE ${routingVersionTable} SET is_default = false WHERE id = $1 AND company_code = $2`,
|
||||||
|
[versionId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("기본 버전 해제", { companyCode, versionId });
|
||||||
|
return res.json({ success: true, message: "기본 버전이 해제되었습니다" });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("기본 버전 해제 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 작업 항목 CRUD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공정별 작업 항목 목록 조회 (phase별 그룹)
|
||||||
|
*/
|
||||||
|
export async function getWorkItems(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { routingDetailId } = req.params;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
wi.id,
|
||||||
|
wi.routing_detail_id,
|
||||||
|
wi.work_phase,
|
||||||
|
wi.title,
|
||||||
|
wi.is_required,
|
||||||
|
wi.sort_order,
|
||||||
|
wi.description,
|
||||||
|
wi.created_date,
|
||||||
|
(SELECT COUNT(*) FROM process_work_item_detail d
|
||||||
|
WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code
|
||||||
|
)::integer AS detail_count
|
||||||
|
FROM process_work_item wi
|
||||||
|
WHERE wi.routing_detail_id = $1 AND wi.company_code = $2
|
||||||
|
ORDER BY wi.work_phase, wi.sort_order, wi.created_date
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, [routingDetailId, companyCode]);
|
||||||
|
|
||||||
|
// phase별 그룹핑
|
||||||
|
const grouped: Record<string, any[]> = {};
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const phase = row.work_phase;
|
||||||
|
if (!grouped[phase]) grouped[phase] = [];
|
||||||
|
grouped[phase].push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: grouped, items: result.rows });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 추가
|
||||||
|
*/
|
||||||
|
export async function createWorkItem(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const writer = req.user?.userId;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { routing_detail_id, work_phase, title, is_required, sort_order, description } = req.body;
|
||||||
|
|
||||||
|
if (!routing_detail_id || !work_phase || !title) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "routing_detail_id, work_phase, title은 필수입니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO process_work_item
|
||||||
|
(company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, [
|
||||||
|
companyCode,
|
||||||
|
routing_detail_id,
|
||||||
|
work_phase,
|
||||||
|
title,
|
||||||
|
is_required || "N",
|
||||||
|
sort_order || 0,
|
||||||
|
description || null,
|
||||||
|
writer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("작업 항목 생성", { companyCode, id: result.rows[0].id });
|
||||||
|
return res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 생성 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 수정
|
||||||
|
*/
|
||||||
|
export async function updateWorkItem(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { title, is_required, sort_order, description } = req.body;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE process_work_item
|
||||||
|
SET title = COALESCE($1, title),
|
||||||
|
is_required = COALESCE($2, is_required),
|
||||||
|
sort_order = COALESCE($3, sort_order),
|
||||||
|
description = COALESCE($4, description),
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $5 AND company_code = $6
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, [
|
||||||
|
title,
|
||||||
|
is_required,
|
||||||
|
sort_order,
|
||||||
|
description,
|
||||||
|
id,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("작업 항목 수정", { companyCode, id });
|
||||||
|
return res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 수정 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 삭제 (상세도 함께 삭제)
|
||||||
|
*/
|
||||||
|
export async function deleteWorkItem(req: AuthenticatedRequest, res: Response) {
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 상세 먼저 삭제
|
||||||
|
await client.query(
|
||||||
|
"DELETE FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 항목 삭제
|
||||||
|
const result = await client.query(
|
||||||
|
"DELETE FROM process_work_item WHERE id = $1 AND company_code = $2 RETURNING id",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("작업 항목 삭제", { companyCode, id });
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("작업 항목 삭제 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 작업 항목 상세 CRUD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 상세 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getWorkItemDetails(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workItemId } = req.params;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||||
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||||
|
duration_minutes, input_type, lookup_target, display_fields,
|
||||||
|
created_date
|
||||||
|
FROM process_work_item_detail
|
||||||
|
WHERE work_item_id = $1 AND company_code = $2
|
||||||
|
ORDER BY sort_order, created_date
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, [workItemId, companyCode]);
|
||||||
|
return res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 상세 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 상세 추가
|
||||||
|
*/
|
||||||
|
export async function createWorkItemDetail(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const writer = req.user?.userId;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||||
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||||
|
duration_minutes, input_type, lookup_target, display_fields,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!work_item_id || !content) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "work_item_id, content는 필수입니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// work_item이 같은 company_code인지 검증
|
||||||
|
const ownerCheck = await getPool().query(
|
||||||
|
"SELECT id FROM process_work_item WHERE id = $1 AND company_code = $2",
|
||||||
|
[work_item_id, companyCode]
|
||||||
|
);
|
||||||
|
if (ownerCheck.rowCount === 0) {
|
||||||
|
return res.status(403).json({ success: false, message: "권한이 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO process_work_item_detail
|
||||||
|
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
|
||||||
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||||
|
duration_minutes, input_type, lookup_target, display_fields)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, [
|
||||||
|
companyCode,
|
||||||
|
work_item_id,
|
||||||
|
detail_type || null,
|
||||||
|
content,
|
||||||
|
is_required || "N",
|
||||||
|
sort_order || 0,
|
||||||
|
remark || null,
|
||||||
|
writer,
|
||||||
|
inspection_code || null,
|
||||||
|
inspection_method || null,
|
||||||
|
unit || null,
|
||||||
|
lower_limit || null,
|
||||||
|
upper_limit || null,
|
||||||
|
duration_minutes || null,
|
||||||
|
input_type || null,
|
||||||
|
lookup_target || null,
|
||||||
|
display_fields || null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
|
||||||
|
return res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 상세 생성 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 상세 수정
|
||||||
|
*/
|
||||||
|
export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const {
|
||||||
|
detail_type, content, is_required, sort_order, remark,
|
||||||
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||||
|
duration_minutes, input_type, lookup_target, display_fields,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE process_work_item_detail
|
||||||
|
SET detail_type = COALESCE($1, detail_type),
|
||||||
|
content = COALESCE($2, content),
|
||||||
|
is_required = COALESCE($3, is_required),
|
||||||
|
sort_order = COALESCE($4, sort_order),
|
||||||
|
remark = COALESCE($5, remark),
|
||||||
|
inspection_code = $8,
|
||||||
|
inspection_method = $9,
|
||||||
|
unit = $10,
|
||||||
|
lower_limit = $11,
|
||||||
|
upper_limit = $12,
|
||||||
|
duration_minutes = $13,
|
||||||
|
input_type = $14,
|
||||||
|
lookup_target = $15,
|
||||||
|
display_fields = $16,
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $6 AND company_code = $7
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, [
|
||||||
|
detail_type,
|
||||||
|
content,
|
||||||
|
is_required,
|
||||||
|
sort_order,
|
||||||
|
remark,
|
||||||
|
id,
|
||||||
|
companyCode,
|
||||||
|
inspection_code || null,
|
||||||
|
inspection_method || null,
|
||||||
|
unit || null,
|
||||||
|
lower_limit || null,
|
||||||
|
upper_limit || null,
|
||||||
|
duration_minutes || null,
|
||||||
|
input_type || null,
|
||||||
|
lookup_target || null,
|
||||||
|
display_fields || null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("작업 항목 상세 수정", { companyCode, id });
|
||||||
|
return res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 상세 수정 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 상세 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteWorkItemDetail(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await getPool().query(
|
||||||
|
"DELETE FROM process_work_item_detail WHERE id = $1 AND company_code = $2 RETURNING id",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("작업 항목 상세 삭제", { companyCode, id });
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 상세 삭제 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 전체 저장 (일괄)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 저장: 작업 항목 + 상세를 일괄 저장
|
||||||
|
* 기존 데이터를 삭제하고 새로 삽입하는 replace 방식
|
||||||
|
*/
|
||||||
|
export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const writer = req.user?.userId;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { routing_detail_id, items } = req.body;
|
||||||
|
|
||||||
|
if (!routing_detail_id || !Array.isArray(items)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "routing_detail_id와 items 배열이 필요합니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 기존 상세 삭제
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM process_work_item_detail
|
||||||
|
WHERE work_item_id IN (
|
||||||
|
SELECT id FROM process_work_item
|
||||||
|
WHERE routing_detail_id = $1 AND company_code = $2
|
||||||
|
)`,
|
||||||
|
[routing_detail_id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기존 항목 삭제
|
||||||
|
await client.query(
|
||||||
|
"DELETE FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2",
|
||||||
|
[routing_detail_id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새 항목 + 상세 삽입
|
||||||
|
for (const item of items) {
|
||||||
|
const itemResult = await client.query(
|
||||||
|
`INSERT INTO process_work_item
|
||||||
|
(company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
companyCode,
|
||||||
|
routing_detail_id,
|
||||||
|
item.work_phase,
|
||||||
|
item.title,
|
||||||
|
item.is_required || "N",
|
||||||
|
item.sort_order || 0,
|
||||||
|
item.description || null,
|
||||||
|
writer,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const workItemId = itemResult.rows[0].id;
|
||||||
|
|
||||||
|
if (Array.isArray(item.details)) {
|
||||||
|
for (const detail of item.details) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO process_work_item_detail
|
||||||
|
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
|
||||||
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||||
|
duration_minutes, input_type, lookup_target, display_fields)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||||
|
[
|
||||||
|
companyCode,
|
||||||
|
workItemId,
|
||||||
|
detail.detail_type || null,
|
||||||
|
detail.content,
|
||||||
|
detail.is_required || "N",
|
||||||
|
detail.sort_order || 0,
|
||||||
|
detail.remark || null,
|
||||||
|
writer,
|
||||||
|
detail.inspection_code || null,
|
||||||
|
detail.inspection_method || null,
|
||||||
|
detail.unit || null,
|
||||||
|
detail.lower_limit || null,
|
||||||
|
detail.upper_limit || null,
|
||||||
|
detail.duration_minutes || null,
|
||||||
|
detail.input_type || null,
|
||||||
|
detail.lookup_target || null,
|
||||||
|
detail.display_fields || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("작업기준 전체 저장", { companyCode, routing_detail_id, itemCount: items.length });
|
||||||
|
return res.json({ success: true, message: "저장 완료" });
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("작업기준 전체 저장 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -921,6 +921,42 @@ export async function addTableData(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 회사별 NOT NULL 소프트 제약조건 검증
|
||||||
|
const notNullViolations = await tableManagementService.validateNotNullConstraints(
|
||||||
|
tableName,
|
||||||
|
data,
|
||||||
|
companyCode || "*"
|
||||||
|
);
|
||||||
|
if (notNullViolations.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
|
||||||
|
error: {
|
||||||
|
code: "NOT_NULL_VIOLATION",
|
||||||
|
details: notNullViolations,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사별 UNIQUE 소프트 제약조건 검증
|
||||||
|
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
|
||||||
|
tableName,
|
||||||
|
data,
|
||||||
|
companyCode || "*"
|
||||||
|
);
|
||||||
|
if (uniqueViolations.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||||
|
error: {
|
||||||
|
code: "UNIQUE_VIOLATION",
|
||||||
|
details: uniqueViolations,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 데이터 추가
|
// 데이터 추가
|
||||||
const result = await tableManagementService.addTableData(tableName, data);
|
const result = await tableManagementService.addTableData(tableName, data);
|
||||||
|
|
||||||
|
|
@ -1004,6 +1040,45 @@ export async function editTableData(
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableManagementService = new TableManagementService();
|
const tableManagementService = new TableManagementService();
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상)
|
||||||
|
const notNullViolations = await tableManagementService.validateNotNullConstraints(
|
||||||
|
tableName,
|
||||||
|
updatedData,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
if (notNullViolations.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
|
||||||
|
error: {
|
||||||
|
code: "NOT_NULL_VIOLATION",
|
||||||
|
details: notNullViolations,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외)
|
||||||
|
const excludeId = originalData?.id ? String(originalData.id) : undefined;
|
||||||
|
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
|
||||||
|
tableName,
|
||||||
|
updatedData,
|
||||||
|
companyCode,
|
||||||
|
excludeId
|
||||||
|
);
|
||||||
|
if (uniqueViolations.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||||
|
error: {
|
||||||
|
code: "UNIQUE_VIOLATION",
|
||||||
|
details: uniqueViolations,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 데이터 수정
|
// 데이터 수정
|
||||||
await tableManagementService.editTableData(
|
await tableManagementService.editTableData(
|
||||||
|
|
@ -1694,6 +1769,7 @@ export async function getCategoryColumnsByCompany(
|
||||||
let columnsResult;
|
let columnsResult;
|
||||||
|
|
||||||
// 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회
|
// 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회
|
||||||
|
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
|
||||||
if (companyCode === "*") {
|
if (companyCode === "*") {
|
||||||
const columnsQuery = `
|
const columnsQuery = `
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
|
|
@ -1713,15 +1789,15 @@ export async function getCategoryColumnsByCompany(
|
||||||
ON ttc.table_name = tl.table_name
|
ON ttc.table_name = tl.table_name
|
||||||
WHERE ttc.input_type = 'category'
|
WHERE ttc.input_type = 'category'
|
||||||
AND ttc.company_code = '*'
|
AND ttc.company_code = '*'
|
||||||
|
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||||
ORDER BY ttc.table_name, ttc.column_name
|
ORDER BY ttc.table_name, ttc.column_name
|
||||||
`;
|
`;
|
||||||
|
|
||||||
columnsResult = await pool.query(columnsQuery);
|
columnsResult = await pool.query(columnsQuery);
|
||||||
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
|
logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||||
rowCount: columnsResult.rows.length
|
rowCount: columnsResult.rows.length
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
|
|
||||||
const columnsQuery = `
|
const columnsQuery = `
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
ttc.table_name AS "tableName",
|
ttc.table_name AS "tableName",
|
||||||
|
|
@ -1740,11 +1816,12 @@ export async function getCategoryColumnsByCompany(
|
||||||
ON ttc.table_name = tl.table_name
|
ON ttc.table_name = tl.table_name
|
||||||
WHERE ttc.input_type = 'category'
|
WHERE ttc.input_type = 'category'
|
||||||
AND ttc.company_code = $1
|
AND ttc.company_code = $1
|
||||||
|
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||||
ORDER BY ttc.table_name, ttc.column_name
|
ORDER BY ttc.table_name, ttc.column_name
|
||||||
`;
|
`;
|
||||||
|
|
||||||
columnsResult = await pool.query(columnsQuery, [companyCode]);
|
columnsResult = await pool.query(columnsQuery, [companyCode]);
|
||||||
logger.info("✅ 회사별 카테고리 컬럼 조회 완료", {
|
logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||||
companyCode,
|
companyCode,
|
||||||
rowCount: columnsResult.rows.length
|
rowCount: columnsResult.rows.length
|
||||||
});
|
});
|
||||||
|
|
@ -1805,13 +1882,10 @@ export async function getCategoryColumnsByMenu(
|
||||||
const { getPool } = await import("../database/db");
|
const { getPool } = await import("../database/db");
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
// 🆕 table_type_columns에서 직접 input_type = 'category'인 컬럼들을 조회
|
// table_type_columns에서 input_type = 'category' 컬럼 조회
|
||||||
// category_column_mapping 대신 table_type_columns 기준으로 조회
|
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
|
||||||
logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
|
|
||||||
|
|
||||||
let columnsResult;
|
let columnsResult;
|
||||||
|
|
||||||
// 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회
|
|
||||||
if (companyCode === "*") {
|
if (companyCode === "*") {
|
||||||
const columnsQuery = `
|
const columnsQuery = `
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
|
|
@ -1831,15 +1905,15 @@ export async function getCategoryColumnsByMenu(
|
||||||
ON ttc.table_name = tl.table_name
|
ON ttc.table_name = tl.table_name
|
||||||
WHERE ttc.input_type = 'category'
|
WHERE ttc.input_type = 'category'
|
||||||
AND ttc.company_code = '*'
|
AND ttc.company_code = '*'
|
||||||
|
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||||
ORDER BY ttc.table_name, ttc.column_name
|
ORDER BY ttc.table_name, ttc.column_name
|
||||||
`;
|
`;
|
||||||
|
|
||||||
columnsResult = await pool.query(columnsQuery);
|
columnsResult = await pool.query(columnsQuery);
|
||||||
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
|
logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||||
rowCount: columnsResult.rows.length
|
rowCount: columnsResult.rows.length
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
|
|
||||||
const columnsQuery = `
|
const columnsQuery = `
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
ttc.table_name AS "tableName",
|
ttc.table_name AS "tableName",
|
||||||
|
|
@ -1858,11 +1932,12 @@ export async function getCategoryColumnsByMenu(
|
||||||
ON ttc.table_name = tl.table_name
|
ON ttc.table_name = tl.table_name
|
||||||
WHERE ttc.input_type = 'category'
|
WHERE ttc.input_type = 'category'
|
||||||
AND ttc.company_code = $1
|
AND ttc.company_code = $1
|
||||||
|
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||||
ORDER BY ttc.table_name, ttc.column_name
|
ORDER BY ttc.table_name, ttc.column_name
|
||||||
`;
|
`;
|
||||||
|
|
||||||
columnsResult = await pool.query(columnsQuery, [companyCode]);
|
columnsResult = await pool.query(columnsQuery, [companyCode]);
|
||||||
logger.info("✅ 회사별 카테고리 컬럼 조회 완료", {
|
logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||||
companyCode,
|
companyCode,
|
||||||
rowCount: columnsResult.rows.length
|
rowCount: columnsResult.rows.length
|
||||||
});
|
});
|
||||||
|
|
@ -2617,8 +2692,22 @@ export async function toggleTableIndex(
|
||||||
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
|
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
|
||||||
|
|
||||||
if (action === "create") {
|
if (action === "create") {
|
||||||
|
let indexColumns = `"${columnName}"`;
|
||||||
|
|
||||||
|
// 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장)
|
||||||
|
if (indexType === "unique") {
|
||||||
|
const hasCompanyCode = await query(
|
||||||
|
`SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
if (hasCompanyCode.length > 0) {
|
||||||
|
indexColumns = `"company_code", "${columnName}"`;
|
||||||
|
logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
|
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
|
||||||
const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`;
|
const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`;
|
||||||
logger.info(`인덱스 생성: ${sql}`);
|
logger.info(`인덱스 생성: ${sql}`);
|
||||||
await query(sql);
|
await query(sql);
|
||||||
} else if (action === "drop") {
|
} else if (action === "drop") {
|
||||||
|
|
@ -2639,22 +2728,55 @@ export async function toggleTableIndex(
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("인덱스 토글 오류:", error);
|
logger.error("인덱스 토글 오류:", error);
|
||||||
|
|
||||||
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내
|
const errMsg = error.message || "";
|
||||||
const errorMsg = error.message?.includes("duplicate key")
|
let userMessage = "인덱스 설정 중 오류가 발생했습니다.";
|
||||||
? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요."
|
let duplicates: any[] = [];
|
||||||
: "인덱스 설정 중 오류가 발생했습니다.";
|
|
||||||
|
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패
|
||||||
|
if (
|
||||||
|
errMsg.includes("could not create unique index") ||
|
||||||
|
errMsg.includes("duplicate key")
|
||||||
|
) {
|
||||||
|
const { columnName, tableName } = { ...req.params, ...req.body };
|
||||||
|
try {
|
||||||
|
duplicates = await query(
|
||||||
|
`SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
duplicates = await query(
|
||||||
|
`SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
|
||||||
|
);
|
||||||
|
} catch { /* 중복 조회 실패 시 무시 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dupDetails = duplicates.length > 0
|
||||||
|
? duplicates.map((d: any) => {
|
||||||
|
const company = d.company_code ? `[${d.company_code}] ` : "";
|
||||||
|
return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`;
|
||||||
|
}).join(", ")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
userMessage = dupDetails
|
||||||
|
? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}`
|
||||||
|
: `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`;
|
||||||
|
}
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: errorMsg,
|
message: userMessage,
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
error: errMsg,
|
||||||
|
duplicates,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NOT NULL 토글
|
* NOT NULL 토글 (회사별 소프트 제약조건)
|
||||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
|
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
|
||||||
|
*
|
||||||
|
* DB 레벨 ALTER TABLE 대신 table_type_columns.is_nullable을 회사별로 관리한다.
|
||||||
|
* 멀티테넌시 환경에서 회사 A는 NOT NULL, 회사 B는 NULL 허용이 가능하다.
|
||||||
*/
|
*/
|
||||||
export async function toggleColumnNullable(
|
export async function toggleColumnNullable(
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
@ -2663,6 +2785,7 @@ export async function toggleColumnNullable(
|
||||||
try {
|
try {
|
||||||
const { tableName, columnName } = req.params;
|
const { tableName, columnName } = req.params;
|
||||||
const { nullable } = req.body;
|
const { nullable } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
if (!tableName || !columnName || typeof nullable !== "boolean") {
|
if (!tableName || !columnName || typeof nullable !== "boolean") {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
|
|
@ -2672,17 +2795,53 @@ export async function toggleColumnNullable(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nullable) {
|
// is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL
|
||||||
// NOT NULL 해제
|
const isNullableValue = nullable ? "Y" : "N";
|
||||||
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`;
|
|
||||||
logger.info(`NOT NULL 해제: ${sql}`);
|
if (!nullable) {
|
||||||
await query(sql);
|
// NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인
|
||||||
} else {
|
const hasCompanyCode = await query<{ column_name: string }>(
|
||||||
// NOT NULL 설정
|
`SELECT column_name FROM information_schema.columns
|
||||||
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`;
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
logger.info(`NOT NULL 설정: ${sql}`);
|
[tableName]
|
||||||
await query(sql);
|
);
|
||||||
|
|
||||||
|
if (hasCompanyCode.length > 0) {
|
||||||
|
const nullCheckQuery = companyCode === "*"
|
||||||
|
? `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL`
|
||||||
|
: `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL AND company_code = $1`;
|
||||||
|
const nullCheckParams = companyCode === "*" ? [] : [companyCode];
|
||||||
|
|
||||||
|
const nullCheckResult = await query<{ null_count: string }>(nullCheckQuery, nullCheckParams);
|
||||||
|
const nullCount = parseInt(nullCheckResult[0]?.null_count || "0", 10);
|
||||||
|
|
||||||
|
if (nullCount > 0) {
|
||||||
|
logger.warn(`NOT NULL 설정 불가 - 해당 회사에 NULL 데이터 존재: ${tableName}.${columnName}`, {
|
||||||
|
companyCode,
|
||||||
|
nullCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `현재 회사 데이터에 NULL 값이 ${nullCount}건 존재합니다. NULL 데이터를 먼저 정리해주세요.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// table_type_columns에 회사별 is_nullable 설정 UPSERT
|
||||||
|
await query(
|
||||||
|
`INSERT INTO table_type_columns (table_name, column_name, is_nullable, company_code, created_date, updated_date)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||||
|
ON CONFLICT (table_name, column_name, company_code)
|
||||||
|
DO UPDATE SET is_nullable = $3, updated_date = NOW()`,
|
||||||
|
[tableName, columnName, isNullableValue, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`NOT NULL 소프트 제약조건 변경: ${tableName}.${columnName} → is_nullable=${isNullableValue}`, {
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -2693,14 +2852,95 @@ export async function toggleColumnNullable(
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("NOT NULL 토글 오류:", error);
|
logger.error("NOT NULL 토글 오류:", error);
|
||||||
|
|
||||||
// NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내
|
|
||||||
const errorMsg = error.message?.includes("contains null values")
|
|
||||||
? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요."
|
|
||||||
: "NOT NULL 설정 중 오류가 발생했습니다.";
|
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: errorMsg,
|
message: "NOT NULL 설정 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UNIQUE 토글 (회사별 소프트 제약조건)
|
||||||
|
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
|
||||||
|
*
|
||||||
|
* DB 레벨 인덱스 대신 table_type_columns.is_unique를 회사별로 관리한다.
|
||||||
|
* 저장 시 앱 레벨에서 중복 검증을 수행한다.
|
||||||
|
*/
|
||||||
|
export async function toggleColumnUnique(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName, columnName } = req.params;
|
||||||
|
const { unique } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!tableName || !columnName || typeof unique !== "boolean") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "tableName, columnName, unique(boolean)이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUniqueValue = unique ? "Y" : "N";
|
||||||
|
|
||||||
|
if (unique) {
|
||||||
|
// UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인
|
||||||
|
const hasCompanyCode = await query<{ column_name: string }>(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasCompanyCode.length > 0) {
|
||||||
|
const dupQuery = companyCode === "*"
|
||||||
|
? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`
|
||||||
|
: `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`;
|
||||||
|
const dupParams = companyCode === "*" ? [] : [companyCode];
|
||||||
|
|
||||||
|
const dupResult = await query<any>(dupQuery, dupParams);
|
||||||
|
|
||||||
|
if (dupResult.length > 0) {
|
||||||
|
const dupDetails = dupResult
|
||||||
|
.map((d: any) => `"${d[columnName]}" (${d.cnt}건)`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// table_type_columns에 회사별 is_unique 설정 UPSERT
|
||||||
|
await query(
|
||||||
|
`INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||||
|
ON CONFLICT (table_name, column_name, company_code)
|
||||||
|
DO UPDATE SET is_unique = $3, updated_date = NOW()`,
|
||||||
|
[tableName, columnName, isUniqueValue, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, {
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: unique
|
||||||
|
? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.`
|
||||||
|
: `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("UNIQUE 토글 오류:", error);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "UNIQUE 설정 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ router.get("/:bomId/header", bomController.getBomHeader);
|
||||||
router.get("/:bomId/history", bomController.getBomHistory);
|
router.get("/:bomId/history", bomController.getBomHistory);
|
||||||
router.post("/:bomId/history", bomController.addBomHistory);
|
router.post("/:bomId/history", bomController.addBomHistory);
|
||||||
|
|
||||||
|
// 엑셀 업로드/다운로드
|
||||||
|
router.post("/excel-upload", bomController.createBomFromExcel);
|
||||||
|
router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel);
|
||||||
|
router.get("/:bomId/excel-download", bomController.downloadBomExcelData);
|
||||||
|
|
||||||
// 버전
|
// 버전
|
||||||
router.get("/:bomId/versions", bomController.getBomVersions);
|
router.get("/:bomId/versions", bomController.getBomVersions);
|
||||||
router.post("/:bomId/versions", bomController.createBomVersion);
|
router.post("/:bomId/versions", bomController.createBomVersion);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* 공정 작업기준 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import * as ctrl from "../controllers/processWorkStandardController";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 품목/라우팅/공정 조회 (좌측 트리)
|
||||||
|
router.get("/items", ctrl.getItemsWithRouting);
|
||||||
|
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
|
||||||
|
|
||||||
|
// 기본 버전 설정/해제
|
||||||
|
router.put("/versions/:versionId/set-default", ctrl.setDefaultVersion);
|
||||||
|
router.put("/versions/:versionId/unset-default", ctrl.unsetDefaultVersion);
|
||||||
|
|
||||||
|
// 작업 항목 CRUD
|
||||||
|
router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems);
|
||||||
|
router.post("/work-items", ctrl.createWorkItem);
|
||||||
|
router.put("/work-items/:id", ctrl.updateWorkItem);
|
||||||
|
router.delete("/work-items/:id", ctrl.deleteWorkItem);
|
||||||
|
|
||||||
|
// 작업 항목 상세 CRUD
|
||||||
|
router.get("/work-items/:workItemId/details", ctrl.getWorkItemDetails);
|
||||||
|
router.post("/work-item-details", ctrl.createWorkItemDetail);
|
||||||
|
router.put("/work-item-details/:id", ctrl.updateWorkItemDetail);
|
||||||
|
router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
|
||||||
|
|
||||||
|
// 전체 저장 (일괄)
|
||||||
|
router.put("/save-all", ctrl.saveAll);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
setTablePrimaryKey, // 🆕 PK 설정
|
setTablePrimaryKey, // 🆕 PK 설정
|
||||||
toggleTableIndex, // 🆕 인덱스 토글
|
toggleTableIndex, // 🆕 인덱스 토글
|
||||||
toggleColumnNullable, // 🆕 NOT NULL 토글
|
toggleColumnNullable, // 🆕 NOT NULL 토글
|
||||||
|
toggleColumnUnique, // 🆕 UNIQUE 토글
|
||||||
} from "../controllers/tableManagementController";
|
} from "../controllers/tableManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -161,6 +162,12 @@ router.post("/tables/:tableName/indexes", toggleTableIndex);
|
||||||
*/
|
*/
|
||||||
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
|
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UNIQUE 토글
|
||||||
|
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
|
||||||
|
*/
|
||||||
|
router.put("/tables/:tableName/columns/:columnName/unique", toggleColumnUnique);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 존재 여부 확인
|
* 테이블 존재 여부 확인
|
||||||
* GET /api/table-management/tables/:tableName/exists
|
* GET /api/table-management/tables/:tableName/exists
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,485 @@ export async function initializeBomVersion(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── BOM 엑셀 업로드 ─────────────────────────────
|
||||||
|
|
||||||
|
interface BomExcelRow {
|
||||||
|
level: number;
|
||||||
|
item_number: string;
|
||||||
|
item_name?: string;
|
||||||
|
quantity: number;
|
||||||
|
unit?: string;
|
||||||
|
process_type?: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BomExcelUploadResult {
|
||||||
|
success: boolean;
|
||||||
|
insertedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
errors: string[];
|
||||||
|
unmatchedItems: string[];
|
||||||
|
createdBomId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOM 엑셀 업로드 - 새 BOM 생성
|
||||||
|
*
|
||||||
|
* 엑셀 레벨 체계:
|
||||||
|
* 레벨 0 = BOM 마스터 (최상위 품목) → bom 테이블에 INSERT
|
||||||
|
* 레벨 1 = 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0)
|
||||||
|
* 레벨 2 = 자품목의 자품목 → bom_detail (parent_detail_id=부모ID, DB level=1)
|
||||||
|
* 레벨 N = ... → bom_detail (DB level=N-1)
|
||||||
|
*/
|
||||||
|
export async function createBomFromExcel(
|
||||||
|
companyCode: string,
|
||||||
|
userId: string,
|
||||||
|
rows: BomExcelRow[],
|
||||||
|
): Promise<BomExcelUploadResult> {
|
||||||
|
const result: BomExcelUploadResult = {
|
||||||
|
success: false,
|
||||||
|
insertedCount: 0,
|
||||||
|
skippedCount: 0,
|
||||||
|
errors: [],
|
||||||
|
unmatchedItems: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
result.errors.push("업로드할 데이터가 없습니다");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerRow = rows.find(r => r.level === 0);
|
||||||
|
const detailRows = rows.filter(r => r.level > 0);
|
||||||
|
|
||||||
|
if (!headerRow) {
|
||||||
|
result.errors.push("레벨 0(BOM 마스터) 행이 필요합니다");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!headerRow.item_number?.trim()) {
|
||||||
|
result.errors.push("레벨 0(BOM 마스터)의 품번은 필수입니다");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (detailRows.length === 0) {
|
||||||
|
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레벨 유효성 검사
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
if (row.level < 0) {
|
||||||
|
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
|
||||||
|
}
|
||||||
|
if (i > 0 && row.level > rows[i - 1].level + 1) {
|
||||||
|
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다 (현재: ${row.level}, 이전: ${rows[i - 1].level})`);
|
||||||
|
}
|
||||||
|
if (row.level > 0 && !row.item_number?.trim()) {
|
||||||
|
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return transaction(async (client) => {
|
||||||
|
// 1. 모든 품번 일괄 조회 (헤더 + 디테일)
|
||||||
|
const allItemNumbers = [...new Set(rows.filter(r => r.item_number?.trim()).map(r => r.item_number.trim()))];
|
||||||
|
const itemLookup = await client.query(
|
||||||
|
`SELECT id, item_number, item_name, unit FROM item_info
|
||||||
|
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
|
||||||
|
[companyCode, allItemNumbers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
|
||||||
|
for (const item of itemLookup.rows) {
|
||||||
|
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const num of allItemNumbers) {
|
||||||
|
if (!itemMap.has(num)) {
|
||||||
|
result.unmatchedItems.push(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.unmatchedItems.length > 0) {
|
||||||
|
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. bom 마스터 생성 (레벨 0)
|
||||||
|
const headerItemInfo = itemMap.get(headerRow.item_number.trim())!;
|
||||||
|
|
||||||
|
// 동일 품목으로 이미 BOM이 존재하는지 확인
|
||||||
|
const dupCheck = await client.query(
|
||||||
|
`SELECT id FROM bom WHERE item_id = $1 AND company_code = $2 AND status = 'active'`,
|
||||||
|
[headerItemInfo.id, companyCode],
|
||||||
|
);
|
||||||
|
if (dupCheck.rows.length > 0) {
|
||||||
|
result.errors.push(`해당 품목(${headerRow.item_number})으로 등록된 BOM이 이미 존재합니다`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bomInsert = await client.query(
|
||||||
|
`INSERT INTO bom (item_id, item_code, item_name, base_qty, unit, version, status, remark, writer, company_code)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, '1.0', 'active', $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
headerItemInfo.id,
|
||||||
|
headerRow.item_number.trim(),
|
||||||
|
headerItemInfo.item_name,
|
||||||
|
String(headerRow.quantity || 1),
|
||||||
|
headerRow.unit || headerItemInfo.unit || null,
|
||||||
|
headerRow.remark || null,
|
||||||
|
userId,
|
||||||
|
companyCode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const newBomId = bomInsert.rows[0].id;
|
||||||
|
result.createdBomId = newBomId;
|
||||||
|
|
||||||
|
// 3. bom_version 생성
|
||||||
|
const versionInsert = await client.query(
|
||||||
|
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
|
||||||
|
VALUES ($1, '1.0', 0, 'active', $2, $3) RETURNING id`,
|
||||||
|
[newBomId, userId, companyCode],
|
||||||
|
);
|
||||||
|
const versionId = versionInsert.rows[0].id;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
|
||||||
|
[versionId, newBomId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. bom_detail INSERT (레벨 1+ → DB level = 엑셀 level - 1)
|
||||||
|
const levelStack: string[] = [];
|
||||||
|
const seqCounterByParent = new Map<string, number>();
|
||||||
|
|
||||||
|
for (let i = 0; i < detailRows.length; i++) {
|
||||||
|
const row = detailRows[i];
|
||||||
|
const itemInfo = itemMap.get(row.item_number.trim())!;
|
||||||
|
const dbLevel = row.level - 1;
|
||||||
|
|
||||||
|
while (levelStack.length > dbLevel) {
|
||||||
|
levelStack.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
|
||||||
|
const parentKey = parentDetailId || "__root__";
|
||||||
|
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
|
||||||
|
seqCounterByParent.set(parentKey, currentSeq);
|
||||||
|
|
||||||
|
const insertResult = await client.query(
|
||||||
|
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
newBomId,
|
||||||
|
versionId,
|
||||||
|
parentDetailId,
|
||||||
|
itemInfo.id,
|
||||||
|
String(dbLevel),
|
||||||
|
String(currentSeq),
|
||||||
|
String(row.quantity || 1),
|
||||||
|
row.unit || itemInfo.unit || null,
|
||||||
|
row.process_type || null,
|
||||||
|
row.remark || null,
|
||||||
|
userId,
|
||||||
|
companyCode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
levelStack.push(insertResult.rows[0].id);
|
||||||
|
result.insertedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 이력 기록
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
|
||||||
|
VALUES ($1, 'excel_upload', $2, $3, $4)`,
|
||||||
|
[newBomId, `엑셀 업로드로 BOM 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
result.success = true;
|
||||||
|
logger.info("BOM 엑셀 업로드 - 새 BOM 생성 완료", {
|
||||||
|
newBomId, companyCode,
|
||||||
|
insertedCount: result.insertedCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOM 엑셀 업로드 - 기존 BOM에 새 버전 생성
|
||||||
|
*
|
||||||
|
* 엑셀에 레벨 0 행이 있으면 건너뛰고 (마스터는 이미 존재)
|
||||||
|
* 레벨 1 이상만 bom_detail로 INSERT, 새 bom_version에 연결
|
||||||
|
*/
|
||||||
|
export async function createBomVersionFromExcel(
|
||||||
|
bomId: string,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string,
|
||||||
|
rows: BomExcelRow[],
|
||||||
|
versionName?: string,
|
||||||
|
): Promise<BomExcelUploadResult> {
|
||||||
|
const result: BomExcelUploadResult = {
|
||||||
|
success: false,
|
||||||
|
insertedCount: 0,
|
||||||
|
skippedCount: 0,
|
||||||
|
errors: [],
|
||||||
|
unmatchedItems: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
result.errors.push("업로드할 데이터가 없습니다");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailRows = rows.filter(r => r.level > 0);
|
||||||
|
result.skippedCount = rows.length - detailRows.length;
|
||||||
|
|
||||||
|
if (detailRows.length === 0) {
|
||||||
|
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레벨 유효성 검사
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
if (row.level < 0) {
|
||||||
|
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
|
||||||
|
}
|
||||||
|
if (i > 0 && row.level > rows[i - 1].level + 1) {
|
||||||
|
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다`);
|
||||||
|
}
|
||||||
|
if (row.level > 0 && !row.item_number?.trim()) {
|
||||||
|
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return transaction(async (client) => {
|
||||||
|
// 1. BOM 존재 확인
|
||||||
|
const bomRow = await client.query(
|
||||||
|
`SELECT id, version FROM bom WHERE id = $1 AND company_code = $2`,
|
||||||
|
[bomId, companyCode],
|
||||||
|
);
|
||||||
|
if (bomRow.rows.length === 0) {
|
||||||
|
result.errors.push("BOM을 찾을 수 없습니다");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 품번 → item_info 매핑
|
||||||
|
const uniqueItemNumbers = [...new Set(detailRows.map(r => r.item_number.trim()))];
|
||||||
|
const itemLookup = await client.query(
|
||||||
|
`SELECT id, item_number, item_name, unit FROM item_info
|
||||||
|
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
|
||||||
|
[companyCode, uniqueItemNumbers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
|
||||||
|
for (const item of itemLookup.rows) {
|
||||||
|
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const num of uniqueItemNumbers) {
|
||||||
|
if (!itemMap.has(num)) {
|
||||||
|
result.unmatchedItems.push(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.unmatchedItems.length > 0) {
|
||||||
|
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 버전명 결정 (미입력 시 자동 채번)
|
||||||
|
let finalVersionName = versionName?.trim();
|
||||||
|
if (!finalVersionName) {
|
||||||
|
const countResult = await client.query(
|
||||||
|
`SELECT COUNT(*)::int as cnt FROM bom_version WHERE bom_id = $1`,
|
||||||
|
[bomId],
|
||||||
|
);
|
||||||
|
finalVersionName = `${(countResult.rows[0].cnt || 0) + 1}.0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 체크
|
||||||
|
const dupCheck = await client.query(
|
||||||
|
`SELECT id FROM bom_version WHERE bom_id = $1 AND version_name = $2`,
|
||||||
|
[bomId, finalVersionName],
|
||||||
|
);
|
||||||
|
if (dupCheck.rows.length > 0) {
|
||||||
|
result.errors.push(`이미 존재하는 버전명입니다: ${finalVersionName}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. bom_version 생성
|
||||||
|
const versionInsert = await client.query(
|
||||||
|
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
|
||||||
|
VALUES ($1, $2, 0, 'developing', $3, $4) RETURNING id`,
|
||||||
|
[bomId, finalVersionName, userId, companyCode],
|
||||||
|
);
|
||||||
|
const newVersionId = versionInsert.rows[0].id;
|
||||||
|
|
||||||
|
// 5. bom_detail INSERT
|
||||||
|
const levelStack: string[] = [];
|
||||||
|
const seqCounterByParent = new Map<string, number>();
|
||||||
|
|
||||||
|
for (let i = 0; i < detailRows.length; i++) {
|
||||||
|
const row = detailRows[i];
|
||||||
|
const itemInfo = itemMap.get(row.item_number.trim())!;
|
||||||
|
const dbLevel = row.level - 1;
|
||||||
|
|
||||||
|
while (levelStack.length > dbLevel) {
|
||||||
|
levelStack.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
|
||||||
|
const parentKey = parentDetailId || "__root__";
|
||||||
|
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
|
||||||
|
seqCounterByParent.set(parentKey, currentSeq);
|
||||||
|
|
||||||
|
const insertResult = await client.query(
|
||||||
|
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
bomId,
|
||||||
|
newVersionId,
|
||||||
|
parentDetailId,
|
||||||
|
itemInfo.id,
|
||||||
|
String(dbLevel),
|
||||||
|
String(currentSeq),
|
||||||
|
String(row.quantity || 1),
|
||||||
|
row.unit || itemInfo.unit || null,
|
||||||
|
row.process_type || null,
|
||||||
|
row.remark || null,
|
||||||
|
userId,
|
||||||
|
companyCode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
levelStack.push(insertResult.rows[0].id);
|
||||||
|
result.insertedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. BOM 헤더의 version과 current_version_id 갱신
|
||||||
|
await client.query(
|
||||||
|
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
|
||||||
|
[finalVersionName, newVersionId, bomId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. 이력 기록
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
|
||||||
|
VALUES ($1, 'excel_upload', $2, $3, $4)`,
|
||||||
|
[bomId, `엑셀 업로드로 새 버전 ${finalVersionName} 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
result.success = true;
|
||||||
|
result.createdBomId = bomId;
|
||||||
|
logger.info("BOM 엑셀 업로드 - 새 버전 생성 완료", {
|
||||||
|
bomId, companyCode, versionName: finalVersionName,
|
||||||
|
insertedCount: result.insertedCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOM 엑셀 다운로드용 데이터 조회
|
||||||
|
*
|
||||||
|
* 화면과 동일한 레벨 체계로 출력:
|
||||||
|
* 레벨 0 = BOM 헤더 (최상위 품목)
|
||||||
|
* 레벨 1 = 직접 자품목 (DB level=0)
|
||||||
|
* 레벨 N = DB level N-1
|
||||||
|
*
|
||||||
|
* DFS로 순회하여 부모-자식 순서 보장
|
||||||
|
*/
|
||||||
|
export async function downloadBomExcelData(
|
||||||
|
bomId: string,
|
||||||
|
companyCode: string,
|
||||||
|
): Promise<Record<string, any>[]> {
|
||||||
|
// BOM 헤더 정보 조회 (최상위 품목)
|
||||||
|
const bomHeader = await queryOne<Record<string, any>>(
|
||||||
|
`SELECT b.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit
|
||||||
|
FROM bom b
|
||||||
|
LEFT JOIN item_info ii ON b.item_id = ii.id
|
||||||
|
WHERE b.id = $1 AND b.company_code = $2`,
|
||||||
|
[bomId, companyCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bomHeader) return [];
|
||||||
|
|
||||||
|
const flatList: Record<string, any>[] = [];
|
||||||
|
|
||||||
|
// 레벨 0: BOM 헤더 (최상위 품목)
|
||||||
|
flatList.push({
|
||||||
|
level: 0,
|
||||||
|
item_number: bomHeader.item_number || "",
|
||||||
|
item_name: bomHeader.item_name || "",
|
||||||
|
quantity: bomHeader.base_qty || "1",
|
||||||
|
unit: bomHeader.item_unit || bomHeader.unit || "",
|
||||||
|
process_type: "",
|
||||||
|
remark: bomHeader.remark || "",
|
||||||
|
_is_header: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 하위 품목 조회
|
||||||
|
const versionId = bomHeader.current_version_id;
|
||||||
|
const whereVersion = versionId ? `AND bd.version_id = $3` : `AND bd.version_id IS NULL`;
|
||||||
|
const params = versionId ? [bomId, companyCode, versionId] : [bomId, companyCode];
|
||||||
|
|
||||||
|
const details = await query(
|
||||||
|
`SELECT bd.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit, ii.size, ii.material
|
||||||
|
FROM bom_detail bd
|
||||||
|
LEFT JOIN item_info ii ON bd.child_item_id = ii.id
|
||||||
|
WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion}
|
||||||
|
ORDER BY bd.parent_detail_id NULLS FIRST, bd.seq_no::int`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 부모 ID별 자식 목록으로 맵 구성
|
||||||
|
const childrenMap = new Map<string, any[]>();
|
||||||
|
const roots: any[] = [];
|
||||||
|
for (const d of details) {
|
||||||
|
if (!d.parent_detail_id) {
|
||||||
|
roots.push(d);
|
||||||
|
} else {
|
||||||
|
if (!childrenMap.has(d.parent_detail_id)) childrenMap.set(d.parent_detail_id, []);
|
||||||
|
childrenMap.get(d.parent_detail_id)!.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DFS: depth로 정확한 레벨 계산 (DB level 무시, 실제 트리 깊이 사용)
|
||||||
|
const dfs = (nodes: any[], depth: number) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
flatList.push({
|
||||||
|
level: depth,
|
||||||
|
item_number: node.item_number || "",
|
||||||
|
item_name: node.item_name || "",
|
||||||
|
quantity: node.quantity || "1",
|
||||||
|
unit: node.unit || node.item_unit || "",
|
||||||
|
process_type: node.process_type || "",
|
||||||
|
remark: node.remark || "",
|
||||||
|
});
|
||||||
|
const children = childrenMap.get(node.id) || [];
|
||||||
|
if (children.length > 0) {
|
||||||
|
dfs(children, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 루트 노드들은 레벨 1 (BOM 헤더가 0이므로)
|
||||||
|
dfs(roots, 1);
|
||||||
|
|
||||||
|
return flatList;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제
|
* 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,35 @@ interface NumberingRulePart {
|
||||||
autoConfig?: any;
|
autoConfig?: any;
|
||||||
manualConfig?: any;
|
manualConfig?: any;
|
||||||
generatedValue?: string;
|
generatedValue?: string;
|
||||||
|
separatorAfter?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파트 배열에서 autoConfig.separatorAfter를 파트 레벨로 추출
|
||||||
|
*/
|
||||||
|
function extractSeparatorAfterFromParts(parts: any[]): any[] {
|
||||||
|
return parts.map((part) => {
|
||||||
|
if (part.autoConfig?.separatorAfter !== undefined) {
|
||||||
|
part.separatorAfter = part.autoConfig.separatorAfter;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파트별 개별 구분자를 사용하여 코드 결합
|
||||||
|
* 마지막 파트의 separatorAfter는 무시됨
|
||||||
|
*/
|
||||||
|
function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string {
|
||||||
|
let result = "";
|
||||||
|
partValues.forEach((val, idx) => {
|
||||||
|
result += val;
|
||||||
|
if (idx < partValues.length - 1) {
|
||||||
|
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
|
||||||
|
result += sep;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NumberingRuleConfig {
|
interface NumberingRuleConfig {
|
||||||
|
|
@ -141,7 +170,7 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const partsResult = await pool.query(partsQuery, partsParams);
|
const partsResult = await pool.query(partsQuery, partsParams);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, {
|
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, {
|
||||||
|
|
@ -274,7 +303,7 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const partsResult = await pool.query(partsQuery, partsParams);
|
const partsResult = await pool.query(partsQuery, partsParams);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.rows;
|
return result.rows;
|
||||||
|
|
@ -381,7 +410,7 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const partsResult = await pool.query(partsQuery, partsParams);
|
const partsResult = await pool.query(partsQuery, partsParams);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
|
|
||||||
logger.info("✅ 규칙 파트 조회 성공", {
|
logger.info("✅ 규칙 파트 조회 성공", {
|
||||||
ruleId: rule.ruleId,
|
ruleId: rule.ruleId,
|
||||||
|
|
@ -517,7 +546,7 @@ class NumberingRuleService {
|
||||||
companyCode === "*" ? rule.companyCode : companyCode,
|
companyCode === "*" ? rule.companyCode : companyCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, {
|
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, {
|
||||||
|
|
@ -633,7 +662,7 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const partsResult = await pool.query(partsQuery, partsParams);
|
const partsResult = await pool.query(partsQuery, partsParams);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
|
|
||||||
return rule;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
@ -708,17 +737,25 @@ class NumberingRuleService {
|
||||||
manual_config AS "manualConfig"
|
manual_config AS "manualConfig"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// auto_config에 separatorAfter 포함
|
||||||
|
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
|
||||||
|
|
||||||
const partResult = await client.query(insertPartQuery, [
|
const partResult = await client.query(insertPartQuery, [
|
||||||
config.ruleId,
|
config.ruleId,
|
||||||
part.order,
|
part.order,
|
||||||
part.partType,
|
part.partType,
|
||||||
part.generationMethod,
|
part.generationMethod,
|
||||||
JSON.stringify(part.autoConfig || {}),
|
JSON.stringify(autoConfigWithSep),
|
||||||
JSON.stringify(part.manualConfig || {}),
|
JSON.stringify(part.manualConfig || {}),
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
parts.push(partResult.rows[0]);
|
const savedPart = partResult.rows[0];
|
||||||
|
// autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동
|
||||||
|
if (savedPart.autoConfig?.separatorAfter !== undefined) {
|
||||||
|
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
|
||||||
|
}
|
||||||
|
parts.push(savedPart);
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query("COMMIT");
|
await client.query("COMMIT");
|
||||||
|
|
@ -820,17 +857,23 @@ class NumberingRuleService {
|
||||||
manual_config AS "manualConfig"
|
manual_config AS "manualConfig"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
|
||||||
|
|
||||||
const partResult = await client.query(insertPartQuery, [
|
const partResult = await client.query(insertPartQuery, [
|
||||||
ruleId,
|
ruleId,
|
||||||
part.order,
|
part.order,
|
||||||
part.partType,
|
part.partType,
|
||||||
part.generationMethod,
|
part.generationMethod,
|
||||||
JSON.stringify(part.autoConfig || {}),
|
JSON.stringify(autoConfigWithSep),
|
||||||
JSON.stringify(part.manualConfig || {}),
|
JSON.stringify(part.manualConfig || {}),
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
parts.push(partResult.rows[0]);
|
const savedPart = partResult.rows[0];
|
||||||
|
if (savedPart.autoConfig?.separatorAfter !== undefined) {
|
||||||
|
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
|
||||||
|
}
|
||||||
|
parts.push(savedPart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1053,7 +1096,8 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const previewCode = parts.join(rule.separator || "");
|
const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||||
|
const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || "");
|
||||||
logger.info("코드 미리보기 생성", {
|
logger.info("코드 미리보기 생성", {
|
||||||
ruleId,
|
ruleId,
|
||||||
previewCode,
|
previewCode,
|
||||||
|
|
@ -1164,8 +1208,8 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const separator = rule.separator || "";
|
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||||
const previewTemplate = previewParts.join(separator);
|
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
|
||||||
|
|
||||||
// 사용자 입력 코드에서 수동 입력 부분 추출
|
// 사용자 입력 코드에서 수동 입력 부분 추출
|
||||||
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
|
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
|
||||||
|
|
@ -1382,7 +1426,8 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const allocatedCode = parts.join(rule.separator || "");
|
const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||||
|
const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || "");
|
||||||
|
|
||||||
// 순번이 있는 경우에만 증가
|
// 순번이 있는 경우에만 증가
|
||||||
const hasSequence = rule.parts.some(
|
const hasSequence = rule.parts.some(
|
||||||
|
|
@ -1541,7 +1586,7 @@ class NumberingRuleService {
|
||||||
rule.ruleId,
|
rule.ruleId,
|
||||||
companyCode === "*" ? rule.companyCode : companyCode,
|
companyCode === "*" ? rule.companyCode : companyCode,
|
||||||
]);
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("[테스트] 채번 규칙 목록 조회 완료", {
|
logger.info("[테스트] 채번 규칙 목록 조회 완료", {
|
||||||
|
|
@ -1634,7 +1679,7 @@ class NumberingRuleService {
|
||||||
rule.ruleId,
|
rule.ruleId,
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
|
|
||||||
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
|
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
|
||||||
ruleId: rule.ruleId,
|
ruleId: rule.ruleId,
|
||||||
|
|
@ -1754,12 +1799,14 @@ class NumberingRuleService {
|
||||||
auto_config, manual_config, company_code, created_at
|
auto_config, manual_config, company_code, created_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
`;
|
`;
|
||||||
|
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
|
||||||
|
|
||||||
await client.query(partInsertQuery, [
|
await client.query(partInsertQuery, [
|
||||||
config.ruleId,
|
config.ruleId,
|
||||||
part.order,
|
part.order,
|
||||||
part.partType,
|
part.partType,
|
||||||
part.generationMethod,
|
part.generationMethod,
|
||||||
JSON.stringify(part.autoConfig || {}),
|
JSON.stringify(autoConfigWithSep),
|
||||||
JSON.stringify(part.manualConfig || {}),
|
JSON.stringify(part.manualConfig || {}),
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
|
|
@ -1914,7 +1961,7 @@ class NumberingRuleService {
|
||||||
rule.ruleId,
|
rule.ruleId,
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
|
|
||||||
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
|
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
|
||||||
ruleId: rule.ruleId,
|
ruleId: rule.ruleId,
|
||||||
|
|
@ -1973,7 +2020,7 @@ class NumberingRuleService {
|
||||||
rule.ruleId,
|
rule.ruleId,
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
|
|
||||||
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
|
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
|
||||||
ruleId: rule.ruleId,
|
ruleId: rule.ruleId,
|
||||||
|
|
@ -2056,7 +2103,7 @@ class NumberingRuleService {
|
||||||
rule.ruleId,
|
rule.ruleId,
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.rows;
|
return result.rows;
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,15 @@ export class TableManagementService {
|
||||||
cl.input_type as "cl_input_type",
|
cl.input_type as "cl_input_type",
|
||||||
COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings",
|
COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings",
|
||||||
COALESCE(ttc.description, cl.description, '') as "description",
|
COALESCE(ttc.description, cl.description, '') as "description",
|
||||||
c.is_nullable as "isNullable",
|
CASE
|
||||||
|
WHEN COALESCE(ttc.is_nullable, cl.is_nullable) IS NOT NULL
|
||||||
|
THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END
|
||||||
|
ELSE c.is_nullable
|
||||||
|
END as "isNullable",
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES'
|
||||||
|
ELSE 'NO'
|
||||||
|
END as "isUnique",
|
||||||
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
||||||
c.column_default as "defaultValue",
|
c.column_default as "defaultValue",
|
||||||
c.character_maximum_length as "maxLength",
|
c.character_maximum_length as "maxLength",
|
||||||
|
|
@ -241,7 +249,15 @@ export class TableManagementService {
|
||||||
COALESCE(cl.input_type, 'direct') as "inputType",
|
COALESCE(cl.input_type, 'direct') as "inputType",
|
||||||
COALESCE(cl.detail_settings::text, '') as "detailSettings",
|
COALESCE(cl.detail_settings::text, '') as "detailSettings",
|
||||||
COALESCE(cl.description, '') as "description",
|
COALESCE(cl.description, '') as "description",
|
||||||
c.is_nullable as "isNullable",
|
CASE
|
||||||
|
WHEN cl.is_nullable IS NOT NULL
|
||||||
|
THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END
|
||||||
|
ELSE c.is_nullable
|
||||||
|
END as "isNullable",
|
||||||
|
CASE
|
||||||
|
WHEN cl.is_unique = 'Y' THEN 'YES'
|
||||||
|
ELSE 'NO'
|
||||||
|
END as "isUnique",
|
||||||
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
||||||
c.column_default as "defaultValue",
|
c.column_default as "defaultValue",
|
||||||
c.character_maximum_length as "maxLength",
|
c.character_maximum_length as "maxLength",
|
||||||
|
|
@ -502,8 +518,8 @@ export class TableManagementService {
|
||||||
table_name, column_name, column_label, input_type, detail_settings,
|
table_name, column_name, column_label, input_type, detail_settings,
|
||||||
code_category, code_value, reference_table, reference_column,
|
code_category, code_value, reference_table, reference_column,
|
||||||
display_column, display_order, is_visible, is_nullable,
|
display_column, display_order, is_visible, is_nullable,
|
||||||
company_code, created_date, updated_date
|
company_code, category_ref, created_date, updated_date
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW())
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, $14, NOW(), NOW())
|
||||||
ON CONFLICT (table_name, column_name, company_code)
|
ON CONFLICT (table_name, column_name, company_code)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
|
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
|
||||||
|
|
@ -516,6 +532,7 @@ export class TableManagementService {
|
||||||
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
|
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
|
||||||
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
|
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
|
||||||
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
|
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
|
||||||
|
category_ref = EXCLUDED.category_ref,
|
||||||
updated_date = NOW()`,
|
updated_date = NOW()`,
|
||||||
[
|
[
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -531,6 +548,7 @@ export class TableManagementService {
|
||||||
settings.displayOrder || 0,
|
settings.displayOrder || 0,
|
||||||
settings.isVisible !== undefined ? settings.isVisible : true,
|
settings.isVisible !== undefined ? settings.isVisible : true,
|
||||||
companyCode,
|
companyCode,
|
||||||
|
settings.categoryRef || null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1599,7 +1617,8 @@ export class TableManagementService {
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
actualValue,
|
actualValue,
|
||||||
paramIndex
|
paramIndex,
|
||||||
|
operator
|
||||||
);
|
);
|
||||||
|
|
||||||
case "entity":
|
case "entity":
|
||||||
|
|
@ -1612,7 +1631,14 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 기본 문자열 검색 (actualValue 사용)
|
// operator에 따라 정확 일치 또는 부분 일치 검색
|
||||||
|
if (operator === "equals") {
|
||||||
|
return {
|
||||||
|
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||||
|
values: [String(actualValue)],
|
||||||
|
paramCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||||
values: [`%${actualValue}%`],
|
values: [`%${actualValue}%`],
|
||||||
|
|
@ -1626,10 +1652,19 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
// 오류 시 기본 검색으로 폴백
|
// 오류 시 기본 검색으로 폴백
|
||||||
let fallbackValue = value;
|
let fallbackValue = value;
|
||||||
|
let fallbackOperator = "contains";
|
||||||
if (typeof value === "object" && value !== null && "value" in value) {
|
if (typeof value === "object" && value !== null && "value" in value) {
|
||||||
fallbackValue = value.value;
|
fallbackValue = value.value;
|
||||||
|
fallbackOperator = value.operator || "contains";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fallbackOperator === "equals") {
|
||||||
|
return {
|
||||||
|
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||||
|
values: [String(fallbackValue)],
|
||||||
|
paramCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||||
values: [`%${fallbackValue}%`],
|
values: [`%${fallbackValue}%`],
|
||||||
|
|
@ -1776,7 +1811,8 @@ export class TableManagementService {
|
||||||
tableName: string,
|
tableName: string,
|
||||||
columnName: string,
|
columnName: string,
|
||||||
value: any,
|
value: any,
|
||||||
paramIndex: number
|
paramIndex: number,
|
||||||
|
operator: string = "contains"
|
||||||
): Promise<{
|
): Promise<{
|
||||||
whereClause: string;
|
whereClause: string;
|
||||||
values: any[];
|
values: any[];
|
||||||
|
|
@ -1786,7 +1822,14 @@ export class TableManagementService {
|
||||||
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
|
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
|
||||||
|
|
||||||
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
|
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
|
||||||
// 코드 타입이 아니면 기본 검색
|
// 코드 타입이 아니면 operator에 따라 검색
|
||||||
|
if (operator === "equals") {
|
||||||
|
return {
|
||||||
|
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||||
|
values: [String(value)],
|
||||||
|
paramCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||||
values: [`%${value}%`],
|
values: [`%${value}%`],
|
||||||
|
|
@ -1794,6 +1837,15 @@ export class TableManagementService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
|
||||||
|
if (operator === "equals") {
|
||||||
|
return {
|
||||||
|
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||||
|
values: [String(value)],
|
||||||
|
paramCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof value === "string" && value.trim() !== "") {
|
if (typeof value === "string" && value.trim() !== "") {
|
||||||
// 코드값 또는 코드명으로 검색
|
// 코드값 또는 코드명으로 검색
|
||||||
return {
|
return {
|
||||||
|
|
@ -2431,6 +2483,154 @@ export class TableManagementService {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사별 NOT NULL 소프트 제약조건 검증
|
||||||
|
* table_type_columns.is_nullable = 'N'인 컬럼에 NULL/빈값이 들어오면 위반 목록을 반환한다.
|
||||||
|
*/
|
||||||
|
async validateNotNullConstraints(
|
||||||
|
tableName: string,
|
||||||
|
data: Record<string, any>,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
|
||||||
|
const notNullColumns = await query<{ column_name: string; column_label: string }>(
|
||||||
|
`SELECT
|
||||||
|
ttc.column_name,
|
||||||
|
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||||
|
FROM table_type_columns ttc
|
||||||
|
WHERE ttc.table_name = $1
|
||||||
|
AND ttc.is_nullable = 'N'
|
||||||
|
AND ttc.company_code = $2`,
|
||||||
|
[tableName, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 회사별 설정이 없으면 공통 설정 확인
|
||||||
|
if (notNullColumns.length === 0 && companyCode !== "*") {
|
||||||
|
const globalNotNull = await query<{ column_name: string; column_label: string }>(
|
||||||
|
`SELECT
|
||||||
|
ttc.column_name,
|
||||||
|
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||||
|
FROM table_type_columns ttc
|
||||||
|
WHERE ttc.table_name = $1
|
||||||
|
AND ttc.is_nullable = 'N'
|
||||||
|
AND ttc.company_code = '*'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM table_type_columns ttc2
|
||||||
|
WHERE ttc2.table_name = ttc.table_name
|
||||||
|
AND ttc2.column_name = ttc.column_name
|
||||||
|
AND ttc2.company_code = $2
|
||||||
|
)`,
|
||||||
|
[tableName, companyCode]
|
||||||
|
);
|
||||||
|
notNullColumns.push(...globalNotNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notNullColumns.length === 0) return [];
|
||||||
|
|
||||||
|
const violations: string[] = [];
|
||||||
|
for (const col of notNullColumns) {
|
||||||
|
const value = data[col.column_name];
|
||||||
|
// NULL, undefined, 빈 문자열을 NOT NULL 위반으로 처리
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
violations.push(col.column_label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`NOT NULL 검증 오류: ${tableName}`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사별 UNIQUE 소프트 제약조건 검증
|
||||||
|
* table_type_columns.is_unique = 'Y'인 컬럼에 중복 값이 들어오면 위반 목록을 반환한다.
|
||||||
|
* @param excludeId 수정 시 자기 자신은 제외
|
||||||
|
*/
|
||||||
|
async validateUniqueConstraints(
|
||||||
|
tableName: string,
|
||||||
|
data: Record<string, any>,
|
||||||
|
companyCode: string,
|
||||||
|
excludeId?: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
|
||||||
|
let uniqueColumns = await query<{ column_name: string; column_label: string }>(
|
||||||
|
`SELECT
|
||||||
|
ttc.column_name,
|
||||||
|
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||||
|
FROM table_type_columns ttc
|
||||||
|
WHERE ttc.table_name = $1
|
||||||
|
AND ttc.is_unique = 'Y'
|
||||||
|
AND ttc.company_code = $2`,
|
||||||
|
[tableName, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 회사별 설정이 없으면 공통 설정 확인
|
||||||
|
if (uniqueColumns.length === 0 && companyCode !== "*") {
|
||||||
|
const globalUnique = await query<{ column_name: string; column_label: string }>(
|
||||||
|
`SELECT
|
||||||
|
ttc.column_name,
|
||||||
|
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||||
|
FROM table_type_columns ttc
|
||||||
|
WHERE ttc.table_name = $1
|
||||||
|
AND ttc.is_unique = 'Y'
|
||||||
|
AND ttc.company_code = '*'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM table_type_columns ttc2
|
||||||
|
WHERE ttc2.table_name = ttc.table_name
|
||||||
|
AND ttc2.column_name = ttc.column_name
|
||||||
|
AND ttc2.company_code = $2
|
||||||
|
)`,
|
||||||
|
[tableName, companyCode]
|
||||||
|
);
|
||||||
|
uniqueColumns = globalUnique;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueColumns.length === 0) return [];
|
||||||
|
|
||||||
|
const violations: string[] = [];
|
||||||
|
for (const col of uniqueColumns) {
|
||||||
|
const value = data[col.column_name];
|
||||||
|
if (value === null || value === undefined || value === "") continue;
|
||||||
|
|
||||||
|
// 해당 회사 내에서 같은 값이 이미 존재하는지 확인
|
||||||
|
const hasCompanyCode = await query(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
let dupQuery: string;
|
||||||
|
let dupParams: any[];
|
||||||
|
|
||||||
|
if (hasCompanyCode.length > 0 && companyCode !== "*") {
|
||||||
|
dupQuery = excludeId
|
||||||
|
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1`
|
||||||
|
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`;
|
||||||
|
dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode];
|
||||||
|
} else {
|
||||||
|
dupQuery = excludeId
|
||||||
|
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1`
|
||||||
|
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`;
|
||||||
|
dupParams = excludeId ? [value, excludeId] : [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dupResult = await query(dupQuery, dupParams);
|
||||||
|
if (dupResult.length > 0) {
|
||||||
|
violations.push(`${col.column_label} (${value})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`UNIQUE 검증 오류: ${tableName}`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블에 데이터 추가
|
* 테이블에 데이터 추가
|
||||||
* @returns 무시된 컬럼 정보 (디버깅용)
|
* @returns 무시된 컬럼 정보 (디버깅용)
|
||||||
|
|
@ -4355,7 +4555,8 @@ export class TableManagementService {
|
||||||
END as "detailSettings",
|
END as "detailSettings",
|
||||||
ttc.is_nullable as "isNullable",
|
ttc.is_nullable as "isNullable",
|
||||||
ic.data_type as "dataType",
|
ic.data_type as "dataType",
|
||||||
ttc.company_code as "companyCode"
|
ttc.company_code as "companyCode",
|
||||||
|
ttc.category_ref as "categoryRef"
|
||||||
FROM table_type_columns ttc
|
FROM table_type_columns ttc
|
||||||
LEFT JOIN information_schema.columns ic
|
LEFT JOIN information_schema.columns ic
|
||||||
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
|
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
|
||||||
|
|
@ -4432,20 +4633,24 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
|
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
|
||||||
const baseInfo = {
|
const baseInfo: any = {
|
||||||
tableName: tableName,
|
tableName: tableName,
|
||||||
columnName: col.columnName,
|
columnName: col.columnName,
|
||||||
displayName: col.displayName,
|
displayName: col.displayName,
|
||||||
dataType: col.dataType || "varchar",
|
dataType: col.dataType || "varchar",
|
||||||
inputType: col.inputType,
|
inputType: col.inputType,
|
||||||
detailSettings: col.detailSettings,
|
detailSettings: col.detailSettings,
|
||||||
description: "", // 필수 필드 추가
|
description: "",
|
||||||
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
|
isNullable: col.isNullable === "Y" ? "Y" : "N",
|
||||||
isPrimaryKey: false,
|
isPrimaryKey: false,
|
||||||
displayOrder: 0,
|
displayOrder: 0,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (col.categoryRef) {
|
||||||
|
baseInfo.categoryRef = col.categoryRef;
|
||||||
|
}
|
||||||
|
|
||||||
// 카테고리 타입인 경우 categoryMenus 추가
|
// 카테고리 타입인 경우 categoryMenus 추가
|
||||||
if (
|
if (
|
||||||
col.inputType === "category" &&
|
col.inputType === "category" &&
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export interface ColumnSettings {
|
||||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||||
displayOrder?: number; // 표시 순서
|
displayOrder?: number; // 표시 순서
|
||||||
isVisible?: boolean; // 표시 여부
|
isVisible?: boolean; // 표시 여부
|
||||||
|
categoryRef?: string | null; // 카테고리 참조
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TableLabels {
|
export interface TableLabels {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# 엘에스티라유텍(주) - 동부지사 (COMPANY_13) 전체 데이터 Export
|
||||||
|
#
|
||||||
|
# 사용법:
|
||||||
|
# 1. SOURCE_* / TARGET_* 변수를 수정
|
||||||
|
# 2. chmod +x migrate_company13_export.sh
|
||||||
|
# 3. ./migrate_company13_export.sh export → SQL 파일 생성
|
||||||
|
# 4. ./migrate_company13_export.sh import → 대상 DB에 적재
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
SOURCE_HOST="localhost"
|
||||||
|
SOURCE_PORT="5432"
|
||||||
|
SOURCE_DB="vexplor"
|
||||||
|
SOURCE_USER="postgres"
|
||||||
|
|
||||||
|
TARGET_HOST="대상_호스트"
|
||||||
|
TARGET_PORT="5432"
|
||||||
|
TARGET_DB="대상_DB명"
|
||||||
|
TARGET_USER="postgres"
|
||||||
|
|
||||||
|
OUTPUT_FILE="company13_migration_$(date '+%Y%m%d_%H%M%S').sql"
|
||||||
|
|
||||||
|
# 데이터가 있는 테이블 (의존성 순서)
|
||||||
|
TABLES=(
|
||||||
|
"company_mng"
|
||||||
|
"user_info"
|
||||||
|
"authority_master"
|
||||||
|
"menu_info"
|
||||||
|
"external_db_connections"
|
||||||
|
"external_rest_api_connections"
|
||||||
|
"screen_definitions"
|
||||||
|
"screen_groups"
|
||||||
|
"screen_layouts_v1"
|
||||||
|
"screen_layouts_v2"
|
||||||
|
"screen_layouts_v3"
|
||||||
|
"screen_menu_assignments"
|
||||||
|
"dashboards"
|
||||||
|
"dashboard_elements"
|
||||||
|
"flow_definition"
|
||||||
|
"node_flows"
|
||||||
|
"table_column_category_values"
|
||||||
|
"attach_file_info"
|
||||||
|
"tax_invoice"
|
||||||
|
"auth_tokens"
|
||||||
|
"batch_configs"
|
||||||
|
"batch_execution_logs"
|
||||||
|
"batch_mappings"
|
||||||
|
"digital_twin_layout"
|
||||||
|
"digital_twin_layout_template"
|
||||||
|
"dtg_management"
|
||||||
|
"transport_statistics"
|
||||||
|
"vehicles"
|
||||||
|
"vehicle_location_history"
|
||||||
|
)
|
||||||
|
|
||||||
|
do_export() {
|
||||||
|
echo "=========================================="
|
||||||
|
echo " COMPANY_13 데이터 Export 시작"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
cat > "$OUTPUT_FILE" <<'HEADER'
|
||||||
|
-- ============================================================
|
||||||
|
-- 엘에스티라유텍(주) - 동부지사 (COMPANY_13) 전체 데이터 마이그레이션
|
||||||
|
--
|
||||||
|
-- 총 29개 테이블, 약 11,500건 데이터
|
||||||
|
--
|
||||||
|
-- 실행 방법:
|
||||||
|
-- psql -h HOST -U USER -d DATABASE -f 이_파일명.sql
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET client_encoding TO 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
HEADER
|
||||||
|
|
||||||
|
for TABLE in "${TABLES[@]}"; do
|
||||||
|
COUNT=$(psql -h "$SOURCE_HOST" -p "$SOURCE_PORT" -U "$SOURCE_USER" -d "$SOURCE_DB" \
|
||||||
|
-t -A -c "SELECT COUNT(*) FROM $TABLE WHERE company_code = 'COMPANY_13'")
|
||||||
|
COUNT=$(echo "$COUNT" | tr -d '[:space:]')
|
||||||
|
|
||||||
|
if [ "$COUNT" -gt 0 ]; then
|
||||||
|
echo " $TABLE: ${COUNT}건 추출 중..."
|
||||||
|
|
||||||
|
echo "-- ----------------------------------------" >> "$OUTPUT_FILE"
|
||||||
|
echo "-- $TABLE (${COUNT}건)" >> "$OUTPUT_FILE"
|
||||||
|
echo "-- ----------------------------------------" >> "$OUTPUT_FILE"
|
||||||
|
echo "COPY $TABLE FROM stdin;" >> "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
psql -h "$SOURCE_HOST" -p "$SOURCE_PORT" -U "$SOURCE_USER" -d "$SOURCE_DB" \
|
||||||
|
-t -A -c "COPY (SELECT * FROM $TABLE WHERE company_code = 'COMPANY_13') TO STDOUT" >> "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
echo "\\." >> "$OUTPUT_FILE"
|
||||||
|
echo "" >> "$OUTPUT_FILE"
|
||||||
|
else
|
||||||
|
echo " $TABLE: 데이터 없음 (건너뜀)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "" >> "$OUTPUT_FILE"
|
||||||
|
echo "COMMIT;" >> "$OUTPUT_FILE"
|
||||||
|
echo "" >> "$OUTPUT_FILE"
|
||||||
|
echo "-- 마이그레이션 완료" >> "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Export 완료: $OUTPUT_FILE"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "대상 DB에서 실행:"
|
||||||
|
echo " psql -h $TARGET_HOST -p $TARGET_PORT -U $TARGET_USER -d $TARGET_DB -f $OUTPUT_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_import() {
|
||||||
|
SQL_FILE=$(ls -t company13_migration_*.sql 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -z "$SQL_FILE" ]; then
|
||||||
|
echo "마이그레이션 SQL 파일을 찾을 수 없습니다. 먼저 export를 실행하세요."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " COMPANY_13 데이터 Import 시작"
|
||||||
|
echo " 파일: $SQL_FILE"
|
||||||
|
echo " 대상: $TARGET_HOST:$TARGET_PORT/$TARGET_DB"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
psql -h "$TARGET_HOST" -p "$TARGET_PORT" -U "$TARGET_USER" -d "$TARGET_DB" -f "$SQL_FILE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Import 완료"
|
||||||
|
echo "=========================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-export}" in
|
||||||
|
export)
|
||||||
|
do_export
|
||||||
|
;;
|
||||||
|
import)
|
||||||
|
do_import
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "사용법: $0 {export|import}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
@ -12,7 +12,7 @@ services:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm
|
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
|
||||||
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
||||||
JWT_EXPIRES_IN: 24h
|
JWT_EXPIRES_IN: 24h
|
||||||
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com
|
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ services:
|
||||||
- "9771:3000"
|
- "9771:3000"
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
||||||
|
- SERVER_API_URL=http://pms-backend-mac:8080
|
||||||
- NODE_OPTIONS=--max-old-space-size=8192
|
- NODE_OPTIONS=--max-old-space-size=8192
|
||||||
- NEXT_TELEMETRY_DISABLED=1
|
- NEXT_TELEMETRY_DISABLED=1
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
# formData 콘솔 로그 수동 테스트 가이드
|
||||||
|
|
||||||
|
## 테스트 시나리오
|
||||||
|
|
||||||
|
1. http://localhost:9771/screens/1599?menuObjid=1762422235300 접속
|
||||||
|
2. 로그인 필요 시: `topseal_admin` / `1234`
|
||||||
|
3. 5초 대기 (페이지 로드)
|
||||||
|
4. 첫 번째 탭 "공정 마스터" 확인
|
||||||
|
5. 좌측 패널에서 **P003** 행 클릭
|
||||||
|
6. 우측 패널에서 **추가** 버튼 클릭
|
||||||
|
7. 모달에서 설비(equipment) 드롭다운에서 항목 선택
|
||||||
|
8. **저장** 버튼 클릭 **전** 콘솔 스냅샷 확인
|
||||||
|
9. **저장** 버튼 클릭 **후** 콘솔 로그 확인
|
||||||
|
|
||||||
|
## 확인할 콘솔 로그
|
||||||
|
|
||||||
|
### 1. ADD 모드 formData 설정 (ScreenModal)
|
||||||
|
|
||||||
|
```
|
||||||
|
🔵 [ScreenModal] ADD모드 formData 설정: {...}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **위치**: `frontend/components/common/ScreenModal.tsx` 358행
|
||||||
|
- **의미**: 모달이 ADD 모드로 열릴 때 부모 데이터(splitPanelParentData)로 설정된 초기 formData
|
||||||
|
- **확인**: `process_code`가 P003으로 포함되어 있는지
|
||||||
|
|
||||||
|
### 2. formData 변경 시 (ScreenModal)
|
||||||
|
|
||||||
|
```
|
||||||
|
🟡 [ScreenModal] onFormDataChange: equipment_code → E001 | formData keys: [...] | process_code: P003
|
||||||
|
```
|
||||||
|
|
||||||
|
- **위치**: `frontend/components/common/ScreenModal.tsx` 1184행
|
||||||
|
- **의미**: 사용자가 설비를 선택할 때마다 발생
|
||||||
|
- **확인**: `process_code`가 유지되는지, `equipment_code`가 추가되는지
|
||||||
|
|
||||||
|
### 3. 저장 시 formData 디버그 (ButtonPrimary)
|
||||||
|
|
||||||
|
```
|
||||||
|
🔴 [ButtonPrimary] 저장 시 formData 디버그: {
|
||||||
|
propsFormDataKeys: [...],
|
||||||
|
screenContextFormDataKeys: [...],
|
||||||
|
effectiveFormDataKeys: [...],
|
||||||
|
process_code: "P003",
|
||||||
|
equipment_code: "E001",
|
||||||
|
fullData: "{...}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **위치**: `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` 1110행
|
||||||
|
- **의미**: 저장 버튼 클릭 시 실제로 API에 전달되는 formData
|
||||||
|
- **확인**: `process_code`, `equipment_code`가 모두 포함되어 있는지
|
||||||
|
|
||||||
|
## 추가로 확인할 로그
|
||||||
|
|
||||||
|
- `process_code` 포함 로그
|
||||||
|
- `splitPanelParentData` 포함 로그
|
||||||
|
- `🆕 [추가모달] screenId 기반 모달 열기:` (SplitPanelLayoutComponent 1639행)
|
||||||
|
|
||||||
|
## 에러 확인
|
||||||
|
|
||||||
|
콘솔에 빨간색으로 표시되는 에러 메시지가 있는지 확인하세요.
|
||||||
|
|
||||||
|
## 사전 조건
|
||||||
|
|
||||||
|
- **process_mng** 테이블에 P003 데이터가 있어야 함 (company_code = 로그인 사용자 회사)
|
||||||
|
- **equipment_mng** 테이블에 설비 데이터가 있어야 함
|
||||||
|
- 로그인 사용자가 해당 회사(COMPANY_7 등) 권한이 있어야 함
|
||||||
|
|
||||||
|
## 자동 테스트 스크립트
|
||||||
|
|
||||||
|
데이터가 준비된 환경에서:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npx tsx scripts/test-formdata-logs.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
데이터가 없으면 "좌측 테이블에 데이터가 없습니다" 오류가 발생합니다.
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
# BOM 엑셀 업로드 기능 개발 계획
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
탑씰(COMPANY_7) BOM관리 화면(screen_id=4168)에 엑셀 업로드 기능을 추가한다.
|
||||||
|
BOM은 트리 구조(parent_detail_id 자기참조)이므로 범용 엑셀 업로드를 사용할 수 없고,
|
||||||
|
BOM 전용 엑셀 업로드 컴포넌트를 개발한다.
|
||||||
|
|
||||||
|
## 핵심 구조
|
||||||
|
|
||||||
|
### DB 테이블
|
||||||
|
- `bom` (마스터): id(UUID), item_id(→item_info), version, current_version_id
|
||||||
|
- `bom_detail` (디테일-트리): id(UUID), bom_id(FK), parent_detail_id(자기참조), child_item_id(→item_info), level, seq_no, quantity, unit, loss_rate, process_type, version_id
|
||||||
|
- `item_info`: id, item_number(품번), item_name(품명), division(구분), unit, size, material
|
||||||
|
|
||||||
|
### 엑셀 포맷 설계 (화면과 동일한 레벨 체계)
|
||||||
|
엑셀 파일은 다음 컬럼으로 구성:
|
||||||
|
|
||||||
|
| 레벨 | 품번 | 품명 | 소요량 | 단위 | 로스율(%) | 공정구분 | 비고 |
|
||||||
|
|------|------|------|--------|------|-----------|----------|------|
|
||||||
|
| 0 | PROD-001 | 완제품A | 1 | EA | 0 | | ← BOM 헤더 (건너뜀) |
|
||||||
|
| 1 | P-001 | 부품A | 2 | EA | 0 | | ← 직접 자품목 |
|
||||||
|
| 2 | P-002 | 부품B | 3 | EA | 5 | 가공 | ← P-001의 하위 |
|
||||||
|
| 1 | P-003 | 부품C | 1 | KG | 0 | | ← 직접 자품목 |
|
||||||
|
| 2 | P-004 | 부품D | 4 | EA | 0 | 조립 | ← P-003의 하위 |
|
||||||
|
| 1 | P-005 | 부품E | 1 | EA | 0 | | ← 직접 자품목 |
|
||||||
|
|
||||||
|
- 레벨 0: BOM 헤더 (최상위 품목) → 업로드 시 건너뜀 (이미 존재)
|
||||||
|
- 레벨 1: 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0)
|
||||||
|
- 레벨 2: 자품목의 하위 → bom_detail (parent_detail_id=부모ID, DB level=1)
|
||||||
|
- 레벨 N: → bom_detail (DB level=N-1)
|
||||||
|
- 품번으로 item_info를 조회하여 child_item_id 자동 매핑
|
||||||
|
|
||||||
|
### 트리 변환 로직 (레벨 1 이상만 처리)
|
||||||
|
엑셀 행을 순서대로 순회하면서 (레벨 0 건너뜀):
|
||||||
|
1. 각 행의 엑셀 레벨에서 -1하여 DB 레벨 계산
|
||||||
|
2. 스택으로 부모-자식 관계 추적
|
||||||
|
|
||||||
|
```
|
||||||
|
행1(레벨0) → BOM 헤더, 건너뜀
|
||||||
|
행2(레벨1) → DB level=0, 스택: [행2] → parent_detail_id = null
|
||||||
|
행3(레벨2) → DB level=1, 스택: [행2, 행3] → parent_detail_id = 행2.id
|
||||||
|
행4(레벨1) → DB level=0, 스택: [행4] → parent_detail_id = null
|
||||||
|
행5(레벨2) → DB level=1, 스택: [행4, 행5] → parent_detail_id = 행4.id
|
||||||
|
행6(레벨1) → DB level=0, 스택: [행6] → parent_detail_id = null
|
||||||
|
```
|
||||||
|
|
||||||
|
## 테스트 계획
|
||||||
|
|
||||||
|
### 1단계: 백엔드 API
|
||||||
|
- [x] 테스트 1: 품번으로 item_info 일괄 조회 (존재하는 품번)
|
||||||
|
- [x] 테스트 2: 존재하지 않는 품번 에러 처리
|
||||||
|
- [x] 테스트 3: 플랫 데이터 → 트리 구조 변환 (parent_detail_id 계산)
|
||||||
|
- [x] 테스트 4: bom_detail INSERT (version_id 포함)
|
||||||
|
- [x] 테스트 5: 기존 디테일 처리 (추가 모드 vs 전체교체 모드)
|
||||||
|
|
||||||
|
### 2단계: 프론트엔드 모달
|
||||||
|
- [x] 테스트 6: 엑셀 파일 파싱 및 미리보기
|
||||||
|
- [x] 테스트 7: 품번 매핑 결과 표시 (성공/실패)
|
||||||
|
- [x] 테스트 8: 업로드 실행 및 결과 표시
|
||||||
|
|
||||||
|
### 3단계: 통합
|
||||||
|
- [x] 테스트 9: BomTreeComponent에 엑셀 업로드 버튼 추가
|
||||||
|
- [x] 테스트 10: 업로드 후 트리 자동 새로고침
|
||||||
|
|
||||||
|
## 구현 파일 목록
|
||||||
|
|
||||||
|
### 백엔드
|
||||||
|
1. `backend-node/src/services/bomService.ts` - `uploadBomExcel()` 함수 추가
|
||||||
|
2. `backend-node/src/controllers/bomController.ts` - `uploadBomExcel` 핸들러 추가
|
||||||
|
3. `backend-node/src/routes/bomRoutes.ts` - `POST /:bomId/excel-upload` 라우트 추가
|
||||||
|
|
||||||
|
### 프론트엔드
|
||||||
|
4. `frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx` - 전용 모달 신규
|
||||||
|
5. `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` - 업로드 버튼 추가
|
||||||
|
|
||||||
|
## 진행 상태
|
||||||
|
- 완료된 테스트는 [x]로 표시
|
||||||
|
- 현재 진행 중인 테스트는 [진행중]으로 표시
|
||||||
|
|
@ -0,0 +1,427 @@
|
||||||
|
# 공정 작업기준 컴포넌트 (v2-process-work-standard) 구현 계획
|
||||||
|
|
||||||
|
> **작성일**: 2026-02-24
|
||||||
|
> **컴포넌트 ID**: `v2-process-work-standard`
|
||||||
|
> **성격**: 도메인 특화 컴포넌트 (v2-rack-structure와 동일 패턴)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 현황 분석
|
||||||
|
|
||||||
|
### 1.1 기존 DB 테이블 (참조용, 이미 존재)
|
||||||
|
|
||||||
|
| 테이블 | 역할 | 핵심 컬럼 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| `item_info` | 품목 마스터 | id, item_name, item_number, company_code |
|
||||||
|
| `item_routing_version` | 라우팅 버전 | id, item_code, version_name, company_code |
|
||||||
|
| `item_routing_detail` | 라우팅 상세 (공정 배정) | id, routing_version_id, seq_no, process_code, company_code |
|
||||||
|
| `process_mng` | 공정 마스터 | id, process_code, process_name, company_code |
|
||||||
|
|
||||||
|
### 1.2 신규 생성 필요 테이블
|
||||||
|
|
||||||
|
**`process_work_item`** - 작업 항목 (검사 장비 준비, 외관 검사 등)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | VARCHAR PK | UUID |
|
||||||
|
| company_code | VARCHAR NOT NULL | 멀티테넌시 |
|
||||||
|
| routing_detail_id | VARCHAR NOT NULL | item_routing_detail.id FK |
|
||||||
|
| work_phase | VARCHAR NOT NULL | Config의 phases[].key 값 (예: 'PRE', 'IN', 'POST' 또는 사용자 정의) |
|
||||||
|
| title | VARCHAR NOT NULL | 항목 제목 (예: 검사 장비 준비) |
|
||||||
|
| is_required | VARCHAR | 'Y' / 'N' |
|
||||||
|
| sort_order | INTEGER | 표시 순서 |
|
||||||
|
| description | TEXT | 비고/설명 |
|
||||||
|
| created_date | TIMESTAMP | 생성일 |
|
||||||
|
| updated_date | TIMESTAMP | 수정일 |
|
||||||
|
| writer | VARCHAR | 작성자 |
|
||||||
|
|
||||||
|
**`process_work_item_detail`** - 작업 항목 상세 (버니어 캘리퍼스 상태 소정 등)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | VARCHAR PK | UUID |
|
||||||
|
| company_code | VARCHAR NOT NULL | 멀티테넌시 |
|
||||||
|
| work_item_id | VARCHAR NOT NULL | process_work_item.id FK |
|
||||||
|
| detail_type | VARCHAR | 'CHECK' / 'INSPECTION' / 'MEASUREMENT' 등 |
|
||||||
|
| content | VARCHAR NOT NULL | 상세 내용 |
|
||||||
|
| is_required | VARCHAR | 'Y' / 'N' |
|
||||||
|
| sort_order | INTEGER | 표시 순서 |
|
||||||
|
| remark | TEXT | 비고 |
|
||||||
|
| created_date | TIMESTAMP | 생성일 |
|
||||||
|
| updated_date | TIMESTAMP | 수정일 |
|
||||||
|
| writer | VARCHAR | 작성자 |
|
||||||
|
|
||||||
|
### 1.3 데이터 흐름 (5단계 연쇄)
|
||||||
|
|
||||||
|
```
|
||||||
|
item_info (품목)
|
||||||
|
└─→ item_routing_version (라우팅 버전)
|
||||||
|
└─→ item_routing_detail (공정 배정) ← JOIN → process_mng (공정명)
|
||||||
|
└─→ process_work_item (작업 항목, phase별)
|
||||||
|
└─→ process_work_item_detail (상세)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 파일 구조 계획
|
||||||
|
|
||||||
|
### 2.1 프론트엔드 (컴포넌트 등록)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/lib/registry/components/v2-process-work-standard/
|
||||||
|
├── index.ts # createComponentDefinition
|
||||||
|
├── types.ts # 타입 정의
|
||||||
|
├── config.ts # 기본 설정
|
||||||
|
├── ProcessWorkStandardRenderer.tsx # AutoRegisteringComponentRenderer
|
||||||
|
├── ProcessWorkStandardConfigPanel.tsx # 설정 패널
|
||||||
|
├── ProcessWorkStandardComponent.tsx # 메인 UI (좌우 분할)
|
||||||
|
├── components/
|
||||||
|
│ ├── ItemProcessSelector.tsx # 좌측: 품목/라우팅/공정 아코디언 트리
|
||||||
|
│ ├── WorkStandardEditor.tsx # 우측: 작업기준 편집 영역 전체
|
||||||
|
│ ├── WorkPhaseSection.tsx # Pre/In/Post 섹션 (3회 재사용)
|
||||||
|
│ ├── WorkItemCard.tsx # 작업 항목 카드
|
||||||
|
│ ├── WorkItemDetailList.tsx # 상세 리스트
|
||||||
|
│ └── WorkItemAddModal.tsx # 작업 항목 추가/수정 모달
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useProcessWorkStandard.ts # 전체 데이터 관리 훅
|
||||||
|
│ ├── useItemProcessTree.ts # 좌측 트리 데이터 훅
|
||||||
|
│ └── useWorkItems.ts # 작업 항목 CRUD 훅
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 백엔드 (API)
|
||||||
|
|
||||||
|
```
|
||||||
|
backend-node/src/
|
||||||
|
├── routes/processWorkStandardRoutes.ts # 라우트 정의
|
||||||
|
└── controllers/processWorkStandardController.ts # 컨트롤러
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 DB 마이그레이션
|
||||||
|
|
||||||
|
```
|
||||||
|
db/migrations/XXX_create_process_work_standard_tables.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API 설계
|
||||||
|
|
||||||
|
| Method | Endpoint | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| GET | `/api/process-work-standard/items` | 품목 목록 (라우팅 있는 품목만) |
|
||||||
|
| GET | `/api/process-work-standard/items/:itemCode/routings` | 품목별 라우팅 버전 + 공정 목록 |
|
||||||
|
| GET | `/api/process-work-standard/routing-detail/:routingDetailId/work-items` | 공정별 작업 항목 목록 (phase별 그룹) |
|
||||||
|
| POST | `/api/process-work-standard/work-items` | 작업 항목 추가 |
|
||||||
|
| PUT | `/api/process-work-standard/work-items/:id` | 작업 항목 수정 |
|
||||||
|
| DELETE | `/api/process-work-standard/work-items/:id` | 작업 항목 삭제 |
|
||||||
|
| GET | `/api/process-work-standard/work-items/:workItemId/details` | 작업 항목 상세 목록 |
|
||||||
|
| POST | `/api/process-work-standard/work-item-details` | 상세 추가 |
|
||||||
|
| PUT | `/api/process-work-standard/work-item-details/:id` | 상세 수정 |
|
||||||
|
| DELETE | `/api/process-work-standard/work-item-details/:id` | 상세 삭제 |
|
||||||
|
| PUT | `/api/process-work-standard/save-all` | 전체 저장 (작업 항목 + 상세 일괄) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 구현 단계 (TDD 기반)
|
||||||
|
|
||||||
|
### Phase 1: DB + API 기반
|
||||||
|
|
||||||
|
- [ ] 1-1. 마이그레이션 SQL 작성 (process_work_item, process_work_item_detail)
|
||||||
|
- [ ] 1-2. 마이그레이션 실행 및 테이블 생성 확인
|
||||||
|
- [ ] 1-3. 백엔드 라우트/컨트롤러 작성 (CRUD API)
|
||||||
|
- [ ] 1-4. API 테스트 (품목 목록, 라우팅 조회, 작업항목 CRUD)
|
||||||
|
|
||||||
|
### Phase 2: 컴포넌트 기본 구조
|
||||||
|
|
||||||
|
- [ ] 2-1. types.ts, config.ts, index.ts 작성 (컴포넌트 정의)
|
||||||
|
- [ ] 2-2. Renderer, ConfigPanel 작성 (V2 시스템 등록)
|
||||||
|
- [ ] 2-3. components/index.ts에 import 추가
|
||||||
|
- [ ] 2-4. getComponentConfigPanel.tsx에 매핑 추가
|
||||||
|
- [ ] 2-5. 화면 디자이너에서 컴포넌트 배치 가능 확인
|
||||||
|
|
||||||
|
### Phase 3: 좌측 패널 (품목/공정 선택)
|
||||||
|
|
||||||
|
- [ ] 3-1. useItemProcessTree 훅 구현 (품목 목록 + 라우팅 조회)
|
||||||
|
- [ ] 3-2. ItemProcessSelector 컴포넌트 (아코디언 + 공정 리스트)
|
||||||
|
- [ ] 3-3. 검색 기능 (품목명/공정명 검색)
|
||||||
|
- [ ] 3-4. 선택 상태 관리 + 우측 패널 연동
|
||||||
|
|
||||||
|
### Phase 4: 우측 패널 (작업기준 편집)
|
||||||
|
|
||||||
|
- [ ] 4-1. WorkStandardEditor 기본 레이아웃 (Pre/In/Post 3단 섹션)
|
||||||
|
- [ ] 4-2. useWorkItems 훅 (작업 항목 + 상세 CRUD)
|
||||||
|
- [ ] 4-3. WorkPhaseSection 컴포넌트 (섹션 헤더 + 카드 영역 + 상세 영역)
|
||||||
|
- [ ] 4-4. WorkItemCard 컴포넌트 (카드 UI + 카운트 배지)
|
||||||
|
- [ ] 4-5. WorkItemDetailList 컴포넌트 (상세 목록 + 인라인 편집)
|
||||||
|
- [ ] 4-6. WorkItemAddModal (작업 항목 추가/수정 모달 + 상세 추가)
|
||||||
|
|
||||||
|
### Phase 5: 통합 + 전체 저장
|
||||||
|
|
||||||
|
- [ ] 5-1. 전체 저장 기능 (변경사항 일괄 저장 API 연동)
|
||||||
|
- [ ] 5-2. 공정 선택 시 데이터 로딩/전환 처리
|
||||||
|
- [ ] 5-3. Empty State 처리 (데이터 없을 때 안내 UI)
|
||||||
|
- [ ] 5-4. 로딩/에러 상태 처리
|
||||||
|
|
||||||
|
### Phase 6: 마무리
|
||||||
|
|
||||||
|
- [ ] 6-1. 멀티테넌시 검증 (company_code 필터링)
|
||||||
|
- [ ] 6-2. 반응형 디자인 점검
|
||||||
|
- [ ] 6-3. README.md 작성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 핵심 UI 설계
|
||||||
|
|
||||||
|
### 5.1 전체 레이아웃
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ v2-process-work-standard │
|
||||||
|
├────────────────────┬────────────────────────────────────────────────┤
|
||||||
|
│ 품목 및 공정 선택 │ [품목명] - [공정명] [전체 저장] │
|
||||||
|
│ │ │
|
||||||
|
│ [검색 입력] │ ── 작업 전 (Pre-Work) N개 항목 ── [+항목추가] │
|
||||||
|
│ │ ┌────────┐ ┌─────────────────────────────┐ │
|
||||||
|
│ ▼ 볼트 M8x20 │ │카드 │ │ 상세 리스트 (선택 시 표시) │ │
|
||||||
|
│ ★ 기본 라우팅 │ │ │ │ │ │
|
||||||
|
│ ◉ 재단 │ └────────┘ └─────────────────────────────┘ │
|
||||||
|
│ ◉ 검사 ← 선택 │ │
|
||||||
|
│ ★ 버전2 │ ── 작업 중 (In-Work) N개 항목 ── [+항목추가] │
|
||||||
|
│ │ ┌────────┐ ┌────────┐ │
|
||||||
|
│ ▶ 기어 50T │ │카드1 │ │카드2 │ (상세: 우측 표시) │
|
||||||
|
│ ▶ 샤프트 D30 │ └────────┘ └────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ ── 작업 후 (Post-Work) N개 항목 ── [+항목추가] │
|
||||||
|
│ │ (동일 구조) │
|
||||||
|
├────────────────────┴────────────────────────────────────────────────┤
|
||||||
|
│ 30% │ 70% │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 WorkPhaseSection 내부 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
── 작업 전 (Pre-Work) 4개 항목 ────────────────── [+ 작업항목 추가]
|
||||||
|
┌──────────────────────────────┬──────────────────────────────────────┐
|
||||||
|
│ 작업 항목 카드 목록 │ 선택된 항목 상세 │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────────┐ │ [항목 제목] [+ 상세추가]│
|
||||||
|
│ │ ≡ 검사 장비 준비 ✏️ 🗑 │ │ ─────────────────────────────────── │
|
||||||
|
│ │ 4개 필수 │ │ 순서│유형 │내용 │필수│관리│
|
||||||
|
│ └──────────────────────┘ │ 1 │체크 │버니어 캘리퍼스... │필수│✏️🗑│
|
||||||
|
│ │ 2 │체크 │마이크로미터... │선택│✏️🗑│
|
||||||
|
│ ┌──────────────────────┐ │ 3 │체크 │검사대 청소 │선택│✏️🗑│
|
||||||
|
│ │ ≡ 측정 도구 확인 ✏️ 🗑 │ │ 4 │체크 │검사 기록지 준비 │필수│✏️🗑│
|
||||||
|
│ │ 2개 선택 │ │ │
|
||||||
|
│ └──────────────────────┘ │ │
|
||||||
|
└──────────────────────────────┴──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 작업 항목 추가 모달
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ 작업 항목 추가 ✕ │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ 기본 정보 │
|
||||||
|
│ │
|
||||||
|
│ 항목 제목 * 필수 여부 │
|
||||||
|
│ [ ] [필수 ▼] │
|
||||||
|
│ │
|
||||||
|
│ 비고 │
|
||||||
|
│ [ ] │
|
||||||
|
│ │
|
||||||
|
│ 상세 항목 [+ 상세 추가] │
|
||||||
|
│ ┌───┬──────┬──────────────┬────┬────┐ │
|
||||||
|
│ │순서│유형 │내용 │필수│관리│ │
|
||||||
|
│ ├───┼──────┼──────────────┼────┼────┤ │
|
||||||
|
│ │ 1 │체크 │ │필수│ 🗑 │ │
|
||||||
|
│ └───┴──────┴──────────────┴────┴────┘ │
|
||||||
|
│ │
|
||||||
|
│ [취소] [저장] │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 컴포넌트 Config 설계
|
||||||
|
|
||||||
|
### 6.1 설정 패널 UI 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ 공정 작업기준 설정 │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ── 데이터 소스 설정 ────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ 품목 테이블 │
|
||||||
|
│ [item_info ▼] │
|
||||||
|
│ 품목명 컬럼 품목코드 컬럼 │
|
||||||
|
│ [item_name ▼] [item_number ▼] │
|
||||||
|
│ │
|
||||||
|
│ 라우팅 버전 테이블 │
|
||||||
|
│ [item_routing_version ▼] │
|
||||||
|
│ 품목 연결 컬럼 (FK) │
|
||||||
|
│ [item_code ▼] │
|
||||||
|
│ │
|
||||||
|
│ 라우팅 상세 테이블 │
|
||||||
|
│ [item_routing_detail ▼] │
|
||||||
|
│ │
|
||||||
|
│ 공정 마스터 테이블 │
|
||||||
|
│ [process_mng ▼] │
|
||||||
|
│ │
|
||||||
|
│ ── 작업 단계 설정 ────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ ┌────┬────────────────────┬─────────────┬───┐ │
|
||||||
|
│ │순서│ 단계 키(DB저장용) │ 표시 이름 │관리│ │
|
||||||
|
│ ├────┼────────────────────┼─────────────┼───┤ │
|
||||||
|
│ │ 1 │ PRE │ 작업 전 │ 🗑 │ │
|
||||||
|
│ │ 2 │ IN │ 작업 중 │ 🗑 │ │
|
||||||
|
│ │ 3 │ POST │ 작업 후 │ 🗑 │ │
|
||||||
|
│ └────┴────────────────────┴─────────────┴───┘ │
|
||||||
|
│ [+ 단계 추가] │
|
||||||
|
│ │
|
||||||
|
│ ── 상세 유형 옵션 ────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────┬─────────────┬───┐ │
|
||||||
|
│ │ 유형 값(DB저장용) │ 표시 이름 │관리│ │
|
||||||
|
│ ├────────────────────┼─────────────┼───┤ │
|
||||||
|
│ │ CHECK │ 체크 │ 🗑 │ │
|
||||||
|
│ │ INSPECTION │ 검사 │ 🗑 │ │
|
||||||
|
│ │ MEASUREMENT │ 측정 │ 🗑 │ │
|
||||||
|
│ └────────────────────┴─────────────┴───┘ │
|
||||||
|
│ [+ 유형 추가] │
|
||||||
|
│ │
|
||||||
|
│ ── UI 설정 ────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ 좌우 분할 비율 │
|
||||||
|
│ [30 ] % │
|
||||||
|
│ │
|
||||||
|
│ 좌측 패널 제목 │
|
||||||
|
│ [품목 및 공정 선택 ] │
|
||||||
|
│ │
|
||||||
|
│ 읽기 전용 모드 │
|
||||||
|
│ [ ] 활성화 │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Config 타입 정의
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 작업 단계 정의 (사용자가 추가/삭제/이름변경 가능)
|
||||||
|
interface WorkPhaseDefinition {
|
||||||
|
key: string; // DB 저장용 키 (예: "PRE", "IN", "POST", "QC")
|
||||||
|
label: string; // 화면 표시명 (예: "작업 전 (Pre-Work)")
|
||||||
|
sortOrder: number; // 표시 순서
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상세 유형 정의 (사용자가 추가/삭제 가능)
|
||||||
|
interface DetailTypeDefinition {
|
||||||
|
value: string; // DB 저장용 값 (예: "CHECK")
|
||||||
|
label: string; // 화면 표시명 (예: "체크")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 소스 설정 (사용자가 테이블 지정 가능)
|
||||||
|
interface DataSourceConfig {
|
||||||
|
// 품목 테이블
|
||||||
|
itemTable: string; // 기본: "item_info"
|
||||||
|
itemNameColumn: string; // 기본: "item_name"
|
||||||
|
itemCodeColumn: string; // 기본: "item_number"
|
||||||
|
|
||||||
|
// 라우팅 버전 테이블
|
||||||
|
routingVersionTable: string; // 기본: "item_routing_version"
|
||||||
|
routingItemFkColumn: string; // 기본: "item_code" (품목과 연결하는 FK)
|
||||||
|
routingVersionNameColumn: string; // 기본: "version_name"
|
||||||
|
|
||||||
|
// 라우팅 상세 테이블
|
||||||
|
routingDetailTable: string; // 기본: "item_routing_detail"
|
||||||
|
|
||||||
|
// 공정 마스터 테이블
|
||||||
|
processTable: string; // 기본: "process_mng"
|
||||||
|
processNameColumn: string; // 기본: "process_name"
|
||||||
|
processCodeColumn: string; // 기본: "process_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 Config
|
||||||
|
interface ProcessWorkStandardConfig {
|
||||||
|
// 데이터 소스 설정
|
||||||
|
dataSource: DataSourceConfig;
|
||||||
|
|
||||||
|
// 작업 단계 정의 (기본 3개, 사용자가 추가/삭제/수정 가능)
|
||||||
|
phases: WorkPhaseDefinition[];
|
||||||
|
// 기본값: [
|
||||||
|
// { key: "PRE", label: "작업 전 (Pre-Work)", sortOrder: 1 },
|
||||||
|
// { key: "IN", label: "작업 중 (In-Work)", sortOrder: 2 },
|
||||||
|
// { key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
|
||||||
|
// ]
|
||||||
|
|
||||||
|
// 상세 유형 옵션 (사용자가 추가/삭제 가능)
|
||||||
|
detailTypes: DetailTypeDefinition[];
|
||||||
|
// 기본값: [
|
||||||
|
// { value: "CHECK", label: "체크" },
|
||||||
|
// { value: "INSPECTION", label: "검사" },
|
||||||
|
// { value: "MEASUREMENT", label: "측정" },
|
||||||
|
// ]
|
||||||
|
|
||||||
|
// UI 설정
|
||||||
|
splitRatio?: number; // 좌우 분할 비율, 기본: 30
|
||||||
|
leftPanelTitle?: string; // 좌측 패널 제목, 기본: "품목 및 공정 선택"
|
||||||
|
readonly?: boolean; // 읽기 전용 모드, 기본: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 커스터마이징 시나리오 예시
|
||||||
|
|
||||||
|
**시나리오 A: 제조업 (기본)**
|
||||||
|
```
|
||||||
|
단계: 작업 전 → 작업 중 → 작업 후
|
||||||
|
유형: 체크, 검사, 측정
|
||||||
|
```
|
||||||
|
|
||||||
|
**시나리오 B: 품질검사 강화 회사**
|
||||||
|
```
|
||||||
|
단계: 준비 → 검사 → 판정 → 기록 → 보관
|
||||||
|
유형: 육안검사, 치수검사, 강도검사, 내구검사, 기능검사
|
||||||
|
```
|
||||||
|
|
||||||
|
**시나리오 C: 단순 2단계 회사**
|
||||||
|
```
|
||||||
|
단계: 사전점검 → 사후점검
|
||||||
|
유형: 확인, 기록
|
||||||
|
```
|
||||||
|
|
||||||
|
**시나리오 D: 다른 테이블 사용 회사**
|
||||||
|
```
|
||||||
|
품목 테이블: product_master (item_info 대신)
|
||||||
|
공정 테이블: operation_mng (process_mng 대신)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 DB 설계 반영 사항
|
||||||
|
|
||||||
|
`work_phase` 컬럼은 고정 ENUM이 아니라 **사용자 정의 키(VARCHAR)** 로 저장합니다.
|
||||||
|
- Config에서 `phases[].key` 로 정의한 값이 DB에 저장됨
|
||||||
|
- 예: "PRE", "IN", "POST" 또는 "PREPARE", "INSPECT", "JUDGE", "RECORD", "STORE"
|
||||||
|
- 회사별 Config에 따라 다른 값이 저장되므로, 조회 시 Config의 phases 정의를 기준으로 섹션을 렌더링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 등록 체크리스트
|
||||||
|
|
||||||
|
| 항목 | 파일 | 작업 |
|
||||||
|
|------|------|------|
|
||||||
|
| 컴포넌트 정의 | `v2-process-work-standard/index.ts` | createComponentDefinition |
|
||||||
|
| 렌더러 등록 | `v2-process-work-standard/...Renderer.tsx` | registerSelf() |
|
||||||
|
| 컴포넌트 로드 | `components/index.ts` | import 추가 |
|
||||||
|
| 설정 패널 매핑 | `getComponentConfigPanel.tsx` | CONFIG_PANEL_MAP 추가 |
|
||||||
|
| 라우트 등록 | `backend-node/src/app.ts` | router.use() 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 의존성
|
||||||
|
|
||||||
|
- 외부 라이브러리 추가: 없음 (기존 shadcn/ui + Lucide 아이콘만 사용)
|
||||||
|
- 기존 API 재사용: dataRoutes의 범용 CRUD는 사용하지 않고 전용 API 개발
|
||||||
|
- 이유: 5단계 JOIN + phase별 그룹핑 등 범용 API로는 처리 불가
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
> **목적**: 다양한 회사에서 V2 컴포넌트를 활용하여 화면을 개발할 때 참고하는 범용 가이드
|
> **목적**: 다양한 회사에서 V2 컴포넌트를 활용하여 화면을 개발할 때 참고하는 범용 가이드
|
||||||
> **대상**: 화면 설계자, 개발자
|
> **대상**: 화면 설계자, 개발자
|
||||||
> **버전**: 1.0.0
|
> **버전**: 1.1.0
|
||||||
> **작성일**: 2026-01-30
|
> **작성일**: 2026-02-23 (최종 업데이트)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -19,60 +19,63 @@
|
||||||
| 카드 뷰 | 이미지+정보 카드 형태 | 설비정보, 대시보드 |
|
| 카드 뷰 | 이미지+정보 카드 형태 | 설비정보, 대시보드 |
|
||||||
| 피벗 분석 | 다차원 집계 | 매출분석, 재고현황 |
|
| 피벗 분석 | 다차원 집계 | 매출분석, 재고현황 |
|
||||||
| 반복 컨테이너 | 데이터 수만큼 UI 반복 | 주문 상세, 항목 리스트 |
|
| 반복 컨테이너 | 데이터 수만큼 UI 반복 | 주문 상세, 항목 리스트 |
|
||||||
|
| 그룹화 테이블 | 그룹핑 기능 포함 테이블 | 카테고리별 집계, 부서별 현황 |
|
||||||
|
| 타임라인/스케줄 | 시간축 기반 일정 관리 | 생산일정, 작업스케줄 |
|
||||||
|
|
||||||
### 1.2 불가능한 화면 유형 (별도 개발 필요)
|
### 1.2 불가능한 화면 유형 (별도 개발 필요)
|
||||||
|
|
||||||
| 화면 유형 | 이유 | 해결 방안 |
|
| 화면 유형 | 이유 | 해결 방안 |
|
||||||
|-----------|------|----------|
|
|-----------|------|----------|
|
||||||
| 간트 차트 / 타임라인 | 시간축 기반 UI 없음 | 별도 컴포넌트 개발 or 외부 라이브러리 |
|
|
||||||
| 트리 뷰 (계층 구조) | 트리 컴포넌트 미존재 | `v2-tree-view` 개발 필요 |
|
| 트리 뷰 (계층 구조) | 트리 컴포넌트 미존재 | `v2-tree-view` 개발 필요 |
|
||||||
| 그룹화 테이블 | 그룹핑 기능 미지원 | `v2-grouped-table` 개발 필요 |
|
|
||||||
| 드래그앤드롭 보드 | 칸반 스타일 UI 없음 | 별도 개발 |
|
| 드래그앤드롭 보드 | 칸반 스타일 UI 없음 | 별도 개발 |
|
||||||
| 모바일 앱 스타일 | 네이티브 앱 UI | 별도 개발 |
|
| 모바일 앱 스타일 | 네이티브 앱 UI | 별도 개발 |
|
||||||
| 복잡한 차트 | 기본 집계 외 시각화 | 차트 라이브러리 연동 |
|
| 복잡한 차트 | 기본 집계 외 시각화 | 차트 라이브러리 연동 |
|
||||||
|
|
||||||
|
> **참고**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)는 v1.1에서 추가되어 이제 지원됩니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. V2 컴포넌트 전체 목록 (23개)
|
## 2. V2 컴포넌트 전체 목록 (25개)
|
||||||
|
|
||||||
### 2.1 입력 컴포넌트 (3개)
|
### 2.1 입력 컴포넌트 (4개)
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
| ID | 이름 | 용도 | 주요 옵션 |
|
||||||
|----|------|------|----------|
|
|----|------|------|----------|
|
||||||
| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 이메일, 전화번호, URL, 여러 줄 | inputType, required, readonly, maxLength |
|
| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 슬라이더, 컬러 | inputType(text/number/password/slider/color/button), format(email/tel/url/currency/biz_no), required, readonly, maxLength, min, max, step |
|
||||||
| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크박스 | mode, source(distinct/static/code/entity), multiple |
|
| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크, 태그, 토글, 스왑 | mode(dropdown/combobox/radio/check/tag/tagbox/toggle/swap), source(static/code/db/api/entity/category/distinct/select), searchable, multiple, cascading |
|
||||||
| `v2-date` | 날짜 | 날짜, 시간, 날짜시간, 날짜범위, 월, 연도 | dateType, format, showTime |
|
| `v2-date` | 날짜 | 날짜, 시간, 날짜시간 | dateType(date/time/datetime), format, range, minDate, maxDate, showToday |
|
||||||
|
| `v2-file-upload` | 파일 업로드 | 파일/이미지 업로드 | - |
|
||||||
|
|
||||||
### 2.2 표시 컴포넌트 (3개)
|
### 2.2 표시 컴포넌트 (3개)
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
| ID | 이름 | 용도 | 주요 옵션 |
|
||||||
|----|------|------|----------|
|
|----|------|------|----------|
|
||||||
| `v2-text-display` | 텍스트 표시 | 라벨, 제목, 설명 텍스트 | fontSize, fontWeight, color, textAlign |
|
| `v2-text-display` | 텍스트 표시 | 라벨, 제목, 설명 텍스트 | fontSize, fontWeight, color, textAlign |
|
||||||
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, showImage, columnMapping |
|
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, cardSpacing, columnMapping(titleColumn/subtitleColumn/descriptionColumn/imageColumn), cardStyle(imagePosition/imageSize), dataSource(table/static/api) |
|
||||||
| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수, 최대, 최소 | items, filters, layout |
|
| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수, 최대, 최소 | items, filters, layout |
|
||||||
|
|
||||||
### 2.3 테이블/데이터 컴포넌트 (3개)
|
### 2.3 테이블/데이터 컴포넌트 (4개)
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
| ID | 이름 | 용도 | 주요 옵션 |
|
||||||
|----|------|------|----------|
|
|----|------|------|----------|
|
||||||
| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter |
|
| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter, displayMode(table/card), checkbox, horizontalScroll, linkedFilters, excludeFilter, toolbar, tableStyle, autoLoad |
|
||||||
| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector |
|
| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector, title |
|
||||||
| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields, totals, aggregation |
|
| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields(area: row/column/data/filter, summaryType: sum/avg/count/min/max/countDistinct, groupInterval: year/quarter/month/week/day), dataSource(type: table/api/static, joinConfigs, filterConditions) |
|
||||||
|
| `v2-table-grouped` | 그룹화 테이블 | 그룹핑 기능이 포함된 테이블 | - |
|
||||||
|
|
||||||
### 2.4 레이아웃 컴포넌트 (8개)
|
### 2.4 레이아웃 컴포넌트 (7개)
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
| ID | 이름 | 용도 | 주요 옵션 |
|
||||||
|----|------|------|----------|
|
|----|------|------|----------|
|
||||||
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation, **displayMode: custom** |
|
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, minLeftWidth, minRightWidth, syncSelection, panel별: displayMode(list/table/custom), relation(type/foreignKey), editButton, addButton, deleteButton, additionalTabs |
|
||||||
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs, activeTabId |
|
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs(id/label/order/disabled/components), defaultTab, orientation(horizontal/vertical), allowCloseable, persistSelection |
|
||||||
| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding |
|
| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding |
|
||||||
| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow |
|
| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow |
|
||||||
| `v2-divider-line` | 구분선 | 영역 구분 | orientation, thickness |
|
| `v2-divider-line` | 구분선 | 영역 구분 | orientation, thickness |
|
||||||
| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 반복 렌더링 | dataSourceType, layout, gridColumns |
|
| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 반복 렌더링 | dataSourceType, layout, gridColumns |
|
||||||
| `v2-repeater` | 리피터 | 반복 컨트롤 | - |
|
| `v2-repeater` | 리피터 | 반복 컨트롤 (inline/modal) | - |
|
||||||
| `v2-repeat-screen-modal` | 반복 화면 모달 | 모달 반복 | - |
|
|
||||||
|
|
||||||
### 2.5 액션/특수 컴포넌트 (6개)
|
### 2.5 액션/특수 컴포넌트 (7개)
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
| ID | 이름 | 용도 | 주요 옵션 |
|
||||||
|----|------|------|----------|
|
|----|------|------|----------|
|
||||||
|
|
@ -82,6 +85,7 @@
|
||||||
| `v2-location-swap-selector` | 위치 교환 | 위치 선택/교환 | - |
|
| `v2-location-swap-selector` | 위치 교환 | 위치 선택/교환 | - |
|
||||||
| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 | - |
|
| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 | - |
|
||||||
| `v2-media` | 미디어 | 이미지/동영상 표시 | - |
|
| `v2-media` | 미디어 | 이미지/동영상 표시 | - |
|
||||||
|
| `v2-timeline-scheduler` | 타임라인 스케줄러 | 시간축 기반 일정/작업 관리 | - |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -261,8 +265,26 @@
|
||||||
],
|
],
|
||||||
pagination: {
|
pagination: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
pageSize: 20
|
pageSize: 20,
|
||||||
}
|
showSizeSelector: true,
|
||||||
|
showPageInfo: true
|
||||||
|
},
|
||||||
|
displayMode: "table", // "table" | "card"
|
||||||
|
checkbox: {
|
||||||
|
enabled: true,
|
||||||
|
multiple: true,
|
||||||
|
position: "left",
|
||||||
|
selectAll: true
|
||||||
|
},
|
||||||
|
horizontalScroll: { // 가로 스크롤 설정
|
||||||
|
enabled: true,
|
||||||
|
maxVisibleColumns: 8
|
||||||
|
},
|
||||||
|
linkedFilters: [], // 연결 필터 (다른 컴포넌트와 연동)
|
||||||
|
excludeFilter: {}, // 제외 필터
|
||||||
|
autoLoad: true, // 자동 데이터 로드
|
||||||
|
stickyHeader: false, // 헤더 고정
|
||||||
|
autoWidth: true // 자동 너비 조정
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -271,16 +293,44 @@
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
leftPanel: {
|
leftPanel: {
|
||||||
tableName: "마스터_테이블명"
|
displayMode: "table", // "list" | "table" | "custom"
|
||||||
|
tableName: "마스터_테이블명",
|
||||||
|
columns: [], // 컬럼 설정
|
||||||
|
editButton: { // 수정 버튼 설정
|
||||||
|
enabled: true,
|
||||||
|
mode: "auto", // "auto" | "modal"
|
||||||
|
modalScreenId: "" // 모달 모드 시 화면 ID
|
||||||
|
},
|
||||||
|
addButton: { // 추가 버튼 설정
|
||||||
|
enabled: true,
|
||||||
|
mode: "auto",
|
||||||
|
modalScreenId: ""
|
||||||
|
},
|
||||||
|
deleteButton: { // 삭제 버튼 설정
|
||||||
|
enabled: true,
|
||||||
|
buttonLabel: "삭제",
|
||||||
|
confirmMessage: "삭제하시겠습니까?"
|
||||||
|
},
|
||||||
|
addModalColumns: [], // 추가 모달 전용 컬럼
|
||||||
|
additionalTabs: [] // 추가 탭 설정
|
||||||
},
|
},
|
||||||
rightPanel: {
|
rightPanel: {
|
||||||
|
displayMode: "table",
|
||||||
tableName: "디테일_테이블명",
|
tableName: "디테일_테이블명",
|
||||||
relation: {
|
relation: {
|
||||||
type: "detail", // join | detail | custom
|
type: "detail", // "join" | "detail" | "custom"
|
||||||
foreignKey: "master_id" // 연결 키
|
foreignKey: "master_id", // 연결 키
|
||||||
|
leftColumn: "", // 좌측 연결 컬럼
|
||||||
|
rightColumn: "", // 우측 연결 컬럼
|
||||||
|
keys: [] // 복합 키
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
splitRatio: 30 // 좌측 비율
|
splitRatio: 30, // 좌측 비율 (0-100)
|
||||||
|
resizable: true, // 리사이즈 가능
|
||||||
|
minLeftWidth: 200, // 좌측 최소 너비
|
||||||
|
minRightWidth: 300, // 우측 최소 너비
|
||||||
|
syncSelection: true, // 선택 동기화
|
||||||
|
autoLoad: true // 자동 로드
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -347,12 +397,12 @@
|
||||||
| 기능 | 상태 | 대안 |
|
| 기능 | 상태 | 대안 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 트리 뷰 (BOM, 조직도) | ❌ 미지원 | 테이블로 대체 or 별도 개발 |
|
| 트리 뷰 (BOM, 조직도) | ❌ 미지원 | 테이블로 대체 or 별도 개발 |
|
||||||
| 그룹화 테이블 | ❌ 미지원 | 일반 테이블로 대체 or 별도 개발 |
|
|
||||||
| 간트 차트 | ❌ 미지원 | 별도 개발 필요 |
|
|
||||||
| 드래그앤드롭 정렬 | ❌ 미지원 | 순서 컬럼으로 대체 |
|
| 드래그앤드롭 정렬 | ❌ 미지원 | 순서 컬럼으로 대체 |
|
||||||
| 인라인 편집 | ⚠️ 제한적 | 모달 편집으로 대체 |
|
| 인라인 편집 | ⚠️ 제한적 | 모달 편집으로 대체 |
|
||||||
| 복잡한 차트 | ❌ 미지원 | 외부 라이브러리 연동 |
|
| 복잡한 차트 | ❌ 미지원 | 외부 라이브러리 연동 |
|
||||||
|
|
||||||
|
> **v1.1 업데이트**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)가 추가되어 해당 기능은 이제 지원됩니다.
|
||||||
|
|
||||||
### 5.2 권장하지 않는 조합
|
### 5.2 권장하지 않는 조합
|
||||||
|
|
||||||
| 조합 | 이유 |
|
| 조합 | 이유 |
|
||||||
|
|
@ -555,9 +605,10 @@
|
||||||
| 탭 화면 | ✅ 완전 | v2-tabs-widget |
|
| 탭 화면 | ✅ 완전 | v2-tabs-widget |
|
||||||
| 카드 뷰 | ✅ 완전 | v2-card-display |
|
| 카드 뷰 | ✅ 완전 | v2-card-display |
|
||||||
| 피벗 분석 | ✅ 완전 | v2-pivot-grid |
|
| 피벗 분석 | ✅ 완전 | v2-pivot-grid |
|
||||||
| 그룹화 테이블 | ❌ 미지원 | 개발 필요 |
|
| 그룹화 테이블 | ✅ 지원 | v2-table-grouped |
|
||||||
|
| 타임라인/스케줄 | ✅ 지원 | v2-timeline-scheduler |
|
||||||
|
| 파일 업로드 | ✅ 지원 | v2-file-upload |
|
||||||
| 트리 뷰 | ❌ 미지원 | 개발 필요 |
|
| 트리 뷰 | ❌ 미지원 | 개발 필요 |
|
||||||
| 간트 차트 | ❌ 미지원 | 개발 필요 |
|
|
||||||
|
|
||||||
### 개발 시 핵심 원칙
|
### 개발 시 핵심 원칙
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
## ⚠️ 문서 사용 안내
|
## ⚠️ 문서 사용 안내
|
||||||
|
|
||||||
> **이 문서는 "품목정보" 화면의 구현 예시입니다.**
|
|
||||||
>
|
>
|
||||||
> ### 📌 중요: JSON 데이터는 참고용입니다!
|
> ### 📌 중요: JSON 데이터는 참고용입니다!
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ interface ColumnTypeInfo {
|
||||||
detailSettings: string;
|
detailSettings: string;
|
||||||
description: string;
|
description: string;
|
||||||
isNullable: string;
|
isNullable: string;
|
||||||
|
isUnique: string;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
numericPrecision?: number;
|
numericPrecision?: number;
|
||||||
|
|
@ -72,9 +73,10 @@ interface ColumnTypeInfo {
|
||||||
referenceTable?: string;
|
referenceTable?: string;
|
||||||
referenceColumn?: string;
|
referenceColumn?: string;
|
||||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||||
categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열
|
categoryMenus?: number[];
|
||||||
hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할
|
hierarchyRole?: "large" | "medium" | "small";
|
||||||
numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID
|
numberingRuleId?: string;
|
||||||
|
categoryRef?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SecondLevelMenu {
|
interface SecondLevelMenu {
|
||||||
|
|
@ -382,10 +384,12 @@ export default function TableManagementPage() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...col,
|
...col,
|
||||||
inputType: col.inputType || "text", // 기본값: text
|
inputType: col.inputType || "text",
|
||||||
numberingRuleId, // 🆕 채번규칙 ID
|
isUnique: col.isUnique || "NO",
|
||||||
categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보
|
numberingRuleId,
|
||||||
hierarchyRole, // 계층구조 역할
|
categoryMenus: col.categoryMenus || [],
|
||||||
|
hierarchyRole,
|
||||||
|
categoryRef: col.categoryRef || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -668,15 +672,16 @@ export default function TableManagementPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnSetting = {
|
const columnSetting = {
|
||||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
columnName: column.columnName,
|
||||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
columnLabel: column.displayName,
|
||||||
inputType: column.inputType || "text",
|
inputType: column.inputType || "text",
|
||||||
detailSettings: finalDetailSettings,
|
detailSettings: finalDetailSettings,
|
||||||
codeCategory: column.codeCategory || "",
|
codeCategory: column.codeCategory || "",
|
||||||
codeValue: column.codeValue || "",
|
codeValue: column.codeValue || "",
|
||||||
referenceTable: column.referenceTable || "",
|
referenceTable: column.referenceTable || "",
|
||||||
referenceColumn: column.referenceColumn || "",
|
referenceColumn: column.referenceColumn || "",
|
||||||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
displayColumn: column.displayColumn || "",
|
||||||
|
categoryRef: column.categoryRef || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// console.log("저장할 컬럼 설정:", columnSetting);
|
// console.log("저장할 컬럼 설정:", columnSetting);
|
||||||
|
|
@ -703,9 +708,9 @@ export default function TableManagementPage() {
|
||||||
length: column.categoryMenus?.length || 0,
|
length: column.categoryMenus?.length || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (column.inputType === "category") {
|
if (column.inputType === "category" && !column.categoryRef) {
|
||||||
// 1. 먼저 기존 매핑 모두 삭제
|
// 참조가 아닌 자체 카테고리만 메뉴 매핑 처리
|
||||||
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", {
|
console.log("기존 카테고리 메뉴 매핑 삭제 시작:", {
|
||||||
tableName: selectedTable,
|
tableName: selectedTable,
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
});
|
});
|
||||||
|
|
@ -864,8 +869,8 @@ export default function TableManagementPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
columnName: column.columnName,
|
||||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
columnLabel: column.displayName,
|
||||||
inputType: column.inputType || "text",
|
inputType: column.inputType || "text",
|
||||||
detailSettings: finalDetailSettings,
|
detailSettings: finalDetailSettings,
|
||||||
description: column.description || "",
|
description: column.description || "",
|
||||||
|
|
@ -873,7 +878,8 @@ export default function TableManagementPage() {
|
||||||
codeValue: column.codeValue || "",
|
codeValue: column.codeValue || "",
|
||||||
referenceTable: column.referenceTable || "",
|
referenceTable: column.referenceTable || "",
|
||||||
referenceColumn: column.referenceColumn || "",
|
referenceColumn: column.referenceColumn || "",
|
||||||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
displayColumn: column.displayColumn || "",
|
||||||
|
categoryRef: column.categoryRef || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -886,8 +892,8 @@ export default function TableManagementPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
// 🆕 Category 타입 컬럼들의 메뉴 매핑 처리
|
// 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외)
|
||||||
const categoryColumns = columns.filter((col) => col.inputType === "category");
|
const categoryColumns = columns.filter((col) => col.inputType === "category" && !col.categoryRef);
|
||||||
|
|
||||||
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
|
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
|
||||||
totalColumns: columns.length,
|
totalColumns: columns.length,
|
||||||
|
|
@ -1091,9 +1097,9 @@ export default function TableManagementPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 인덱스 토글 핸들러
|
// 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨)
|
||||||
const handleIndexToggle = useCallback(
|
const handleIndexToggle = useCallback(
|
||||||
async (columnName: string, indexType: "index" | "unique", checked: boolean) => {
|
async (columnName: string, indexType: "index", checked: boolean) => {
|
||||||
if (!selectedTable) return;
|
if (!selectedTable) return;
|
||||||
const action = checked ? "create" : "drop";
|
const action = checked ? "create" : "drop";
|
||||||
try {
|
try {
|
||||||
|
|
@ -1122,14 +1128,41 @@ export default function TableManagementPage() {
|
||||||
const hasIndex = constraints.indexes.some(
|
const hasIndex = constraints.indexes.some(
|
||||||
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
|
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
|
||||||
);
|
);
|
||||||
const hasUnique = constraints.indexes.some(
|
return { isPk, hasIndex };
|
||||||
(idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
|
|
||||||
);
|
|
||||||
return { isPk, hasIndex, hasUnique };
|
|
||||||
},
|
},
|
||||||
[constraints],
|
[constraints],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// UNIQUE 토글 핸들러 (앱 레벨 소프트 제약조건 - NOT NULL과 동일 패턴)
|
||||||
|
const handleUniqueToggle = useCallback(
|
||||||
|
async (columnName: string, currentIsUnique: string) => {
|
||||||
|
if (!selectedTable) return;
|
||||||
|
const isCurrentlyUnique = currentIsUnique === "YES";
|
||||||
|
const newUnique = !isCurrentlyUnique;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(
|
||||||
|
`/table-management/tables/${selectedTable}/columns/${columnName}/unique`,
|
||||||
|
{ unique: newUnique },
|
||||||
|
);
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success(response.data.message);
|
||||||
|
setColumns((prev) =>
|
||||||
|
prev.map((col) =>
|
||||||
|
col.columnName === columnName
|
||||||
|
? { ...col, isUnique: newUnique ? "YES" : "NO" }
|
||||||
|
: col,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.message || "UNIQUE 설정 실패");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || "UNIQUE 설정 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedTable],
|
||||||
|
);
|
||||||
|
|
||||||
// NOT NULL 토글 핸들러
|
// NOT NULL 토글 핸들러
|
||||||
const handleNullableToggle = useCallback(
|
const handleNullableToggle = useCallback(
|
||||||
async (columnName: string, currentIsNullable: string) => {
|
async (columnName: string, currentIsNullable: string) => {
|
||||||
|
|
@ -1662,7 +1695,30 @@ export default function TableManagementPage() {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */}
|
{/* 카테고리 타입: 참조 설정 */}
|
||||||
|
{column.inputType === "category" && (
|
||||||
|
<div className="w-56">
|
||||||
|
<label className="text-muted-foreground mb-1 block text-xs">카테고리 참조 (선택)</label>
|
||||||
|
<Input
|
||||||
|
value={column.categoryRef || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value || null;
|
||||||
|
setColumns((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.columnName === column.columnName
|
||||||
|
? { ...c, categoryRef: val }
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
placeholder="테이블명.컬럼명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||||
|
다른 테이블의 카테고리 값 참조 시 입력
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||||
{column.inputType === "entity" && (
|
{column.inputType === "entity" && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -2029,12 +2085,12 @@ export default function TableManagementPage() {
|
||||||
aria-label={`${column.columnName} 인덱스 설정`}
|
aria-label={`${column.columnName} 인덱스 설정`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* UQ 체크박스 */}
|
{/* UQ 체크박스 (앱 레벨 소프트 제약조건) */}
|
||||||
<div className="flex items-center justify-center pt-1">
|
<div className="flex items-center justify-center pt-1">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={idxState.hasUnique}
|
checked={column.isUnique === "YES"}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={() =>
|
||||||
handleIndexToggle(column.columnName, "unique", checked as boolean)
|
handleUniqueToggle(column.columnName, column.isUnique)
|
||||||
}
|
}
|
||||||
aria-label={`${column.columnName} 유니크 설정`}
|
aria-label={`${column.columnName} 유니크 설정`}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,12 @@ function ScreenViewPage() {
|
||||||
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
|
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
|
||||||
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
|
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
// 🆕 레이어 시스템 지원
|
// 레이어 시스템 지원
|
||||||
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
|
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
|
||||||
// 🆕 조건부 영역(Zone) 목록
|
// 조건부 영역(Zone) 목록
|
||||||
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
|
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
|
||||||
|
// 데이터 전달에 의해 강제 활성화된 레이어 ID 목록
|
||||||
|
const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// 편집 모달 상태
|
// 편집 모달 상태
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
|
@ -378,11 +380,51 @@ function ScreenViewPage() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return newActiveIds;
|
// 강제 활성화된 레이어 ID 병합
|
||||||
}, [formData, conditionalLayers, layout]);
|
for (const forcedId of forceActivatedLayerIds) {
|
||||||
|
if (!newActiveIds.includes(forcedId)) {
|
||||||
|
newActiveIds.push(forcedId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
return newActiveIds;
|
||||||
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
|
}, [formData, conditionalLayers, layout, forceActivatedLayerIds]);
|
||||||
|
|
||||||
|
// 데이터 전달에 의한 레이어 강제 활성화 이벤트 리스너
|
||||||
|
useEffect(() => {
|
||||||
|
const handleActivateLayer = (e: Event) => {
|
||||||
|
const { componentId, targetLayerId } = (e as CustomEvent).detail || {};
|
||||||
|
if (!componentId && !targetLayerId) return;
|
||||||
|
|
||||||
|
// targetLayerId가 직접 지정된 경우
|
||||||
|
if (targetLayerId) {
|
||||||
|
setForceActivatedLayerIds((prev) =>
|
||||||
|
prev.includes(targetLayerId) ? prev : [...prev, targetLayerId],
|
||||||
|
);
|
||||||
|
console.log(`🔓 [레이어 강제 활성화] layerId: ${targetLayerId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// componentId로 해당 컴포넌트가 속한 레이어를 찾아 활성화
|
||||||
|
for (const layer of conditionalLayers) {
|
||||||
|
const found = layer.components.some((comp) => comp.id === componentId);
|
||||||
|
if (found) {
|
||||||
|
setForceActivatedLayerIds((prev) =>
|
||||||
|
prev.includes(layer.id) ? prev : [...prev, layer.id],
|
||||||
|
);
|
||||||
|
console.log(`🔓 [레이어 강제 활성화] componentId: ${componentId} → layerId: ${layer.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("activateLayerForComponent", handleActivateLayer);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("activateLayerForComponent", handleActivateLayer);
|
||||||
|
};
|
||||||
|
}, [conditionalLayers]);
|
||||||
|
|
||||||
|
// 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadMainTableData = async () => {
|
const loadMainTableData = async () => {
|
||||||
if (!screen || !layout || !layout.components || !companyCode) {
|
if (!screen || !layout || !layout.components || !companyCode) {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ import {
|
||||||
GRID_BREAKPOINTS,
|
GRID_BREAKPOINTS,
|
||||||
detectGridMode,
|
detectGridMode,
|
||||||
} from "@/components/pop/designer/types/pop-layout";
|
} from "@/components/pop/designer/types/pop-layout";
|
||||||
import PopRenderer from "@/components/pop/designer/renderers/PopRenderer";
|
// POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import)
|
||||||
|
import "@/lib/registry/pop-components";
|
||||||
|
import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals";
|
||||||
import {
|
import {
|
||||||
useResponsiveModeWithOverride,
|
useResponsiveModeWithOverride,
|
||||||
type DeviceType,
|
type DeviceType,
|
||||||
|
|
@ -144,6 +146,28 @@ function PopScreenViewPage() {
|
||||||
}
|
}
|
||||||
}, [screenId]);
|
}, [screenId]);
|
||||||
|
|
||||||
|
// 뷰어 모드에서도 컴포넌트 크기 변경 지원 (더보기 등)
|
||||||
|
const handleRequestResize = React.useCallback((componentId: string, newRowSpan: number, newColSpan?: number) => {
|
||||||
|
setLayout((prev) => {
|
||||||
|
const comp = prev.components[componentId];
|
||||||
|
if (!comp) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
components: {
|
||||||
|
...prev.components,
|
||||||
|
[componentId]: {
|
||||||
|
...comp,
|
||||||
|
position: {
|
||||||
|
...comp.position,
|
||||||
|
rowSpan: newRowSpan,
|
||||||
|
...(newColSpan !== undefined ? { colSpan: newColSpan } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"];
|
const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"];
|
||||||
const hasComponents = Object.keys(layout.components).length > 0;
|
const hasComponents = Object.keys(layout.components).length > 0;
|
||||||
|
|
||||||
|
|
@ -180,7 +204,7 @@ function PopScreenViewPage() {
|
||||||
<ScreenPreviewProvider isPreviewMode={isPreviewMode}>
|
<ScreenPreviewProvider isPreviewMode={isPreviewMode}>
|
||||||
<ActiveTabProvider>
|
<ActiveTabProvider>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div className="h-screen bg-gray-100 flex flex-col overflow-hidden">
|
<div className="h-screen bg-gray-100 flex flex-col">
|
||||||
{/* 상단 툴바 (프리뷰 모드에서만) */}
|
{/* 상단 툴바 (프리뷰 모드에서만) */}
|
||||||
{isPreviewMode && (
|
{isPreviewMode && (
|
||||||
<div className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
<div className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
||||||
|
|
@ -261,7 +285,7 @@ function PopScreenViewPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* POP 화면 컨텐츠 */}
|
{/* POP 화면 컨텐츠 */}
|
||||||
<div className={`flex-1 flex flex-col ${isPreviewMode ? "py-4 overflow-auto items-center" : ""}`}>
|
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : ""}`}>
|
||||||
{/* 현재 모드 표시 (일반 모드) */}
|
{/* 현재 모드 표시 (일반 모드) */}
|
||||||
{!isPreviewMode && (
|
{!isPreviewMode && (
|
||||||
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
||||||
|
|
@ -270,7 +294,7 @@ function PopScreenViewPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full"}`}
|
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full min-h-full"}`}
|
||||||
style={isPreviewMode ? {
|
style={isPreviewMode ? {
|
||||||
width: currentDevice.width,
|
width: currentDevice.width,
|
||||||
maxHeight: "80vh",
|
maxHeight: "80vh",
|
||||||
|
|
@ -292,13 +316,15 @@ function PopScreenViewPage() {
|
||||||
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
|
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopRenderer
|
<PopViewerWithModals
|
||||||
layout={layout}
|
layout={layout}
|
||||||
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
|
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
|
||||||
|
screenId={String(screenId)}
|
||||||
currentMode={currentModeKey}
|
currentMode={currentModeKey}
|
||||||
isDesignMode={false}
|
|
||||||
overrideGap={adjustedGap}
|
overrideGap={adjustedGap}
|
||||||
overridePadding={adjustedPadding}
|
overridePadding={adjustedPadding}
|
||||||
|
onRequestResize={handleRequestResize}
|
||||||
|
currentScreenId={screenId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
|
|
@ -942,8 +942,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용)
|
// 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용
|
||||||
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) {
|
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) {
|
||||||
|
const existingValue = dataToSave[numberingInfo.columnName];
|
||||||
|
const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
|
||||||
|
|
||||||
|
if (!hasExcelValue) {
|
||||||
try {
|
try {
|
||||||
const { apiClient } = await import("@/lib/api/client");
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`);
|
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`);
|
||||||
|
|
@ -955,6 +959,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
console.error("채번 오류:", numError);
|
console.error("채번 오류:", numError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldUpdate && existingRow) {
|
if (shouldUpdate && existingRow) {
|
||||||
// 덮어쓰기: 기존 데이터 업데이트
|
// 덮어쓰기: 기존 데이터 업데이트
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||||
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||||
|
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||||
|
|
||||||
interface ScreenModalState {
|
interface ScreenModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -178,10 +179,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
splitPanelParentData,
|
splitPanelParentData,
|
||||||
selectedData: eventSelectedData,
|
selectedData: eventSelectedData,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함)
|
isCreateMode,
|
||||||
fieldMappings, // 🆕 필드 매핑 정보 (명시적 매핑이 있으면 모든 매핑된 필드 전달)
|
fieldMappings,
|
||||||
} = event.detail;
|
} = event.detail;
|
||||||
|
|
||||||
|
console.log("🟣 [ScreenModal] openScreenModal 이벤트 수신:", {
|
||||||
|
screenId,
|
||||||
|
splitPanelParentData: JSON.stringify(splitPanelParentData),
|
||||||
|
editData: !!editData,
|
||||||
|
isCreateMode,
|
||||||
|
});
|
||||||
|
|
||||||
// 🆕 모달 열린 시간 기록
|
// 🆕 모달 열린 시간 기록
|
||||||
modalOpenedAtRef.current = Date.now();
|
modalOpenedAtRef.current = Date.now();
|
||||||
|
|
||||||
|
|
@ -355,8 +363,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(parentData).length > 0) {
|
if (Object.keys(parentData).length > 0) {
|
||||||
|
console.log("🔵 [ScreenModal] ADD모드 formData 설정:", JSON.stringify(parentData));
|
||||||
setFormData(parentData);
|
setFormData(parentData);
|
||||||
} else {
|
} else {
|
||||||
|
console.log("🔵 [ScreenModal] ADD모드 formData 비어있음");
|
||||||
setFormData({});
|
setFormData({});
|
||||||
}
|
}
|
||||||
setOriginalData(null); // 신규 등록 모드
|
setOriginalData(null); // 신규 등록 모드
|
||||||
|
|
@ -1016,6 +1026,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : screenData ? (
|
) : screenData ? (
|
||||||
|
<ScreenContextProvider
|
||||||
|
screenId={modalState.screenId || undefined}
|
||||||
|
tableName={screenData.screenInfo?.tableName}
|
||||||
|
>
|
||||||
<ActiveTabProvider>
|
<ActiveTabProvider>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div
|
<div
|
||||||
|
|
@ -1174,13 +1188,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
formData={formData}
|
formData={formData}
|
||||||
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
// 사용자가 실제로 데이터를 변경한 것으로 표시
|
|
||||||
formDataChangedRef.current = true;
|
formDataChangedRef.current = true;
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const newFormData = {
|
const newFormData = {
|
||||||
...prev,
|
...prev,
|
||||||
[fieldName]: value,
|
[fieldName]: value,
|
||||||
};
|
};
|
||||||
|
console.log("🟡 [ScreenModal] onFormDataChange:", fieldName, "→", value, "| formData keys:", Object.keys(newFormData), "| process_code:", newFormData.process_code);
|
||||||
return newFormData;
|
return newFormData;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
@ -1245,6 +1259,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
</TableOptionsProvider>
|
</TableOptionsProvider>
|
||||||
</ActiveTabProvider>
|
</ActiveTabProvider>
|
||||||
|
</ScreenContextProvider>
|
||||||
) : (
|
) : (
|
||||||
<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>
|
||||||
|
|
@ -1273,7 +1288,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
|
|
||||||
{/* 모달 닫기 확인 다이얼로그 */}
|
{/* 모달 닫기 확인 다이얼로그 */}
|
||||||
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
|
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
|
||||||
<AlertDialogContent className="!z-[1100] max-w-[95vw] sm:max-w-[400px]">
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle className="text-base sm:text-lg">
|
<AlertDialogTitle className="text-base sm:text-lg">
|
||||||
화면을 닫으시겠습니까?
|
화면을 닫으시겠습니까?
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
||||||
isPreview = false,
|
isPreview = false,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Card className="border-border bg-card">
|
<Card className="border-border bg-card flex-1">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Badge variant="outline" className="text-xs sm:text-sm">
|
<Badge variant="outline" className="text-xs sm:text-sm">
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
||||||
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
||||||
|
|
||||||
// 구분자 관련 상태
|
// 구분자 관련 상태 (개별 파트 사이 구분자)
|
||||||
const [separatorType, setSeparatorType] = useState<SeparatorType>("-");
|
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
||||||
const [customSeparator, setCustomSeparator] = useState("");
|
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
|
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
|
||||||
interface CategoryOption {
|
interface CategoryOption {
|
||||||
|
|
@ -192,48 +192,68 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
}
|
}
|
||||||
}, [currentRule, onChange]);
|
}, [currentRule, onChange]);
|
||||||
|
|
||||||
// currentRule이 변경될 때 구분자 상태 동기화
|
// currentRule이 변경될 때 파트별 구분자 상태 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentRule) {
|
if (currentRule && currentRule.parts.length > 0) {
|
||||||
const sep = currentRule.separator ?? "-";
|
const newSepTypes: Record<number, SeparatorType> = {};
|
||||||
// 빈 문자열이면 "none"
|
const newCustomSeps: Record<number, string> = {};
|
||||||
|
|
||||||
|
currentRule.parts.forEach((part) => {
|
||||||
|
const sep = part.separatorAfter ?? currentRule.separator ?? "-";
|
||||||
if (sep === "") {
|
if (sep === "") {
|
||||||
setSeparatorType("none");
|
newSepTypes[part.order] = "none";
|
||||||
setCustomSeparator("");
|
newCustomSeps[part.order] = "";
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
// 미리 정의된 구분자인지 확인 (none, custom 제외)
|
|
||||||
const predefinedOption = SEPARATOR_OPTIONS.find(
|
const predefinedOption = SEPARATOR_OPTIONS.find(
|
||||||
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
|
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
|
||||||
);
|
);
|
||||||
if (predefinedOption) {
|
if (predefinedOption) {
|
||||||
setSeparatorType(predefinedOption.value);
|
newSepTypes[part.order] = predefinedOption.value;
|
||||||
setCustomSeparator("");
|
newCustomSeps[part.order] = "";
|
||||||
} else {
|
} else {
|
||||||
// 직접 입력된 구분자
|
newSepTypes[part.order] = "custom";
|
||||||
setSeparatorType("custom");
|
newCustomSeps[part.order] = sep;
|
||||||
setCustomSeparator(sep);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시)
|
});
|
||||||
|
|
||||||
// 구분자 변경 핸들러
|
setSeparatorTypes(newSepTypes);
|
||||||
const handleSeparatorChange = useCallback((type: SeparatorType) => {
|
setCustomSeparators(newCustomSeps);
|
||||||
setSeparatorType(type);
|
}
|
||||||
|
}, [currentRule?.ruleId]);
|
||||||
|
|
||||||
|
// 개별 파트 구분자 변경 핸들러
|
||||||
|
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
|
||||||
|
setSeparatorTypes(prev => ({ ...prev, [partOrder]: type }));
|
||||||
if (type !== "custom") {
|
if (type !== "custom") {
|
||||||
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
|
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
|
||||||
const newSeparator = option?.displayValue ?? "";
|
const newSeparator = option?.displayValue ?? "";
|
||||||
setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null);
|
setCustomSeparators(prev => ({ ...prev, [partOrder]: "" }));
|
||||||
setCustomSeparator("");
|
setCurrentRule((prev) => {
|
||||||
|
if (!prev) return null;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
parts: prev.parts.map((part) =>
|
||||||
|
part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 직접 입력 구분자 변경 핸들러
|
// 개별 파트 직접 입력 구분자 변경 핸들러
|
||||||
const handleCustomSeparatorChange = useCallback((value: string) => {
|
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
|
||||||
// 최대 2자 제한
|
|
||||||
const trimmedValue = value.slice(0, 2);
|
const trimmedValue = value.slice(0, 2);
|
||||||
setCustomSeparator(trimmedValue);
|
setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue }));
|
||||||
setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null);
|
setCurrentRule((prev) => {
|
||||||
|
if (!prev) return null;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
parts: prev.parts.map((part) =>
|
||||||
|
part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddPart = useCallback(() => {
|
const handleAddPart = useCallback(() => {
|
||||||
|
|
@ -250,6 +270,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
partType: "text",
|
partType: "text",
|
||||||
generationMethod: "auto",
|
generationMethod: "auto",
|
||||||
autoConfig: { textValue: "CODE" },
|
autoConfig: { textValue: "CODE" },
|
||||||
|
separatorAfter: "-",
|
||||||
};
|
};
|
||||||
|
|
||||||
setCurrentRule((prev) => {
|
setCurrentRule((prev) => {
|
||||||
|
|
@ -257,6 +278,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
return { ...prev, parts: [...prev.parts, newPart] };
|
return { ...prev, parts: [...prev.parts, newPart] };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 새 파트의 구분자 상태 초기화
|
||||||
|
setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" }));
|
||||||
|
setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" }));
|
||||||
|
|
||||||
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
||||||
}, [currentRule, maxRules]);
|
}, [currentRule, maxRules]);
|
||||||
|
|
||||||
|
|
@ -573,42 +598,6 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 두 번째 줄: 구분자 설정 */}
|
|
||||||
<div className="flex items-end gap-3">
|
|
||||||
<div className="w-48 space-y-2">
|
|
||||||
<Label className="text-sm font-medium">구분자</Label>
|
|
||||||
<Select
|
|
||||||
value={separatorType}
|
|
||||||
onValueChange={(value) => handleSeparatorChange(value as SeparatorType)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue placeholder="구분자 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{SEPARATOR_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{separatorType === "custom" && (
|
|
||||||
<div className="w-32 space-y-2">
|
|
||||||
<Label className="text-sm font-medium">직접 입력</Label>
|
|
||||||
<Input
|
|
||||||
value={customSeparator}
|
|
||||||
onChange={(e) => handleCustomSeparatorChange(e.target.value)}
|
|
||||||
className="h-9"
|
|
||||||
placeholder="최대 2자"
|
|
||||||
maxLength={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-muted-foreground pb-2 text-xs">
|
|
||||||
규칙 사이에 들어갈 문자입니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -625,15 +614,48 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
<p className="text-muted-foreground text-xs sm:text-sm">규칙을 추가하여 코드를 구성하세요</p>
|
<p className="text-muted-foreground text-xs sm:text-sm">규칙을 추가하여 코드를 구성하세요</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
<div className="flex flex-wrap items-stretch gap-3">
|
||||||
{currentRule.parts.map((part, index) => (
|
{currentRule.parts.map((part, index) => (
|
||||||
|
<React.Fragment key={`part-${part.order}-${index}`}>
|
||||||
|
<div className="flex w-[200px] flex-col">
|
||||||
<NumberingRuleCard
|
<NumberingRuleCard
|
||||||
key={`part-${part.order}-${index}`}
|
|
||||||
part={part}
|
part={part}
|
||||||
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
||||||
onDelete={() => handleDeletePart(part.order)}
|
onDelete={() => handleDeletePart(part.order)}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
/>
|
/>
|
||||||
|
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
|
||||||
|
{index < currentRule.parts.length - 1 && (
|
||||||
|
<div className="mt-2 flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground text-[10px] whitespace-nowrap">뒤 구분자</span>
|
||||||
|
<Select
|
||||||
|
value={separatorTypes[part.order] || "-"}
|
||||||
|
onValueChange={(value) => handlePartSeparatorChange(part.order, value as SeparatorType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 flex-1 text-[10px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SEPARATOR_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{separatorTypes[part.order] === "custom" && (
|
||||||
|
<Input
|
||||||
|
value={customSeparators[part.order] || ""}
|
||||||
|
onChange={(e) => handlePartCustomSeparatorChange(part.order, e.target.value)}
|
||||||
|
className="h-6 w-14 text-center text-[10px]"
|
||||||
|
placeholder="2자"
|
||||||
|
maxLength={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
||||||
return "규칙을 추가해주세요";
|
return "규칙을 추가해주세요";
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = config.parts
|
const sortedParts = config.parts.sort((a, b) => a.order - b.order);
|
||||||
.sort((a, b) => a.order - b.order)
|
|
||||||
.map((part) => {
|
const partValues = sortedParts.map((part) => {
|
||||||
if (part.generationMethod === "manual") {
|
if (part.generationMethod === "manual") {
|
||||||
return part.manualConfig?.value || "XXX";
|
return part.manualConfig?.value || "XXX";
|
||||||
}
|
}
|
||||||
|
|
@ -27,27 +27,19 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
||||||
const autoConfig = part.autoConfig || {};
|
const autoConfig = part.autoConfig || {};
|
||||||
|
|
||||||
switch (part.partType) {
|
switch (part.partType) {
|
||||||
// 1. 순번 (자동 증가)
|
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
const length = autoConfig.sequenceLength || 3;
|
const length = autoConfig.sequenceLength || 3;
|
||||||
const startFrom = autoConfig.startFrom || 1;
|
const startFrom = autoConfig.startFrom || 1;
|
||||||
return String(startFrom).padStart(length, "0");
|
return String(startFrom).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 숫자 (고정 자릿수)
|
|
||||||
case "number": {
|
case "number": {
|
||||||
const length = autoConfig.numberLength || 4;
|
const length = autoConfig.numberLength || 4;
|
||||||
const value = autoConfig.numberValue || 0;
|
const value = autoConfig.numberValue || 0;
|
||||||
return String(value).padStart(length, "0");
|
return String(value).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 날짜
|
|
||||||
case "date": {
|
case "date": {
|
||||||
const format = autoConfig.dateFormat || "YYYYMMDD";
|
const format = autoConfig.dateFormat || "YYYYMMDD";
|
||||||
|
|
||||||
// 컬럼 기준 생성인 경우 placeholder 표시
|
|
||||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
|
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
|
||||||
// 형식에 맞는 placeholder 반환
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case "YYYY": return "[YYYY]";
|
case "YYYY": return "[YYYY]";
|
||||||
case "YY": return "[YY]";
|
case "YY": return "[YY]";
|
||||||
|
|
@ -58,13 +50,10 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
||||||
default: return "[DATE]";
|
default: return "[DATE]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 현재 날짜 기준 생성
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(now.getDate()).padStart(2, "0");
|
const day = String(now.getDate()).padStart(2, "0");
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case "YYYY": return String(year);
|
case "YYYY": return String(year);
|
||||||
case "YY": return String(year).slice(-2);
|
case "YY": return String(year).slice(-2);
|
||||||
|
|
@ -75,17 +64,24 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
||||||
default: return `${year}${month}${day}`;
|
default: return `${year}${month}${day}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 문자
|
|
||||||
case "text":
|
case "text":
|
||||||
return autoConfig.textValue || "TEXT";
|
return autoConfig.textValue || "TEXT";
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return "XXX";
|
return "XXX";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return parts.join(config.separator || "");
|
// 파트별 개별 구분자로 결합
|
||||||
|
const globalSep = config.separator ?? "-";
|
||||||
|
let result = "";
|
||||||
|
partValues.forEach((val, idx) => {
|
||||||
|
result += val;
|
||||||
|
if (idx < partValues.length - 1) {
|
||||||
|
const sep = sortedParts[idx].separatorAfter ?? globalSep;
|
||||||
|
result += sep;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,12 @@ import {
|
||||||
GAP_PRESETS,
|
GAP_PRESETS,
|
||||||
GRID_BREAKPOINTS,
|
GRID_BREAKPOINTS,
|
||||||
DEFAULT_COMPONENT_GRID_SIZE,
|
DEFAULT_COMPONENT_GRID_SIZE,
|
||||||
|
PopModalDefinition,
|
||||||
|
ModalSizePreset,
|
||||||
|
MODAL_SIZE_PRESETS,
|
||||||
|
resolveModalWidth,
|
||||||
} from "./types/pop-layout";
|
} from "./types/pop-layout";
|
||||||
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff } from "lucide-react";
|
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { useDrag } from "react-dnd";
|
import { useDrag } from "react-dnd";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -112,6 +116,16 @@ interface PopCanvasProps {
|
||||||
onLockLayout?: () => void;
|
onLockLayout?: () => void;
|
||||||
onResetOverride?: (mode: GridMode) => void;
|
onResetOverride?: (mode: GridMode) => void;
|
||||||
onChangeGapPreset?: (preset: GapPreset) => void;
|
onChangeGapPreset?: (preset: GapPreset) => void;
|
||||||
|
/** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */
|
||||||
|
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
||||||
|
/** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */
|
||||||
|
previewPageIndex?: number;
|
||||||
|
/** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */
|
||||||
|
activeCanvasId?: string;
|
||||||
|
/** 캔버스 전환 콜백 */
|
||||||
|
onActiveCanvasChange?: (canvasId: string) => void;
|
||||||
|
/** 모달 정의 업데이트 콜백 */
|
||||||
|
onUpdateModal?: (modalId: string, updates: Partial<PopModalDefinition>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -135,7 +149,43 @@ export default function PopCanvas({
|
||||||
onLockLayout,
|
onLockLayout,
|
||||||
onResetOverride,
|
onResetOverride,
|
||||||
onChangeGapPreset,
|
onChangeGapPreset,
|
||||||
|
onRequestResize,
|
||||||
|
previewPageIndex,
|
||||||
|
activeCanvasId = "main",
|
||||||
|
onActiveCanvasChange,
|
||||||
|
onUpdateModal,
|
||||||
}: PopCanvasProps) {
|
}: PopCanvasProps) {
|
||||||
|
// 모달 탭 데이터
|
||||||
|
const modalTabs = useMemo(() => {
|
||||||
|
const tabs: { id: string; label: string }[] = [{ id: "main", label: "메인화면" }];
|
||||||
|
if (layout.modals?.length) {
|
||||||
|
for (const modal of layout.modals) {
|
||||||
|
const numbering = modal.id.replace("modal-", "");
|
||||||
|
tabs.push({ id: modal.id, label: `모달화면 ${numbering}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tabs;
|
||||||
|
}, [layout.modals]);
|
||||||
|
|
||||||
|
// activeCanvasId에 따라 렌더링할 layout 분기
|
||||||
|
const activeLayout = useMemo((): PopLayoutDataV5 => {
|
||||||
|
if (activeCanvasId === "main") return layout;
|
||||||
|
const modal = layout.modals?.find(m => m.id === activeCanvasId);
|
||||||
|
if (!modal) return layout; // fallback
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
gridConfig: modal.gridConfig,
|
||||||
|
components: modal.components,
|
||||||
|
overrides: modal.overrides,
|
||||||
|
};
|
||||||
|
}, [layout, activeCanvasId]);
|
||||||
|
|
||||||
|
// 현재 활성 모달 정의 (모달 캔버스일 때만)
|
||||||
|
const activeModal = useMemo(() => {
|
||||||
|
if (activeCanvasId === "main") return null;
|
||||||
|
return layout.modals?.find(m => m.id === activeCanvasId) || null;
|
||||||
|
}, [layout.modals, activeCanvasId]);
|
||||||
|
|
||||||
// 줌 상태
|
// 줌 상태
|
||||||
const [canvasScale, setCanvasScale] = useState(0.8);
|
const [canvasScale, setCanvasScale] = useState(0.8);
|
||||||
|
|
||||||
|
|
@ -162,12 +212,12 @@ export default function PopCanvas({
|
||||||
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
|
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
|
||||||
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
|
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
|
||||||
|
|
||||||
// 숨김 컴포넌트 ID 목록
|
// 숨김 컴포넌트 ID 목록 (activeLayout 기반)
|
||||||
const hiddenComponentIds = layout.overrides?.[currentMode]?.hidden || [];
|
const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || [];
|
||||||
|
|
||||||
// 동적 캔버스 높이 계산 (컴포넌트 배치 기반)
|
// 동적 캔버스 높이 계산 (컴포넌트 배치 기반)
|
||||||
const dynamicCanvasHeight = useMemo(() => {
|
const dynamicCanvasHeight = useMemo(() => {
|
||||||
const visibleComps = Object.values(layout.components).filter(
|
const visibleComps = Object.values(activeLayout.components).filter(
|
||||||
comp => !hiddenComponentIds.includes(comp.id)
|
comp => !hiddenComponentIds.includes(comp.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -186,7 +236,7 @@ export default function PopCanvas({
|
||||||
const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2;
|
const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2;
|
||||||
|
|
||||||
return Math.max(MIN_CANVAS_HEIGHT, height);
|
return Math.max(MIN_CANVAS_HEIGHT, height);
|
||||||
}, [layout.components, layout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]);
|
}, [activeLayout.components, activeLayout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]);
|
||||||
|
|
||||||
// 그리드 라벨 계산 (동적 행 수)
|
// 그리드 라벨 계산 (동적 행 수)
|
||||||
const gridLabels = useMemo(() => {
|
const gridLabels = useMemo(() => {
|
||||||
|
|
@ -300,7 +350,7 @@ export default function PopCanvas({
|
||||||
};
|
};
|
||||||
|
|
||||||
// 현재 모드에서의 유효 위치들로 중첩 검사
|
// 현재 모드에서의 유효 위치들로 중첩 검사
|
||||||
const effectivePositions = getAllEffectivePositions(layout, currentMode);
|
const effectivePositions = getAllEffectivePositions(activeLayout, currentMode);
|
||||||
const existingPositions = Array.from(effectivePositions.values());
|
const existingPositions = Array.from(effectivePositions.values());
|
||||||
|
|
||||||
const hasOverlap = existingPositions.some(pos =>
|
const hasOverlap = existingPositions.some(pos =>
|
||||||
|
|
@ -346,7 +396,7 @@ export default function PopCanvas({
|
||||||
const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean };
|
const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean };
|
||||||
|
|
||||||
// 현재 모드에서의 유효 위치들 가져오기
|
// 현재 모드에서의 유효 위치들 가져오기
|
||||||
const effectivePositions = getAllEffectivePositions(layout, currentMode);
|
const effectivePositions = getAllEffectivePositions(activeLayout, currentMode);
|
||||||
|
|
||||||
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
|
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
|
||||||
// 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
|
// 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
|
||||||
|
|
@ -398,42 +448,42 @@ export default function PopCanvas({
|
||||||
canDrop: monitor.canDrop(),
|
canDrop: monitor.canDrop(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
[onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding]
|
[onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, activeLayout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding]
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(canvasRef);
|
drop(canvasRef);
|
||||||
|
|
||||||
// 빈 상태 체크
|
// 빈 상태 체크 (activeLayout 기반)
|
||||||
const isEmpty = Object.keys(layout.components).length === 0;
|
const isEmpty = Object.keys(activeLayout.components).length === 0;
|
||||||
|
|
||||||
// 숨김 처리된 컴포넌트 객체 목록 (hiddenComponentIds는 라인 166에서 정의됨)
|
// 숨김 처리된 컴포넌트 객체 목록
|
||||||
const hiddenComponents = useMemo(() => {
|
const hiddenComponents = useMemo(() => {
|
||||||
return hiddenComponentIds
|
return hiddenComponentIds
|
||||||
.map(id => layout.components[id])
|
.map(id => activeLayout.components[id])
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}, [hiddenComponentIds, layout.components]);
|
}, [hiddenComponentIds, activeLayout.components]);
|
||||||
|
|
||||||
// 표시되는 컴포넌트 목록 (숨김 제외)
|
// 표시되는 컴포넌트 목록 (숨김 제외)
|
||||||
const visibleComponents = useMemo(() => {
|
const visibleComponents = useMemo(() => {
|
||||||
return Object.values(layout.components).filter(
|
return Object.values(activeLayout.components).filter(
|
||||||
comp => !hiddenComponentIds.includes(comp.id)
|
comp => !hiddenComponentIds.includes(comp.id)
|
||||||
);
|
);
|
||||||
}, [layout.components, hiddenComponentIds]);
|
}, [activeLayout.components, hiddenComponentIds]);
|
||||||
|
|
||||||
// 검토 필요 컴포넌트 목록
|
// 검토 필요 컴포넌트 목록
|
||||||
const reviewComponents = useMemo(() => {
|
const reviewComponents = useMemo(() => {
|
||||||
return visibleComponents.filter(comp => {
|
return visibleComponents.filter(comp => {
|
||||||
const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id];
|
const hasOverride = !!activeLayout.overrides?.[currentMode]?.positions?.[comp.id];
|
||||||
return needsReview(currentMode, hasOverride);
|
return needsReview(currentMode, hasOverride);
|
||||||
});
|
});
|
||||||
}, [visibleComponents, layout.overrides, currentMode]);
|
}, [visibleComponents, activeLayout.overrides, currentMode]);
|
||||||
|
|
||||||
// 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때)
|
// 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때)
|
||||||
const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0;
|
const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0;
|
||||||
|
|
||||||
// 12칸 모드가 아닐 때만 패널 표시
|
// 12칸 모드가 아닐 때만 패널 표시
|
||||||
// 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시
|
// 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시
|
||||||
const hasGridComponents = Object.keys(layout.components).length > 0;
|
const hasGridComponents = Object.keys(activeLayout.components).length > 0;
|
||||||
const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents);
|
const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents);
|
||||||
const showRightPanel = showReviewPanel || showHiddenPanel;
|
const showRightPanel = showReviewPanel || showHiddenPanel;
|
||||||
|
|
||||||
|
|
@ -573,6 +623,32 @@ export default function PopCanvas({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 탭 바 (모달이 1개 이상 있을 때만 표시) */}
|
||||||
|
{modalTabs.length > 1 && (
|
||||||
|
<div className="flex gap-1 border-b bg-muted/30 px-4 py-1">
|
||||||
|
{modalTabs.map(tab => (
|
||||||
|
<Button
|
||||||
|
key={tab.id}
|
||||||
|
variant={activeCanvasId === tab.id ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onActiveCanvasChange?.(tab.id)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모달 사이즈 설정 패널 (모달 캔버스 활성 시) */}
|
||||||
|
{activeModal && (
|
||||||
|
<ModalSizeSettingsPanel
|
||||||
|
modal={activeModal}
|
||||||
|
currentMode={currentMode}
|
||||||
|
onUpdate={(updates) => onUpdateModal?.(activeModal.id, updates)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 캔버스 영역 */}
|
{/* 캔버스 영역 */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|
@ -677,7 +753,7 @@ export default function PopCanvas({
|
||||||
) : (
|
) : (
|
||||||
// 그리드 렌더러
|
// 그리드 렌더러
|
||||||
<PopRenderer
|
<PopRenderer
|
||||||
layout={layout}
|
layout={activeLayout}
|
||||||
viewportWidth={customWidth}
|
viewportWidth={customWidth}
|
||||||
currentMode={currentMode}
|
currentMode={currentMode}
|
||||||
isDesignMode={true}
|
isDesignMode={true}
|
||||||
|
|
@ -688,8 +764,10 @@ export default function PopCanvas({
|
||||||
onComponentMove={onMoveComponent}
|
onComponentMove={onMoveComponent}
|
||||||
onComponentResize={onResizeComponent}
|
onComponentResize={onResizeComponent}
|
||||||
onComponentResizeEnd={onResizeEnd}
|
onComponentResizeEnd={onResizeEnd}
|
||||||
|
onRequestResize={onRequestResize}
|
||||||
overrideGap={adjustedGap}
|
overrideGap={adjustedGap}
|
||||||
overridePadding={adjustedPadding}
|
overridePadding={adjustedPadding}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -969,3 +1047,278 @@ function HiddenItem({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 모달 사이즈 설정 패널
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const SIZE_PRESET_ORDER: ModalSizePreset[] = ["sm", "md", "lg", "xl", "full"];
|
||||||
|
|
||||||
|
const MODE_LABELS: { mode: GridMode; label: string; icon: typeof Smartphone; width: number }[] = [
|
||||||
|
{ mode: "mobile_portrait", label: "모바일 세로", icon: Smartphone, width: 375 },
|
||||||
|
{ mode: "mobile_landscape", label: "모바일 가로", icon: Smartphone, width: 667 },
|
||||||
|
{ mode: "tablet_portrait", label: "태블릿 세로", icon: Tablet, width: 768 },
|
||||||
|
{ mode: "tablet_landscape", label: "태블릿 가로", icon: Monitor, width: 1024 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ModalSizeSettingsPanel({
|
||||||
|
modal,
|
||||||
|
currentMode,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
modal: PopModalDefinition;
|
||||||
|
currentMode: GridMode;
|
||||||
|
onUpdate: (updates: Partial<PopModalDefinition>) => void;
|
||||||
|
}) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const sizeConfig = modal.sizeConfig || { default: "md" };
|
||||||
|
const usePerMode = !!sizeConfig.modeOverrides && Object.keys(sizeConfig.modeOverrides).length > 0;
|
||||||
|
|
||||||
|
const currentModeInfo = MODE_LABELS.find(m => m.mode === currentMode)!;
|
||||||
|
const currentModeWidth = currentModeInfo.width;
|
||||||
|
const currentModalWidth = resolveModalWidth(
|
||||||
|
{ default: sizeConfig.default, modeOverrides: sizeConfig.modeOverrides as Record<GridMode, ModalSizePreset> | undefined },
|
||||||
|
currentMode,
|
||||||
|
currentModeWidth,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDefaultChange = (preset: ModalSizePreset) => {
|
||||||
|
onUpdate({
|
||||||
|
sizeConfig: {
|
||||||
|
...sizeConfig,
|
||||||
|
default: preset,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePerMode = () => {
|
||||||
|
if (usePerMode) {
|
||||||
|
onUpdate({
|
||||||
|
sizeConfig: {
|
||||||
|
default: sizeConfig.default,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onUpdate({
|
||||||
|
sizeConfig: {
|
||||||
|
...sizeConfig,
|
||||||
|
modeOverrides: {
|
||||||
|
mobile_portrait: sizeConfig.default,
|
||||||
|
mobile_landscape: sizeConfig.default,
|
||||||
|
tablet_portrait: sizeConfig.default,
|
||||||
|
tablet_landscape: sizeConfig.default,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModeChange = (mode: GridMode, preset: ModalSizePreset) => {
|
||||||
|
onUpdate({
|
||||||
|
sizeConfig: {
|
||||||
|
...sizeConfig,
|
||||||
|
modeOverrides: {
|
||||||
|
...sizeConfig.modeOverrides,
|
||||||
|
[mode]: preset,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b bg-muted/20">
|
||||||
|
{/* 헤더 (항상 표시) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isExpanded
|
||||||
|
? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
: <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
<span className="text-xs font-semibold">{modal.title}</span>
|
||||||
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||||||
|
{sizeConfig.default.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{currentModalWidth}px / {currentModeWidth}px
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{modal.id}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 펼침 영역 */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 pb-3 space-y-3">
|
||||||
|
{/* 기본 사이즈 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[11px] text-muted-foreground font-medium">모달 사이즈</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{SIZE_PRESET_ORDER.map(preset => {
|
||||||
|
const info = MODAL_SIZE_PRESETS[preset];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDefaultChange(preset)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 h-8 rounded-md text-xs font-medium transition-colors flex flex-col items-center justify-center gap-0",
|
||||||
|
sizeConfig.default === preset
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-background border hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="leading-none">{preset.toUpperCase()}</span>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[9px] leading-none",
|
||||||
|
sizeConfig.default === preset ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{preset === "full" ? "100%" : `${info.width}px`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모드별 개별 설정 토글 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px] text-muted-foreground">모드별 개별 사이즈</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTogglePerMode}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||||
|
usePerMode ? "bg-primary" : "bg-gray-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||||
|
usePerMode ? "translate-x-4.5" : "translate-x-0.5"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모드별 설정 */}
|
||||||
|
{usePerMode && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{MODE_LABELS.map(({ mode, label, icon: Icon }) => {
|
||||||
|
const modePreset = sizeConfig.modeOverrides?.[mode] ?? sizeConfig.default;
|
||||||
|
return (
|
||||||
|
<div key={mode} className={cn(
|
||||||
|
"flex items-center justify-between rounded-md px-2 py-1",
|
||||||
|
mode === currentMode ? "bg-primary/10 ring-1 ring-primary/30" : ""
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-[11px]">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{SIZE_PRESET_ORDER.map(preset => (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleModeChange(mode, preset)}
|
||||||
|
className={cn(
|
||||||
|
"h-6 px-1.5 rounded text-[10px] font-medium transition-colors",
|
||||||
|
modePreset === preset
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-background border hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{preset.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 캔버스 축소판 미리보기 */}
|
||||||
|
<ModalThumbnailPreview sizeConfig={sizeConfig} currentMode={currentMode} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 모달 사이즈 썸네일 미리보기 (캔버스 축소판 + 모달 오버레이)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function ModalThumbnailPreview({
|
||||||
|
sizeConfig,
|
||||||
|
currentMode,
|
||||||
|
}: {
|
||||||
|
sizeConfig: { default: ModalSizePreset; modeOverrides?: Partial<Record<GridMode, ModalSizePreset>> };
|
||||||
|
currentMode: GridMode;
|
||||||
|
}) {
|
||||||
|
const PREVIEW_WIDTH = 260;
|
||||||
|
const ASPECT_RATIO = 0.65;
|
||||||
|
|
||||||
|
const modeInfo = MODE_LABELS.find(m => m.mode === currentMode)!;
|
||||||
|
const modeWidth = modeInfo.width;
|
||||||
|
const modeHeight = modeWidth * ASPECT_RATIO;
|
||||||
|
|
||||||
|
const scale = PREVIEW_WIDTH / modeWidth;
|
||||||
|
const previewHeight = Math.round(modeHeight * scale);
|
||||||
|
|
||||||
|
const modalWidth = resolveModalWidth(
|
||||||
|
{ default: sizeConfig.default, modeOverrides: sizeConfig.modeOverrides as Record<GridMode, ModalSizePreset> | undefined },
|
||||||
|
currentMode,
|
||||||
|
modeWidth,
|
||||||
|
);
|
||||||
|
const scaledModalWidth = Math.min(Math.round(modalWidth * scale), PREVIEW_WIDTH);
|
||||||
|
const isFull = modalWidth >= modeWidth;
|
||||||
|
const scaledModalHeight = isFull ? previewHeight : Math.round(previewHeight * 0.75);
|
||||||
|
const Icon = modeInfo.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px] text-muted-foreground font-medium">미리보기</span>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
<span>{modeInfo.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative mx-auto rounded-md border bg-gray-100 overflow-hidden"
|
||||||
|
style={{ width: `${PREVIEW_WIDTH}px`, height: `${previewHeight}px` }}
|
||||||
|
>
|
||||||
|
{/* 반투명 배경 오버레이 (모달이 열렸을 때의 딤 효과) */}
|
||||||
|
<div className="absolute inset-0 bg-black/10" />
|
||||||
|
|
||||||
|
{/* 모달 영역 (가운데 정렬, FULL이면 전체 채움) */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute border-2 border-primary/60 bg-primary/15",
|
||||||
|
isFull ? "rounded-none" : "rounded-sm"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: `${scaledModalWidth}px`,
|
||||||
|
height: `${scaledModalHeight}px`,
|
||||||
|
left: `${(PREVIEW_WIDTH - scaledModalWidth) / 2}px`,
|
||||||
|
top: `${(previewHeight - scaledModalHeight) / 2}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute top-1 left-1.5 text-[8px] font-medium text-primary/80 leading-none">
|
||||||
|
모달
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 수치 표시 */}
|
||||||
|
<div className="absolute bottom-1 right-1.5 rounded bg-black/50 px-1.5 py-0.5 text-[9px] text-white">
|
||||||
|
{isFull ? "FULL" : `${modalWidth}px`} / {modeWidth}px
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,15 @@ import {
|
||||||
createEmptyPopLayoutV5,
|
createEmptyPopLayoutV5,
|
||||||
isV5Layout,
|
isV5Layout,
|
||||||
addComponentToV5Layout,
|
addComponentToV5Layout,
|
||||||
|
createComponentDefinitionV5,
|
||||||
GRID_BREAKPOINTS,
|
GRID_BREAKPOINTS,
|
||||||
|
PopModalDefinition,
|
||||||
|
PopDataConnection,
|
||||||
} from "./types/pop-layout";
|
} from "./types/pop-layout";
|
||||||
import { getAllEffectivePositions } from "./utils/gridUtils";
|
import { getAllEffectivePositions } from "./utils/gridUtils";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
import { PopDesignerContext } from "./PopDesignerContext";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Props
|
// Props
|
||||||
|
|
@ -51,6 +55,7 @@ export default function PopDesigner({
|
||||||
onBackToList,
|
onBackToList,
|
||||||
onScreenUpdate,
|
onScreenUpdate,
|
||||||
}: PopDesignerProps) {
|
}: PopDesignerProps) {
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 레이아웃 상태
|
// 레이아웃 상태
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -69,13 +74,24 @@ export default function PopDesigner({
|
||||||
// 선택 상태
|
// 선택 상태
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 대시보드 페이지 미리보기 인덱스 (-1 = 기본 모드)
|
||||||
|
const [previewPageIndex, setPreviewPageIndex] = useState<number>(-1);
|
||||||
|
|
||||||
// 그리드 모드 (4개 프리셋)
|
// 그리드 모드 (4개 프리셋)
|
||||||
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
|
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
|
||||||
|
|
||||||
// 선택된 컴포넌트
|
// 모달 캔버스 활성 상태 ("main" 또는 모달 ID)
|
||||||
const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId
|
const [activeCanvasId, setActiveCanvasId] = useState<string>("main");
|
||||||
? layout.components[selectedComponentId] || null
|
|
||||||
: null;
|
// 선택된 컴포넌트 (activeCanvasId에 따라 메인 또는 모달에서 조회)
|
||||||
|
const selectedComponent: PopComponentDefinitionV5 | null = (() => {
|
||||||
|
if (!selectedComponentId) return null;
|
||||||
|
if (activeCanvasId === "main") {
|
||||||
|
return layout.components[selectedComponentId] || null;
|
||||||
|
}
|
||||||
|
const modal = layout.modals?.find(m => m.id === activeCanvasId);
|
||||||
|
return modal?.components[selectedComponentId] || null;
|
||||||
|
})();
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 히스토리 관리
|
// 히스토리 관리
|
||||||
|
|
@ -206,52 +222,169 @@ export default function PopDesigner({
|
||||||
(type: PopComponentType, position: PopGridPosition) => {
|
(type: PopComponentType, position: PopGridPosition) => {
|
||||||
const componentId = `comp_${idCounter}`;
|
const componentId = `comp_${idCounter}`;
|
||||||
setIdCounter((prev) => prev + 1);
|
setIdCounter((prev) => prev + 1);
|
||||||
|
|
||||||
|
if (activeCanvasId === "main") {
|
||||||
|
// 메인 캔버스
|
||||||
const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
|
const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
|
||||||
setLayout(newLayout);
|
setLayout(newLayout);
|
||||||
saveToHistory(newLayout);
|
saveToHistory(newLayout);
|
||||||
|
} else {
|
||||||
|
// 모달 캔버스
|
||||||
|
setLayout(prev => {
|
||||||
|
const comp = createComponentDefinitionV5(componentId, type, position, `${type} ${idCounter}`);
|
||||||
|
const newLayout = {
|
||||||
|
...prev,
|
||||||
|
modals: (prev.modals || []).map(m => {
|
||||||
|
if (m.id !== activeCanvasId) return m;
|
||||||
|
return { ...m, components: { ...m.components, [componentId]: comp } };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
return newLayout;
|
||||||
|
});
|
||||||
|
}
|
||||||
setSelectedComponentId(componentId);
|
setSelectedComponentId(componentId);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[idCounter, layout, saveToHistory]
|
[idCounter, layout, saveToHistory, activeCanvasId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdateComponent = useCallback(
|
const handleUpdateComponent = useCallback(
|
||||||
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
|
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
|
||||||
const existingComponent = layout.components[componentId];
|
// 함수적 업데이트로 stale closure 방지
|
||||||
if (!existingComponent) return;
|
setLayout((prev) => {
|
||||||
|
if (activeCanvasId === "main") {
|
||||||
|
// 메인 캔버스
|
||||||
|
const existingComponent = prev.components[componentId];
|
||||||
|
if (!existingComponent) return prev;
|
||||||
|
|
||||||
const newLayout = {
|
const newLayout = {
|
||||||
...layout,
|
...prev,
|
||||||
components: {
|
components: {
|
||||||
...layout.components,
|
...prev.components,
|
||||||
[componentId]: {
|
[componentId]: { ...existingComponent, ...updates },
|
||||||
...existingComponent,
|
|
||||||
...updates,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
setLayout(newLayout);
|
|
||||||
saveToHistory(newLayout);
|
saveToHistory(newLayout);
|
||||||
|
return newLayout;
|
||||||
|
} else {
|
||||||
|
// 모달 캔버스
|
||||||
|
const newLayout = {
|
||||||
|
...prev,
|
||||||
|
modals: (prev.modals || []).map(m => {
|
||||||
|
if (m.id !== activeCanvasId) return m;
|
||||||
|
const existing = m.components[componentId];
|
||||||
|
if (!existing) return m;
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
components: {
|
||||||
|
...m.components,
|
||||||
|
[componentId]: { ...existing, ...updates },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
return newLayout;
|
||||||
|
}
|
||||||
|
});
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[layout, saveToHistory]
|
[saveToHistory, activeCanvasId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 연결 CRUD
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const handleAddConnection = useCallback(
|
||||||
|
(conn: Omit<PopDataConnection, "id">) => {
|
||||||
|
setLayout((prev) => {
|
||||||
|
const newId = `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
const newConnection: PopDataConnection = { ...conn, id: newId };
|
||||||
|
const prevConnections = prev.dataFlow?.connections || [];
|
||||||
|
const newLayout: PopLayoutDataV5 = {
|
||||||
|
...prev,
|
||||||
|
dataFlow: {
|
||||||
|
...prev.dataFlow,
|
||||||
|
connections: [...prevConnections, newConnection],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
return newLayout;
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
|
},
|
||||||
|
[saveToHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdateConnection = useCallback(
|
||||||
|
(connectionId: string, conn: Omit<PopDataConnection, "id">) => {
|
||||||
|
setLayout((prev) => {
|
||||||
|
const prevConnections = prev.dataFlow?.connections || [];
|
||||||
|
const newLayout: PopLayoutDataV5 = {
|
||||||
|
...prev,
|
||||||
|
dataFlow: {
|
||||||
|
...prev.dataFlow,
|
||||||
|
connections: prevConnections.map((c) =>
|
||||||
|
c.id === connectionId ? { ...conn, id: connectionId } : c
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
return newLayout;
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
|
},
|
||||||
|
[saveToHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveConnection = useCallback(
|
||||||
|
(connectionId: string) => {
|
||||||
|
setLayout((prev) => {
|
||||||
|
const prevConnections = prev.dataFlow?.connections || [];
|
||||||
|
const newLayout: PopLayoutDataV5 = {
|
||||||
|
...prev,
|
||||||
|
dataFlow: {
|
||||||
|
...prev.dataFlow,
|
||||||
|
connections: prevConnections.filter((c) => c.id !== connectionId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
return newLayout;
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
|
},
|
||||||
|
[saveToHistory]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteComponent = useCallback(
|
const handleDeleteComponent = useCallback(
|
||||||
(componentId: string) => {
|
(componentId: string) => {
|
||||||
const newComponents = { ...layout.components };
|
setLayout(prev => {
|
||||||
|
if (activeCanvasId === "main") {
|
||||||
|
const newComponents = { ...prev.components };
|
||||||
delete newComponents[componentId];
|
delete newComponents[componentId];
|
||||||
|
const newLayout = { ...prev, components: newComponents };
|
||||||
const newLayout = {
|
|
||||||
...layout,
|
|
||||||
components: newComponents,
|
|
||||||
};
|
|
||||||
setLayout(newLayout);
|
|
||||||
saveToHistory(newLayout);
|
saveToHistory(newLayout);
|
||||||
|
return newLayout;
|
||||||
|
} else {
|
||||||
|
const newLayout = {
|
||||||
|
...prev,
|
||||||
|
modals: (prev.modals || []).map(m => {
|
||||||
|
if (m.id !== activeCanvasId) return m;
|
||||||
|
const newComps = { ...m.components };
|
||||||
|
delete newComps[componentId];
|
||||||
|
return { ...m, components: newComps };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
return newLayout;
|
||||||
|
}
|
||||||
|
});
|
||||||
setSelectedComponentId(null);
|
setSelectedComponentId(null);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[layout, saveToHistory]
|
[saveToHistory, activeCanvasId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMoveComponent = useCallback(
|
const handleMoveComponent = useCallback(
|
||||||
|
|
@ -357,6 +490,56 @@ export default function PopDesigner({
|
||||||
[layout, saveToHistory]
|
[layout, saveToHistory]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 컴포넌트가 자신의 rowSpan/colSpan을 동적으로 변경 요청 (CardList 확장 등)
|
||||||
|
const handleRequestResize = useCallback(
|
||||||
|
(componentId: string, newRowSpan: number, newColSpan?: number) => {
|
||||||
|
const component = layout.components[componentId];
|
||||||
|
if (!component) return;
|
||||||
|
|
||||||
|
const newPosition = {
|
||||||
|
...component.position,
|
||||||
|
rowSpan: newRowSpan,
|
||||||
|
...(newColSpan !== undefined ? { colSpan: newColSpan } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정
|
||||||
|
if (currentMode === "tablet_landscape") {
|
||||||
|
const newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: {
|
||||||
|
...layout.components,
|
||||||
|
[componentId]: {
|
||||||
|
...component,
|
||||||
|
position: newPosition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setLayout(newLayout);
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
setHasChanges(true);
|
||||||
|
} else {
|
||||||
|
// 다른 모드인 경우: 오버라이드에 저장
|
||||||
|
const newLayout = {
|
||||||
|
...layout,
|
||||||
|
overrides: {
|
||||||
|
...layout.overrides,
|
||||||
|
[currentMode]: {
|
||||||
|
...layout.overrides?.[currentMode],
|
||||||
|
positions: {
|
||||||
|
...layout.overrides?.[currentMode]?.positions,
|
||||||
|
[componentId]: newPosition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setLayout(newLayout);
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
setHasChanges(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[layout, currentMode, saveToHistory]
|
||||||
|
);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Gap 프리셋 관리
|
// Gap 프리셋 관리
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -471,6 +654,59 @@ export default function PopDesigner({
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
}, [layout, currentMode, saveToHistory]);
|
}, [layout, currentMode, saveToHistory]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 모달 캔버스 관리
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/** 모달 ID 자동 생성 (계층적: modal-1, modal-1-1, modal-1-1-1) */
|
||||||
|
const generateModalId = useCallback((parentCanvasId: string): string => {
|
||||||
|
const modals = layout.modals || [];
|
||||||
|
if (parentCanvasId === "main") {
|
||||||
|
const rootModals = modals.filter(m => !m.parentId);
|
||||||
|
return `modal-${rootModals.length + 1}`;
|
||||||
|
}
|
||||||
|
const prefix = parentCanvasId.replace("modal-", "");
|
||||||
|
const children = modals.filter(m => m.parentId === parentCanvasId);
|
||||||
|
return `modal-${prefix}-${children.length + 1}`;
|
||||||
|
}, [layout.modals]);
|
||||||
|
|
||||||
|
/** 모달 캔버스 생성하고 해당 탭으로 전환 */
|
||||||
|
const createModalCanvas = useCallback((buttonComponentId: string, title: string): string => {
|
||||||
|
const modalId = generateModalId(activeCanvasId);
|
||||||
|
const newModal: PopModalDefinition = {
|
||||||
|
id: modalId,
|
||||||
|
parentId: activeCanvasId === "main" ? undefined : activeCanvasId,
|
||||||
|
title: title || "새 모달",
|
||||||
|
sourceButtonId: buttonComponentId,
|
||||||
|
gridConfig: { ...layout.gridConfig },
|
||||||
|
components: {},
|
||||||
|
};
|
||||||
|
setLayout(prev => ({
|
||||||
|
...prev,
|
||||||
|
modals: [...(prev.modals || []), newModal],
|
||||||
|
}));
|
||||||
|
setHasChanges(true);
|
||||||
|
setActiveCanvasId(modalId);
|
||||||
|
return modalId;
|
||||||
|
}, [generateModalId, activeCanvasId, layout.gridConfig]);
|
||||||
|
|
||||||
|
/** 모달 정의 업데이트 (제목, sizeConfig 등) */
|
||||||
|
const handleUpdateModal = useCallback((modalId: string, updates: Partial<PopModalDefinition>) => {
|
||||||
|
setLayout(prev => ({
|
||||||
|
...prev,
|
||||||
|
modals: (prev.modals || []).map(m =>
|
||||||
|
m.id === modalId ? { ...m, ...updates } : m
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
setHasChanges(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 특정 캔버스로 전환 */
|
||||||
|
const navigateToCanvas = useCallback((canvasId: string) => {
|
||||||
|
setActiveCanvasId(canvasId);
|
||||||
|
setSelectedComponentId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 뒤로가기
|
// 뒤로가기
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -553,6 +789,14 @@ export default function PopDesigner({
|
||||||
// 렌더링
|
// 렌더링
|
||||||
// ========================================
|
// ========================================
|
||||||
return (
|
return (
|
||||||
|
<PopDesignerContext.Provider
|
||||||
|
value={{
|
||||||
|
createModalCanvas,
|
||||||
|
navigateToCanvas,
|
||||||
|
activeCanvasId,
|
||||||
|
selectedComponentId,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
|
|
@ -637,6 +881,11 @@ export default function PopDesigner({
|
||||||
onLockLayout={handleLockLayout}
|
onLockLayout={handleLockLayout}
|
||||||
onResetOverride={handleResetOverride}
|
onResetOverride={handleResetOverride}
|
||||||
onChangeGapPreset={handleChangeGapPreset}
|
onChangeGapPreset={handleChangeGapPreset}
|
||||||
|
onRequestResize={handleRequestResize}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
|
activeCanvasId={activeCanvasId}
|
||||||
|
onActiveCanvasChange={navigateToCanvas}
|
||||||
|
onUpdateModal={handleUpdateModal}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|
@ -652,10 +901,21 @@ export default function PopDesigner({
|
||||||
? (updates) => handleUpdateComponent(selectedComponentId, updates)
|
? (updates) => handleUpdateComponent(selectedComponentId, updates)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
allComponents={Object.values(layout.components)}
|
||||||
|
onSelectComponent={setSelectedComponentId}
|
||||||
|
selectedComponentId={selectedComponentId}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
|
onPreviewPage={setPreviewPageIndex}
|
||||||
|
connections={layout.dataFlow?.connections || []}
|
||||||
|
onAddConnection={handleAddConnection}
|
||||||
|
onUpdateConnection={handleUpdateConnection}
|
||||||
|
onRemoveConnection={handleRemoveConnection}
|
||||||
|
modals={layout.modals}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</div>
|
</div>
|
||||||
</DndProvider>
|
</DndProvider>
|
||||||
|
</PopDesignerContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* PopDesignerContext - 디자이너 전역 컨텍스트
|
||||||
|
*
|
||||||
|
* ConfigPanel 등 하위 컴포넌트에서 디자이너 레벨 동작을 트리거하기 위한 컨텍스트.
|
||||||
|
* 예: pop-button 설정 패널에서 "모달 캔버스 생성" 버튼 클릭 시
|
||||||
|
* 디자이너의 activeCanvasId를 변경하고 새 모달을 생성.
|
||||||
|
*
|
||||||
|
* Provider: PopDesigner.tsx
|
||||||
|
* Consumer: pop-button ConfigPanel (ModalCanvasButton)
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export interface PopDesignerContextType {
|
||||||
|
/** 새 모달 캔버스 생성하고 해당 탭으로 전환 (모달 ID 반환) */
|
||||||
|
createModalCanvas: (buttonComponentId: string, title: string) => string;
|
||||||
|
/** 특정 캔버스(메인 또는 모달)로 전환 */
|
||||||
|
navigateToCanvas: (canvasId: string) => void;
|
||||||
|
/** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */
|
||||||
|
activeCanvasId: string;
|
||||||
|
/** 현재 선택된 컴포넌트 ID */
|
||||||
|
selectedComponentId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PopDesignerContext = createContext<PopDesignerContextType | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디자이너 컨텍스트 사용 훅
|
||||||
|
* 뷰어 모드에서는 null 반환 (Provider 없음)
|
||||||
|
*/
|
||||||
|
export function usePopDesignerContext(): PopDesignerContextType | null {
|
||||||
|
return useContext(PopDesignerContext);
|
||||||
|
}
|
||||||
|
|
@ -7,21 +7,23 @@ import {
|
||||||
PopGridPosition,
|
PopGridPosition,
|
||||||
GridMode,
|
GridMode,
|
||||||
GRID_BREAKPOINTS,
|
GRID_BREAKPOINTS,
|
||||||
PopComponentType,
|
|
||||||
} from "../types/pop-layout";
|
} from "../types/pop-layout";
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
Database,
|
Link2,
|
||||||
Eye,
|
Eye,
|
||||||
Grid3x3,
|
Grid3x3,
|
||||||
MoveHorizontal,
|
MoveHorizontal,
|
||||||
MoveVertical,
|
MoveVertical,
|
||||||
|
Layers,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||||
|
import { PopDataConnection, PopModalDefinition } from "../types/pop-layout";
|
||||||
|
import ConnectionEditor from "./ConnectionEditor";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Props
|
// Props
|
||||||
|
|
@ -36,14 +38,41 @@ interface ComponentEditorPanelProps {
|
||||||
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||||
/** 추가 className */
|
/** 추가 className */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 그리드에 배치된 모든 컴포넌트 */
|
||||||
|
allComponents?: PopComponentDefinitionV5[];
|
||||||
|
/** 컴포넌트 선택 콜백 */
|
||||||
|
onSelectComponent?: (componentId: string) => void;
|
||||||
|
/** 현재 선택된 컴포넌트 ID */
|
||||||
|
selectedComponentId?: string | null;
|
||||||
|
/** 대시보드 페이지 미리보기 인덱스 */
|
||||||
|
previewPageIndex?: number;
|
||||||
|
/** 페이지 미리보기 요청 콜백 */
|
||||||
|
onPreviewPage?: (pageIndex: number) => void;
|
||||||
|
/** 데이터 흐름 연결 목록 */
|
||||||
|
connections?: PopDataConnection[];
|
||||||
|
/** 연결 추가 콜백 */
|
||||||
|
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||||
|
/** 연결 수정 콜백 */
|
||||||
|
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||||
|
/** 연결 삭제 콜백 */
|
||||||
|
onRemoveConnection?: (connectionId: string) => void;
|
||||||
|
/** 모달 정의 목록 (설정 패널에 전달) */
|
||||||
|
modals?: PopModalDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 컴포넌트 타입별 라벨
|
// 컴포넌트 타입별 라벨
|
||||||
// ========================================
|
// ========================================
|
||||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
"pop-sample": "샘플",
|
||||||
|
"pop-text": "텍스트",
|
||||||
|
"pop-icon": "아이콘",
|
||||||
|
"pop-dashboard": "대시보드",
|
||||||
|
"pop-card-list": "카드 목록",
|
||||||
"pop-field": "필드",
|
"pop-field": "필드",
|
||||||
"pop-button": "버튼",
|
"pop-button": "버튼",
|
||||||
|
"pop-string-list": "리스트 목록",
|
||||||
|
"pop-search": "검색",
|
||||||
"pop-list": "리스트",
|
"pop-list": "리스트",
|
||||||
"pop-indicator": "인디케이터",
|
"pop-indicator": "인디케이터",
|
||||||
"pop-scanner": "스캐너",
|
"pop-scanner": "스캐너",
|
||||||
|
|
@ -61,6 +90,16 @@ export default function ComponentEditorPanel({
|
||||||
currentMode,
|
currentMode,
|
||||||
onUpdateComponent,
|
onUpdateComponent,
|
||||||
className,
|
className,
|
||||||
|
allComponents,
|
||||||
|
onSelectComponent,
|
||||||
|
selectedComponentId,
|
||||||
|
previewPageIndex,
|
||||||
|
onPreviewPage,
|
||||||
|
connections,
|
||||||
|
onAddConnection,
|
||||||
|
onUpdateConnection,
|
||||||
|
onRemoveConnection,
|
||||||
|
modals,
|
||||||
}: ComponentEditorPanelProps) {
|
}: ComponentEditorPanelProps) {
|
||||||
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
||||||
|
|
||||||
|
|
@ -97,8 +136,8 @@ export default function ComponentEditorPanel({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 탭 */}
|
{/* 탭 */}
|
||||||
<Tabs defaultValue="position" className="flex flex-1 flex-col">
|
<Tabs defaultValue="position" className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
|
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2 flex-shrink-0">
|
||||||
<TabsTrigger value="position" className="gap-1 text-xs">
|
<TabsTrigger value="position" className="gap-1 text-xs">
|
||||||
<Grid3x3 className="h-3 w-3" />
|
<Grid3x3 className="h-3 w-3" />
|
||||||
위치
|
위치
|
||||||
|
|
@ -111,14 +150,51 @@ export default function ComponentEditorPanel({
|
||||||
<Eye className="h-3 w-3" />
|
<Eye className="h-3 w-3" />
|
||||||
표시
|
표시
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="data" className="gap-1 text-xs">
|
<TabsTrigger value="connection" className="gap-1 text-xs">
|
||||||
<Database className="h-3 w-3" />
|
<Link2 className="h-3 w-3" />
|
||||||
데이터
|
연결
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* 위치 탭 */}
|
{/* 위치 탭 */}
|
||||||
<TabsContent value="position" className="flex-1 overflow-auto p-4">
|
<TabsContent value="position" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
||||||
|
{/* 배치된 컴포넌트 목록 */}
|
||||||
|
{allComponents && allComponents.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-1 mb-2">
|
||||||
|
<Layers className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
배치된 컴포넌트 ({allComponents.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{allComponents.map((comp) => {
|
||||||
|
const label = comp.label
|
||||||
|
|| COMPONENT_TYPE_LABELS[comp.type]
|
||||||
|
|| comp.type;
|
||||||
|
const isActive = comp.id === selectedComponentId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={comp.id}
|
||||||
|
onClick={() => onSelectComponent?.(comp.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "hover:bg-gray-100 text-gray-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate flex-1">{label}</span>
|
||||||
|
<span className="shrink-0 text-[10px] text-gray-400">
|
||||||
|
({comp.position.col},{comp.position.row})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-gray-200 mt-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<PositionForm
|
<PositionForm
|
||||||
component={component}
|
component={component}
|
||||||
currentMode={currentMode}
|
currentMode={currentMode}
|
||||||
|
|
@ -129,24 +205,37 @@ export default function ComponentEditorPanel({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 설정 탭 */}
|
{/* 설정 탭 */}
|
||||||
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
<TabsContent value="settings" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
||||||
<ComponentSettingsForm
|
<ComponentSettingsForm
|
||||||
component={component}
|
component={component}
|
||||||
onUpdate={onUpdateComponent}
|
onUpdate={onUpdateComponent}
|
||||||
|
currentMode={currentMode}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
|
onPreviewPage={onPreviewPage}
|
||||||
|
modals={modals}
|
||||||
|
allComponents={allComponents}
|
||||||
|
connections={connections}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 표시 탭 */}
|
{/* 표시 탭 */}
|
||||||
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
|
<TabsContent value="visibility" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
||||||
<VisibilityForm
|
<VisibilityForm
|
||||||
component={component}
|
component={component}
|
||||||
onUpdate={onUpdateComponent}
|
onUpdate={onUpdateComponent}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 데이터 탭 */}
|
{/* 연결 탭 */}
|
||||||
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
<TabsContent value="connection" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
||||||
<DataBindingPlaceholder />
|
<ConnectionEditor
|
||||||
|
component={component}
|
||||||
|
allComponents={allComponents || []}
|
||||||
|
connections={connections || []}
|
||||||
|
onAddConnection={onAddConnection}
|
||||||
|
onUpdateConnection={onUpdateConnection}
|
||||||
|
onRemoveConnection={onRemoveConnection}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -313,9 +402,15 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
|
||||||
interface ComponentSettingsFormProps {
|
interface ComponentSettingsFormProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinitionV5;
|
||||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||||
|
currentMode?: GridMode;
|
||||||
|
previewPageIndex?: number;
|
||||||
|
onPreviewPage?: (pageIndex: number) => void;
|
||||||
|
modals?: PopModalDefinition[];
|
||||||
|
allComponents?: PopComponentDefinitionV5[];
|
||||||
|
connections?: PopDataConnection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) {
|
function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals, allComponents, connections }: ComponentSettingsFormProps) {
|
||||||
// PopComponentRegistry에서 configPanel 가져오기
|
// PopComponentRegistry에서 configPanel 가져오기
|
||||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||||
const ConfigPanel = registeredComp?.configPanel;
|
const ConfigPanel = registeredComp?.configPanel;
|
||||||
|
|
@ -344,6 +439,14 @@ function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormPro
|
||||||
<ConfigPanel
|
<ConfigPanel
|
||||||
config={component.config || {}}
|
config={component.config || {}}
|
||||||
onUpdate={handleConfigUpdate}
|
onUpdate={handleConfigUpdate}
|
||||||
|
currentMode={currentMode}
|
||||||
|
currentColSpan={component.position.colSpan}
|
||||||
|
onPreviewPage={onPreviewPage}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
|
modals={modals}
|
||||||
|
allComponents={allComponents}
|
||||||
|
connections={connections}
|
||||||
|
componentId={component.id}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg bg-gray-50 p-3">
|
<div className="rounded-lg bg-gray-50 p-3">
|
||||||
|
|
@ -419,20 +522,4 @@ function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 데이터 바인딩 플레이스홀더
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function DataBindingPlaceholder() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-lg bg-gray-50 p-4 text-center">
|
|
||||||
<Database className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
|
||||||
<p className="text-sm font-medium text-gray-700">데이터 바인딩</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Phase 4에서 구현 예정
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useDrag } from "react-dnd";
|
import { useDrag } from "react-dnd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PopComponentType } from "../types/pop-layout";
|
import { PopComponentType } from "../types/pop-layout";
|
||||||
import { Square, FileText } from "lucide-react";
|
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search } from "lucide-react";
|
||||||
import { DND_ITEM_TYPES } from "../constants";
|
import { DND_ITEM_TYPES } from "../constants";
|
||||||
|
|
||||||
// 컴포넌트 정의
|
// 컴포넌트 정의
|
||||||
|
|
@ -27,6 +27,42 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
description: "텍스트, 시간, 이미지 표시",
|
description: "텍스트, 시간, 이미지 표시",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "pop-icon",
|
||||||
|
label: "아이콘",
|
||||||
|
icon: MousePointer,
|
||||||
|
description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-dashboard",
|
||||||
|
label: "대시보드",
|
||||||
|
icon: BarChart3,
|
||||||
|
description: "KPI, 차트, 게이지, 통계 집계",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-card-list",
|
||||||
|
label: "카드 목록",
|
||||||
|
icon: LayoutGrid,
|
||||||
|
description: "테이블 데이터를 카드 형태로 표시",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-button",
|
||||||
|
label: "버튼",
|
||||||
|
icon: MousePointerClick,
|
||||||
|
description: "액션 버튼 (저장/삭제/API/모달)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-string-list",
|
||||||
|
label: "리스트 목록",
|
||||||
|
icon: List,
|
||||||
|
description: "테이블 데이터를 리스트/카드로 표시",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-search",
|
||||||
|
label: "검색",
|
||||||
|
icon: Search,
|
||||||
|
description: "조건 입력 (텍스트/날짜/선택/모달)",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,655 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
PopComponentDefinitionV5,
|
||||||
|
PopDataConnection,
|
||||||
|
} from "../types/pop-layout";
|
||||||
|
import {
|
||||||
|
PopComponentRegistry,
|
||||||
|
type ComponentConnectionMeta,
|
||||||
|
} from "@/lib/registry/PopComponentRegistry";
|
||||||
|
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ConnectionEditorProps {
|
||||||
|
component: PopComponentDefinitionV5;
|
||||||
|
allComponents: PopComponentDefinitionV5[];
|
||||||
|
connections: PopDataConnection[];
|
||||||
|
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||||
|
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||||
|
onRemoveConnection?: (connectionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ConnectionEditor
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export default function ConnectionEditor({
|
||||||
|
component,
|
||||||
|
allComponents,
|
||||||
|
connections,
|
||||||
|
onAddConnection,
|
||||||
|
onUpdateConnection,
|
||||||
|
onRemoveConnection,
|
||||||
|
}: ConnectionEditorProps) {
|
||||||
|
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||||
|
const meta = registeredComp?.connectionMeta;
|
||||||
|
|
||||||
|
const outgoing = connections.filter(
|
||||||
|
(c) => c.sourceComponent === component.id
|
||||||
|
);
|
||||||
|
const incoming = connections.filter(
|
||||||
|
(c) => c.targetComponent === component.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasSendable = meta?.sendable && meta.sendable.length > 0;
|
||||||
|
const hasReceivable = meta?.receivable && meta.receivable.length > 0;
|
||||||
|
|
||||||
|
if (!hasSendable && !hasReceivable) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg bg-gray-50 p-4 text-center">
|
||||||
|
<Link2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-gray-700">연결 없음</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
이 컴포넌트는 다른 컴포넌트와 연결할 수 없습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{hasSendable && (
|
||||||
|
<SendSection
|
||||||
|
component={component}
|
||||||
|
meta={meta!}
|
||||||
|
allComponents={allComponents}
|
||||||
|
outgoing={outgoing}
|
||||||
|
onAddConnection={onAddConnection}
|
||||||
|
onUpdateConnection={onUpdateConnection}
|
||||||
|
onRemoveConnection={onRemoveConnection}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasReceivable && (
|
||||||
|
<ReceiveSection
|
||||||
|
component={component}
|
||||||
|
meta={meta!}
|
||||||
|
allComponents={allComponents}
|
||||||
|
incoming={incoming}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 대상 컴포넌트에서 정보 추출
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/** 화면에 표시 중인 컬럼만 추출 */
|
||||||
|
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
|
||||||
|
if (!comp?.config) return [];
|
||||||
|
const cfg = comp.config as Record<string, unknown>;
|
||||||
|
const cols: string[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(cfg.listColumns)) {
|
||||||
|
(cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => {
|
||||||
|
if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(cfg.selectedColumns)) {
|
||||||
|
(cfg.selectedColumns as string[]).forEach((c) => {
|
||||||
|
if (!cols.includes(c)) cols.push(c);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 대상 컴포넌트의 데이터소스 테이블명 추출 */
|
||||||
|
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
|
||||||
|
if (!comp?.config) return "";
|
||||||
|
const cfg = comp.config as Record<string, unknown>;
|
||||||
|
const ds = cfg.dataSource as { tableName?: string } | undefined;
|
||||||
|
return ds?.tableName || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 보내기 섹션
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface SendSectionProps {
|
||||||
|
component: PopComponentDefinitionV5;
|
||||||
|
meta: ComponentConnectionMeta;
|
||||||
|
allComponents: PopComponentDefinitionV5[];
|
||||||
|
outgoing: PopDataConnection[];
|
||||||
|
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||||
|
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||||
|
onRemoveConnection?: (connectionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendSection({
|
||||||
|
component,
|
||||||
|
meta,
|
||||||
|
allComponents,
|
||||||
|
outgoing,
|
||||||
|
onAddConnection,
|
||||||
|
onUpdateConnection,
|
||||||
|
onRemoveConnection,
|
||||||
|
}: SendSectionProps) {
|
||||||
|
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||||
|
<ArrowRight className="h-3 w-3 text-blue-500" />
|
||||||
|
이때 (보내기)
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{/* 기존 연결 목록 */}
|
||||||
|
{outgoing.map((conn) => (
|
||||||
|
<div key={conn.id}>
|
||||||
|
{editingId === conn.id ? (
|
||||||
|
<ConnectionForm
|
||||||
|
component={component}
|
||||||
|
meta={meta}
|
||||||
|
allComponents={allComponents}
|
||||||
|
initial={conn}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
onUpdateConnection?.(conn.id, data);
|
||||||
|
setEditingId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
submitLabel="수정"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 rounded border bg-blue-50/50 px-3 py-2">
|
||||||
|
<span className="flex-1 truncate text-xs">
|
||||||
|
{conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingId(conn.id)}
|
||||||
|
className="shrink-0 p-0.5 text-muted-foreground hover:text-primary"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
{onRemoveConnection && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveConnection(conn.id)}
|
||||||
|
className="shrink-0 p-0.5 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 새 연결 추가 */}
|
||||||
|
<ConnectionForm
|
||||||
|
component={component}
|
||||||
|
meta={meta}
|
||||||
|
allComponents={allComponents}
|
||||||
|
onSubmit={(data) => onAddConnection?.(data)}
|
||||||
|
submitLabel="연결 추가"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 연결 폼 (추가/수정 공용)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ConnectionFormProps {
|
||||||
|
component: PopComponentDefinitionV5;
|
||||||
|
meta: ComponentConnectionMeta;
|
||||||
|
allComponents: PopComponentDefinitionV5[];
|
||||||
|
initial?: PopDataConnection;
|
||||||
|
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
submitLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionForm({
|
||||||
|
component,
|
||||||
|
meta,
|
||||||
|
allComponents,
|
||||||
|
initial,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
submitLabel,
|
||||||
|
}: ConnectionFormProps) {
|
||||||
|
const [selectedOutput, setSelectedOutput] = React.useState(
|
||||||
|
initial?.sourceOutput || meta.sendable[0]?.key || ""
|
||||||
|
);
|
||||||
|
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
||||||
|
initial?.targetComponent || ""
|
||||||
|
);
|
||||||
|
const [selectedTargetInput, setSelectedTargetInput] = React.useState(
|
||||||
|
initial?.targetInput || ""
|
||||||
|
);
|
||||||
|
const [filterColumns, setFilterColumns] = React.useState<string[]>(
|
||||||
|
initial?.filterConfig?.targetColumns ||
|
||||||
|
(initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : [])
|
||||||
|
);
|
||||||
|
const [filterMode, setFilterMode] = React.useState<
|
||||||
|
"equals" | "contains" | "starts_with" | "range"
|
||||||
|
>(initial?.filterConfig?.filterMode || "contains");
|
||||||
|
|
||||||
|
const targetCandidates = allComponents.filter((c) => {
|
||||||
|
if (c.id === component.id) return false;
|
||||||
|
const reg = PopComponentRegistry.getComponent(c.type);
|
||||||
|
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetComp = selectedTargetId
|
||||||
|
? allComponents.find((c) => c.id === selectedTargetId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const targetMeta = targetComp
|
||||||
|
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 보내는 값 + 받는 컴포넌트가 결정되면 받는 방식 자동 매칭
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!selectedOutput || !targetMeta?.receivable?.length) return;
|
||||||
|
// 이미 선택된 값이 있으면 건드리지 않음
|
||||||
|
if (selectedTargetInput) return;
|
||||||
|
|
||||||
|
const receivables = targetMeta.receivable;
|
||||||
|
// 1) 같은 key가 있으면 자동 매칭
|
||||||
|
const exactMatch = receivables.find((r) => r.key === selectedOutput);
|
||||||
|
if (exactMatch) {
|
||||||
|
setSelectedTargetInput(exactMatch.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 2) receivable이 1개뿐이면 자동 선택
|
||||||
|
if (receivables.length === 1) {
|
||||||
|
setSelectedTargetInput(receivables[0].key);
|
||||||
|
}
|
||||||
|
}, [selectedOutput, targetMeta, selectedTargetInput]);
|
||||||
|
|
||||||
|
// 화면에 표시 중인 컬럼
|
||||||
|
const displayColumns = React.useMemo(
|
||||||
|
() => extractDisplayColumns(targetComp || undefined),
|
||||||
|
[targetComp]
|
||||||
|
);
|
||||||
|
|
||||||
|
// DB 테이블 전체 컬럼 (비동기 조회)
|
||||||
|
const tableName = React.useMemo(
|
||||||
|
() => extractTableName(targetComp || undefined),
|
||||||
|
[targetComp]
|
||||||
|
);
|
||||||
|
const [allDbColumns, setAllDbColumns] = React.useState<string[]>([]);
|
||||||
|
const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!tableName) {
|
||||||
|
setAllDbColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setDbColumnsLoading(true);
|
||||||
|
getTableColumns(tableName).then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data?.columns) {
|
||||||
|
setAllDbColumns(res.data.columns.map((c) => c.columnName));
|
||||||
|
} else {
|
||||||
|
setAllDbColumns([]);
|
||||||
|
}
|
||||||
|
setDbColumnsLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [tableName]);
|
||||||
|
|
||||||
|
// 표시 컬럼과 데이터 전용 컬럼 분리
|
||||||
|
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
|
||||||
|
const dataOnlyColumns = React.useMemo(
|
||||||
|
() => allDbColumns.filter((c) => !displaySet.has(c)),
|
||||||
|
[allDbColumns, displaySet]
|
||||||
|
);
|
||||||
|
const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0;
|
||||||
|
|
||||||
|
const toggleColumn = (col: string) => {
|
||||||
|
setFilterColumns((prev) =>
|
||||||
|
prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
|
||||||
|
|
||||||
|
const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput);
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
sourceComponent: component.id,
|
||||||
|
sourceField: "",
|
||||||
|
sourceOutput: selectedOutput,
|
||||||
|
targetComponent: selectedTargetId,
|
||||||
|
targetField: "",
|
||||||
|
targetInput: selectedTargetInput,
|
||||||
|
filterConfig:
|
||||||
|
!isEvent && filterColumns.length > 0
|
||||||
|
? {
|
||||||
|
targetColumn: filterColumns[0],
|
||||||
|
targetColumns: filterColumns,
|
||||||
|
filterMode,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
label: buildConnectionLabel(
|
||||||
|
component,
|
||||||
|
selectedOutput,
|
||||||
|
allComponents.find((c) => c.id === selectedTargetId),
|
||||||
|
selectedTargetInput,
|
||||||
|
filterColumns
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!initial) {
|
||||||
|
setSelectedTargetId("");
|
||||||
|
setSelectedTargetInput("");
|
||||||
|
setFilterColumns([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded border border-dashed p-3">
|
||||||
|
{onCancel && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[10px] font-medium text-muted-foreground">연결 수정</p>
|
||||||
|
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!onCancel && (
|
||||||
|
<p className="text-[10px] font-medium text-muted-foreground">새 연결 추가</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 보내는 값 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">보내는 값</span>
|
||||||
|
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{meta.sendable.map((s) => (
|
||||||
|
<SelectItem key={s.key} value={s.key} className="text-xs">
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 받는 컴포넌트 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">받는 컴포넌트</span>
|
||||||
|
<Select
|
||||||
|
value={selectedTargetId}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setSelectedTargetId(v);
|
||||||
|
setSelectedTargetInput("");
|
||||||
|
setFilterColumns([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컴포넌트 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{targetCandidates.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id} className="text-xs">
|
||||||
|
{c.label || c.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 받는 방식 */}
|
||||||
|
{targetMeta && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">받는 방식</span>
|
||||||
|
<Select value={selectedTargetInput} onValueChange={setSelectedTargetInput}>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{targetMeta.receivable.map((r) => (
|
||||||
|
<SelectItem key={r.key} value={r.key} className="text-xs">
|
||||||
|
{r.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필터 설정: event 타입 연결이면 숨김 */}
|
||||||
|
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
|
||||||
|
<div className="space-y-2 rounded bg-gray-50 p-2">
|
||||||
|
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
||||||
|
|
||||||
|
{dbColumnsLoading ? (
|
||||||
|
<div className="flex items-center gap-2 py-2">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-[10px] text-muted-foreground">컬럼 조회 중...</span>
|
||||||
|
</div>
|
||||||
|
) : hasAnyColumns ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 표시 컬럼 그룹 */}
|
||||||
|
{displayColumns.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-medium text-green-600">화면 표시 컬럼</p>
|
||||||
|
{displayColumns.map((col) => (
|
||||||
|
<div key={col} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`col-${col}-${initial?.id || "new"}`}
|
||||||
|
checked={filterColumns.includes(col)}
|
||||||
|
onCheckedChange={() => toggleColumn(col)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`col-${col}-${initial?.id || "new"}`}
|
||||||
|
className="cursor-pointer text-xs"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 데이터 전용 컬럼 그룹 */}
|
||||||
|
{dataOnlyColumns.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{displayColumns.length > 0 && (
|
||||||
|
<div className="my-1 h-px bg-gray-200" />
|
||||||
|
)}
|
||||||
|
<p className="text-[9px] font-medium text-amber-600">데이터 전용 컬럼</p>
|
||||||
|
{dataOnlyColumns.map((col) => (
|
||||||
|
<div key={col} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`col-${col}-${initial?.id || "new"}`}
|
||||||
|
checked={filterColumns.includes(col)}
|
||||||
|
onCheckedChange={() => toggleColumn(col)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`col-${col}-${initial?.id || "new"}`}
|
||||||
|
className="cursor-pointer text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={filterColumns[0] || ""}
|
||||||
|
onChange={(e) => setFilterColumns(e.target.value ? [e.target.value] : [])}
|
||||||
|
placeholder="컬럼명 입력"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filterColumns.length > 0 && (
|
||||||
|
<p className="text-[10px] text-blue-600">
|
||||||
|
{filterColumns.length}개 컬럼 중 하나라도 일치하면 표시
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필터 방식 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[10px] text-muted-foreground">필터 방식</p>
|
||||||
|
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="contains" className="text-xs">포함</SelectItem>
|
||||||
|
<SelectItem value="equals" className="text-xs">일치</SelectItem>
|
||||||
|
<SelectItem value="starts_with" className="text-xs">시작</SelectItem>
|
||||||
|
<SelectItem value="range" className="text-xs">범위</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 제출 버튼 */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 w-full text-xs"
|
||||||
|
disabled={!selectedOutput || !selectedTargetId || !selectedTargetInput}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 받기 섹션 (읽기 전용)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ReceiveSectionProps {
|
||||||
|
component: PopComponentDefinitionV5;
|
||||||
|
meta: ComponentConnectionMeta;
|
||||||
|
allComponents: PopComponentDefinitionV5[];
|
||||||
|
incoming: PopDataConnection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReceiveSection({
|
||||||
|
component,
|
||||||
|
meta,
|
||||||
|
allComponents,
|
||||||
|
incoming,
|
||||||
|
}: ReceiveSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||||
|
<Unlink2 className="h-3 w-3 text-green-500" />
|
||||||
|
이렇게 (받기)
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{meta.receivable.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.key}
|
||||||
|
className="rounded bg-green-50/50 px-3 py-2 text-xs text-gray-600"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{r.label}</span>
|
||||||
|
{r.description && (
|
||||||
|
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{r.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{incoming.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] text-muted-foreground">연결된 소스</p>
|
||||||
|
{incoming.map((conn) => {
|
||||||
|
const sourceComp = allComponents.find(
|
||||||
|
(c) => c.id === conn.sourceComponent
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={conn.id}
|
||||||
|
className="flex items-center gap-2 rounded border bg-gray-50 px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="truncate">
|
||||||
|
{sourceComp?.label || conn.sourceComponent}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
아직 연결된 소스가 없습니다. 보내는 컴포넌트에서 연결을 설정하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 유틸
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function isEventTypeConnection(
|
||||||
|
sourceMeta: ComponentConnectionMeta | undefined,
|
||||||
|
outputKey: string,
|
||||||
|
targetMeta: ComponentConnectionMeta | null | undefined,
|
||||||
|
inputKey: string,
|
||||||
|
): boolean {
|
||||||
|
const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey);
|
||||||
|
const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey);
|
||||||
|
return sourceItem?.type === "event" || targetItem?.type === "event";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConnectionLabel(
|
||||||
|
source: PopComponentDefinitionV5,
|
||||||
|
_outputKey: string,
|
||||||
|
target: PopComponentDefinitionV5 | undefined,
|
||||||
|
_inputKey: string,
|
||||||
|
columns?: string[]
|
||||||
|
): string {
|
||||||
|
const srcLabel = source.label || source.id;
|
||||||
|
const tgtLabel = target?.label || target?.id || "?";
|
||||||
|
const colInfo = columns && columns.length > 0
|
||||||
|
? ` [${columns.join(", ")}]`
|
||||||
|
: "";
|
||||||
|
return `${srcLabel} -> ${tgtLabel}${colInfo}`;
|
||||||
|
}
|
||||||
|
|
@ -48,12 +48,18 @@ interface PopRendererProps {
|
||||||
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
/** 컴포넌트 크기 조정 완료 (히스토리 저장용) */
|
/** 컴포넌트 크기 조정 완료 (히스토리 저장용) */
|
||||||
onComponentResizeEnd?: (componentId: string) => void;
|
onComponentResizeEnd?: (componentId: string) => void;
|
||||||
|
/** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */
|
||||||
|
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
||||||
/** Gap 오버라이드 (Gap 프리셋 적용된 값) */
|
/** Gap 오버라이드 (Gap 프리셋 적용된 값) */
|
||||||
overrideGap?: number;
|
overrideGap?: number;
|
||||||
/** Padding 오버라이드 (Gap 프리셋 적용된 값) */
|
/** Padding 오버라이드 (Gap 프리셋 적용된 값) */
|
||||||
overridePadding?: number;
|
overridePadding?: number;
|
||||||
/** 추가 className */
|
/** 추가 className */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */
|
||||||
|
currentScreenId?: number;
|
||||||
|
/** 대시보드 페이지 미리보기 인덱스 */
|
||||||
|
previewPageIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -62,6 +68,13 @@ interface PopRendererProps {
|
||||||
|
|
||||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||||
"pop-sample": "샘플",
|
"pop-sample": "샘플",
|
||||||
|
"pop-text": "텍스트",
|
||||||
|
"pop-icon": "아이콘",
|
||||||
|
"pop-dashboard": "대시보드",
|
||||||
|
"pop-card-list": "카드 목록",
|
||||||
|
"pop-button": "버튼",
|
||||||
|
"pop-string-list": "리스트 목록",
|
||||||
|
"pop-search": "검색",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -80,9 +93,12 @@ export default function PopRenderer({
|
||||||
onComponentMove,
|
onComponentMove,
|
||||||
onComponentResize,
|
onComponentResize,
|
||||||
onComponentResizeEnd,
|
onComponentResizeEnd,
|
||||||
|
onRequestResize,
|
||||||
overrideGap,
|
overrideGap,
|
||||||
overridePadding,
|
overridePadding,
|
||||||
className,
|
className,
|
||||||
|
currentScreenId,
|
||||||
|
previewPageIndex,
|
||||||
}: PopRendererProps) {
|
}: PopRendererProps) {
|
||||||
const { gridConfig, components, overrides } = layout;
|
const { gridConfig, components, overrides } = layout;
|
||||||
|
|
||||||
|
|
@ -110,18 +126,27 @@ export default function PopRenderer({
|
||||||
return Math.max(10, maxRowEnd + 3);
|
return Math.max(10, maxRowEnd + 3);
|
||||||
}, [components, overrides, mode, hiddenIds]);
|
}, [components, overrides, mode, hiddenIds]);
|
||||||
|
|
||||||
// CSS Grid 스타일 (행 높이 강제 고정: 셀 크기 = 컴포넌트 크기의 기준)
|
// CSS Grid 스타일
|
||||||
|
// 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집)
|
||||||
|
// 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능)
|
||||||
|
const rowTemplate = isDesignMode
|
||||||
|
? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`
|
||||||
|
: `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`;
|
||||||
|
const autoRowHeight = isDesignMode
|
||||||
|
? `${breakpoint.rowHeight}px`
|
||||||
|
: `minmax(${breakpoint.rowHeight}px, auto)`;
|
||||||
|
|
||||||
const gridStyle = useMemo((): React.CSSProperties => ({
|
const gridStyle = useMemo((): React.CSSProperties => ({
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
|
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
|
||||||
gridTemplateRows: `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`,
|
gridTemplateRows: rowTemplate,
|
||||||
gridAutoRows: `${breakpoint.rowHeight}px`,
|
gridAutoRows: autoRowHeight,
|
||||||
gap: `${finalGap}px`,
|
gap: `${finalGap}px`,
|
||||||
padding: `${finalPadding}px`,
|
padding: `${finalPadding}px`,
|
||||||
minHeight: "100%",
|
minHeight: "100%",
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}), [breakpoint, finalGap, finalPadding, dynamicRowCount]);
|
}), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]);
|
||||||
|
|
||||||
// 그리드 가이드 셀 생성 (동적 행 수)
|
// 그리드 가이드 셀 생성 (동적 행 수)
|
||||||
const gridCells = useMemo(() => {
|
const gridCells = useMemo(() => {
|
||||||
|
|
@ -248,15 +273,17 @@ export default function PopRenderer({
|
||||||
onComponentMove={onComponentMove}
|
onComponentMove={onComponentMove}
|
||||||
onComponentResize={onComponentResize}
|
onComponentResize={onComponentResize}
|
||||||
onComponentResizeEnd={onComponentResizeEnd}
|
onComponentResizeEnd={onComponentResizeEnd}
|
||||||
|
onRequestResize={onRequestResize}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 뷰어 모드: 드래그 없는 일반 렌더링
|
// 뷰어 모드: 드래그 없는 일반 렌더링 (overflow visible로 컨텐츠 확장 허용)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={comp.id}
|
key={comp.id}
|
||||||
className="relative rounded-lg border-2 border-gray-200 bg-white transition-all overflow-hidden z-10"
|
className="relative overflow-hidden rounded-lg border-2 border-gray-200 bg-white transition-all z-10"
|
||||||
style={positionStyle}
|
style={positionStyle}
|
||||||
>
|
>
|
||||||
<ComponentContent
|
<ComponentContent
|
||||||
|
|
@ -264,6 +291,8 @@ export default function PopRenderer({
|
||||||
effectivePosition={position}
|
effectivePosition={position}
|
||||||
isDesignMode={false}
|
isDesignMode={false}
|
||||||
isSelected={false}
|
isSelected={false}
|
||||||
|
onRequestResize={onRequestResize}
|
||||||
|
screenId={currentScreenId ? String(currentScreenId) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -291,6 +320,8 @@ interface DraggableComponentProps {
|
||||||
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
|
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
onComponentResizeEnd?: (componentId: string) => void;
|
onComponentResizeEnd?: (componentId: string) => void;
|
||||||
|
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
||||||
|
previewPageIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraggableComponent({
|
function DraggableComponent({
|
||||||
|
|
@ -308,6 +339,8 @@ function DraggableComponent({
|
||||||
onComponentMove,
|
onComponentMove,
|
||||||
onComponentResize,
|
onComponentResize,
|
||||||
onComponentResizeEnd,
|
onComponentResizeEnd,
|
||||||
|
onRequestResize,
|
||||||
|
previewPageIndex,
|
||||||
}: DraggableComponentProps) {
|
}: DraggableComponentProps) {
|
||||||
const [{ isDragging }, drag] = useDrag(
|
const [{ isDragging }, drag] = useDrag(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -346,6 +379,9 @@ function DraggableComponent({
|
||||||
effectivePosition={position}
|
effectivePosition={position}
|
||||||
isDesignMode={isDesignMode}
|
isDesignMode={isDesignMode}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
|
onRequestResize={onRequestResize}
|
||||||
|
screenId={undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
|
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
|
||||||
|
|
@ -496,36 +532,52 @@ interface ComponentContentProps {
|
||||||
effectivePosition: PopGridPosition;
|
effectivePosition: PopGridPosition;
|
||||||
isDesignMode: boolean;
|
isDesignMode: boolean;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
previewPageIndex?: number;
|
||||||
|
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
||||||
|
/** 화면 ID (이벤트 버스/액션 실행용) */
|
||||||
|
screenId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) {
|
function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex, onRequestResize, screenId }: ComponentContentProps) {
|
||||||
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||||||
|
|
||||||
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
|
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
|
||||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||||
const PreviewComponent = registeredComp?.preview;
|
const PreviewComponent = registeredComp?.preview;
|
||||||
|
|
||||||
// 디자인 모드: 미리보기 컴포넌트 또는 플레이스홀더 표시
|
// 디자인 모드: 실제 컴포넌트 또는 미리보기 표시 (헤더 없음 - 뷰어와 동일하게)
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
return (
|
const ActualComp = registeredComp?.component;
|
||||||
<div className="flex h-full w-full flex-col">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-5 shrink-0 items-center border-b px-2",
|
|
||||||
isSelected ? "bg-primary/10 border-primary" : "bg-gray-50 border-gray-200"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn(
|
|
||||||
"text-[10px] font-medium truncate",
|
|
||||||
isSelected ? "text-primary" : "text-gray-600"
|
|
||||||
)}>
|
|
||||||
{component.label || typeLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */}
|
// 실제 컴포넌트가 등록되어 있으면 실제 데이터로 렌더링 (대시보드 등)
|
||||||
<div className="flex flex-1 items-center justify-center overflow-hidden">
|
if (ActualComp) {
|
||||||
|
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
|
||||||
|
// CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용
|
||||||
|
const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"h-full w-full overflow-hidden",
|
||||||
|
!needsPointerEvents && "pointer-events-none"
|
||||||
|
)}>
|
||||||
|
<ActualComp
|
||||||
|
config={component.config}
|
||||||
|
label={component.label}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
previewPageIndex={previewPageIndex}
|
||||||
|
componentId={component.id}
|
||||||
|
screenId={screenId}
|
||||||
|
currentRowSpan={effectivePosition.rowSpan}
|
||||||
|
currentColSpan={effectivePosition.colSpan}
|
||||||
|
onRequestResize={onRequestResize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미등록: preview 컴포넌트 또는 기본 플레이스홀더
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
||||||
{PreviewComponent ? (
|
{PreviewComponent ? (
|
||||||
<PreviewComponent config={component.config} />
|
<PreviewComponent config={component.config} />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -534,28 +586,45 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 위치 정보 표시 (유효 위치 사용) */}
|
|
||||||
<div className="absolute bottom-1 right-1 text-[9px] text-gray-400 bg-white/80 px-1 rounded">
|
|
||||||
{effectivePosition.col},{effectivePosition.row}
|
|
||||||
({effectivePosition.colSpan}×{effectivePosition.rowSpan})
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 실제 모드: 컴포넌트 렌더링
|
// 실제 모드: 컴포넌트 렌더링 (뷰어 모드에서도 리사이즈 지원)
|
||||||
return renderActualComponent(component);
|
return renderActualComponent(component, effectivePosition, onRequestResize, screenId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 실제 컴포넌트 렌더링 (뷰어 모드)
|
// 실제 컴포넌트 렌더링 (뷰어 모드)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode {
|
function renderActualComponent(
|
||||||
const typeLabel = COMPONENT_TYPE_LABELS[component.type];
|
component: PopComponentDefinitionV5,
|
||||||
|
effectivePosition?: PopGridPosition,
|
||||||
|
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void,
|
||||||
|
screenId?: string,
|
||||||
|
): React.ReactNode {
|
||||||
|
// 레지스트리에서 등록된 실제 컴포넌트 조회
|
||||||
|
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||||
|
const ActualComp = registeredComp?.component;
|
||||||
|
|
||||||
// 샘플 박스 렌더링
|
if (ActualComp) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full overflow-hidden">
|
||||||
|
<ActualComp
|
||||||
|
config={component.config}
|
||||||
|
label={component.label}
|
||||||
|
componentId={component.id}
|
||||||
|
screenId={screenId}
|
||||||
|
currentRowSpan={effectivePosition?.rowSpan}
|
||||||
|
currentColSpan={effectivePosition?.colSpan}
|
||||||
|
onRequestResize={onRequestResize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미등록 컴포넌트: 플레이스홀더 (fallback)
|
||||||
|
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center p-2">
|
<div className="flex h-full w-full items-center justify-center p-2">
|
||||||
<span className="text-xs text-gray-500">{component.label || typeLabel}</span>
|
<span className="text-xs text-gray-500">{component.label || typeLabel}</span>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
/**
|
/**
|
||||||
* POP 컴포넌트 타입
|
* POP 컴포넌트 타입
|
||||||
*/
|
*/
|
||||||
export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트
|
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 흐름 정의
|
* 데이터 흐름 정의
|
||||||
|
|
@ -25,6 +25,16 @@ export interface PopDataConnection {
|
||||||
targetComponent: string;
|
targetComponent: string;
|
||||||
targetField: string;
|
targetField: string;
|
||||||
transformType?: "direct" | "calculate" | "lookup";
|
transformType?: "direct" | "calculate" | "lookup";
|
||||||
|
|
||||||
|
// v2: 연결 시스템 전용
|
||||||
|
sourceOutput?: string;
|
||||||
|
targetInput?: string;
|
||||||
|
filterConfig?: {
|
||||||
|
targetColumn: string;
|
||||||
|
targetColumns?: string[];
|
||||||
|
filterMode: "equals" | "contains" | "starts_with" | "range";
|
||||||
|
};
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -208,6 +218,9 @@ export interface PopLayoutDataV5 {
|
||||||
mobile_landscape?: PopModeOverrideV5;
|
mobile_landscape?: PopModeOverrideV5;
|
||||||
tablet_portrait?: PopModeOverrideV5;
|
tablet_portrait?: PopModeOverrideV5;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성)
|
||||||
|
modals?: PopModalDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -342,6 +355,12 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
|
||||||
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
||||||
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
||||||
"pop-text": { colSpan: 3, rowSpan: 1 },
|
"pop-text": { colSpan: 3, rowSpan: 1 },
|
||||||
|
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
||||||
|
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
||||||
|
"pop-card-list": { colSpan: 4, rowSpan: 3 },
|
||||||
|
"pop-button": { colSpan: 2, rowSpan: 1 },
|
||||||
|
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
||||||
|
"pop-search": { colSpan: 4, rowSpan: 2 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -380,6 +399,95 @@ export const addComponentToV5Layout = (
|
||||||
return newLayout;
|
return newLayout;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 모달 캔버스 정의
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 모달 사이즈 시스템
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/** 모달 사이즈 프리셋 */
|
||||||
|
export type ModalSizePreset = "sm" | "md" | "lg" | "xl" | "full";
|
||||||
|
|
||||||
|
/** 모달 사이즈 프리셋별 픽셀 값 */
|
||||||
|
export const MODAL_SIZE_PRESETS: Record<ModalSizePreset, { width: number; label: string }> = {
|
||||||
|
sm: { width: 400, label: "Small (400px)" },
|
||||||
|
md: { width: 600, label: "Medium (600px)" },
|
||||||
|
lg: { width: 800, label: "Large (800px)" },
|
||||||
|
xl: { width: 1000, label: "XLarge (1000px)" },
|
||||||
|
full: { width: 9999, label: "Full (화면 꽉 참)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 모달 사이즈 설정 (모드별 독립 설정 가능) */
|
||||||
|
export interface ModalSizeConfig {
|
||||||
|
/** 기본 사이즈 (모든 모드 공통, 기본값: "md") */
|
||||||
|
default: ModalSizePreset;
|
||||||
|
/** 모드별 오버라이드 (미설정 시 default 사용) */
|
||||||
|
modeOverrides?: {
|
||||||
|
mobile_portrait?: ModalSizePreset;
|
||||||
|
mobile_landscape?: ModalSizePreset;
|
||||||
|
tablet_portrait?: ModalSizePreset;
|
||||||
|
tablet_landscape?: ModalSizePreset;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주어진 모드에서 모달의 실제 픽셀 너비를 계산
|
||||||
|
* - 뷰포트보다 모달이 크면 자동으로 뷰포트에 맞춤 (full 승격)
|
||||||
|
*/
|
||||||
|
export function resolveModalWidth(
|
||||||
|
sizeConfig: ModalSizeConfig | undefined,
|
||||||
|
mode: GridMode,
|
||||||
|
viewportWidth: number,
|
||||||
|
): number {
|
||||||
|
const preset = sizeConfig?.modeOverrides?.[mode] ?? sizeConfig?.default ?? "md";
|
||||||
|
const presetEntry = MODAL_SIZE_PRESETS[preset] ?? MODAL_SIZE_PRESETS.md;
|
||||||
|
const presetWidth = presetEntry.width;
|
||||||
|
// full이면 뷰포트 전체, 아니면 프리셋과 뷰포트 중 작은 값
|
||||||
|
if (preset === "full") return viewportWidth;
|
||||||
|
return Math.min(presetWidth, viewportWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모달 캔버스 정의
|
||||||
|
*
|
||||||
|
* 버튼의 "모달 열기" 액션이 참조하는 모달 화면.
|
||||||
|
* 메인 캔버스와 동일한 그리드 시스템을 사용.
|
||||||
|
* 중첩 모달: parentId로 부모-자식 관계 표현.
|
||||||
|
*/
|
||||||
|
export interface PopModalDefinition {
|
||||||
|
/** 모달 고유 ID (예: "modal-1", "modal-1-1") */
|
||||||
|
id: string;
|
||||||
|
/** 부모 모달 ID (최상위 모달은 undefined) */
|
||||||
|
parentId?: string;
|
||||||
|
/** 모달 제목 (다이얼로그 헤더에 표시) */
|
||||||
|
title: string;
|
||||||
|
/** 이 모달을 연 버튼의 컴포넌트 ID */
|
||||||
|
sourceButtonId: string;
|
||||||
|
/** 모달 내부 그리드 설정 */
|
||||||
|
gridConfig: PopGridConfig;
|
||||||
|
/** 모달 내부 컴포넌트 */
|
||||||
|
components: Record<string, PopComponentDefinitionV5>;
|
||||||
|
/** 모드별 오버라이드 */
|
||||||
|
overrides?: {
|
||||||
|
mobile_portrait?: PopModeOverrideV5;
|
||||||
|
mobile_landscape?: PopModeOverrideV5;
|
||||||
|
tablet_portrait?: PopModeOverrideV5;
|
||||||
|
};
|
||||||
|
/** 모달 프레임 설정 (닫기 방식) */
|
||||||
|
frameConfig?: {
|
||||||
|
/** 닫기(X) 버튼 표시 여부 (기본 true) */
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
/** 오버레이 클릭으로 닫기 (기본 true) */
|
||||||
|
closeOnOverlay?: boolean;
|
||||||
|
/** ESC 키로 닫기 (기본 true) */
|
||||||
|
closeOnEsc?: boolean;
|
||||||
|
};
|
||||||
|
/** 모달 사이즈 설정 (미설정 시 md 기본) */
|
||||||
|
sizeConfig?: ModalSizeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 레거시 타입 별칭 (하위 호환 - 추후 제거)
|
// 레거시 타입 별칭 (하위 호환 - 추후 제거)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
/**
|
||||||
|
* PopViewerWithModals - 뷰어 모드에서 모달 렌더링 래퍼
|
||||||
|
*
|
||||||
|
* PopRenderer를 감싸서:
|
||||||
|
* 1. __pop_modal_open__ 이벤트 구독 → Dialog 열기
|
||||||
|
* 2. __pop_modal_close__ 이벤트 구독 → Dialog 닫기
|
||||||
|
* 3. 모달 스택 관리 (중첩 모달 지원)
|
||||||
|
*
|
||||||
|
* 모달 내부는 또 다른 PopRenderer로 렌더링 (독립 그리드).
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import PopRenderer from "../designer/renderers/PopRenderer";
|
||||||
|
import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout";
|
||||||
|
import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout";
|
||||||
|
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||||
|
import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 타입
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface PopViewerWithModalsProps {
|
||||||
|
/** 전체 레이아웃 (모달 정의 포함) */
|
||||||
|
layout: PopLayoutDataV5;
|
||||||
|
/** 뷰포트 너비 */
|
||||||
|
viewportWidth: number;
|
||||||
|
/** 화면 ID (이벤트 버스용) */
|
||||||
|
screenId: string;
|
||||||
|
/** 현재 그리드 모드 (PopRenderer 전달용) */
|
||||||
|
currentMode?: GridMode;
|
||||||
|
/** Gap 오버라이드 */
|
||||||
|
overrideGap?: number;
|
||||||
|
/** Padding 오버라이드 */
|
||||||
|
overridePadding?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 열린 모달 상태 */
|
||||||
|
interface OpenModal {
|
||||||
|
definition: PopModalDefinition;
|
||||||
|
returnTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export default function PopViewerWithModals({
|
||||||
|
layout,
|
||||||
|
viewportWidth,
|
||||||
|
screenId,
|
||||||
|
currentMode,
|
||||||
|
overrideGap,
|
||||||
|
overridePadding,
|
||||||
|
}: PopViewerWithModalsProps) {
|
||||||
|
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
|
||||||
|
const { subscribe, publish } = usePopEvent(screenId);
|
||||||
|
|
||||||
|
// 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환
|
||||||
|
const stableConnections = useMemo(
|
||||||
|
() => layout.dataFlow?.connections ?? [],
|
||||||
|
[layout.dataFlow?.connections]
|
||||||
|
);
|
||||||
|
useConnectionResolver({
|
||||||
|
screenId,
|
||||||
|
connections: stableConnections,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모달 열기/닫기 이벤트 구독
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => {
|
||||||
|
const data = payload as {
|
||||||
|
modalId?: string;
|
||||||
|
title?: string;
|
||||||
|
mode?: string;
|
||||||
|
returnTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data?.modalId) {
|
||||||
|
const modalDef = layout.modals?.find(m => m.id === data.modalId);
|
||||||
|
if (modalDef) {
|
||||||
|
setModalStack(prev => [...prev, {
|
||||||
|
definition: modalDef,
|
||||||
|
returnTo: data.returnTo,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubClose = subscribe("__pop_modal_close__", (payload: unknown) => {
|
||||||
|
const data = payload as { selectedRow?: Record<string, unknown> } | undefined;
|
||||||
|
|
||||||
|
setModalStack(prev => {
|
||||||
|
if (prev.length === 0) return prev;
|
||||||
|
const topModal = prev[prev.length - 1];
|
||||||
|
|
||||||
|
// 결과 데이터가 있고, 반환 대상이 지정된 경우 결과 이벤트 발행
|
||||||
|
if (data?.selectedRow && topModal.returnTo) {
|
||||||
|
publish("__pop_modal_result__", {
|
||||||
|
selectedRow: data.selectedRow,
|
||||||
|
returnTo: topModal.returnTo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev.slice(0, -1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubOpen();
|
||||||
|
unsubClose();
|
||||||
|
};
|
||||||
|
}, [subscribe, publish, layout.modals]);
|
||||||
|
|
||||||
|
// 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC)
|
||||||
|
const handleCloseTopModal = useCallback(() => {
|
||||||
|
setModalStack(prev => prev.slice(0, -1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 메인 화면 렌더링 */}
|
||||||
|
<PopRenderer
|
||||||
|
layout={layout}
|
||||||
|
viewportWidth={viewportWidth}
|
||||||
|
currentScreenId={Number(screenId) || undefined}
|
||||||
|
currentMode={currentMode}
|
||||||
|
isDesignMode={false}
|
||||||
|
overrideGap={overrideGap}
|
||||||
|
overridePadding={overridePadding}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 모달 스택 렌더링 */}
|
||||||
|
{modalStack.map((modal, index) => {
|
||||||
|
const { definition } = modal;
|
||||||
|
const isTopModal = index === modalStack.length - 1;
|
||||||
|
const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false;
|
||||||
|
const closeOnEsc = definition.frameConfig?.closeOnEsc !== false;
|
||||||
|
|
||||||
|
const modalLayout: PopLayoutDataV5 = {
|
||||||
|
...layout,
|
||||||
|
gridConfig: definition.gridConfig,
|
||||||
|
components: definition.components,
|
||||||
|
overrides: definition.overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectedMode = currentMode || detectGridMode(viewportWidth);
|
||||||
|
const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth);
|
||||||
|
const isFull = modalWidth >= viewportWidth;
|
||||||
|
const rendererWidth = isFull ? viewportWidth : modalWidth - 32;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
key={`${definition.id}-${index}`}
|
||||||
|
open={true}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && isTopModal) handleCloseTopModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className={isFull
|
||||||
|
? "h-dvh max-h-dvh w-screen max-w-[100vw] overflow-auto rounded-none border-none p-0"
|
||||||
|
: "max-h-[90vh] overflow-auto p-0"
|
||||||
|
}
|
||||||
|
style={isFull ? undefined : {
|
||||||
|
maxWidth: `${modalWidth}px`,
|
||||||
|
width: `${modalWidth}px`,
|
||||||
|
}}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
// 최상위 모달이 아니면 overlay 클릭 무시 (하위 모달이 먼저 닫히는 것 방지)
|
||||||
|
if (!isTopModal || !closeOnOverlay) e.preventDefault();
|
||||||
|
}}
|
||||||
|
onEscapeKeyDown={(e) => {
|
||||||
|
if (!isTopModal || !closeOnEsc) e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader className={isFull ? "px-4 pt-3 pb-2" : "px-4 pt-4 pb-2"}>
|
||||||
|
<DialogTitle className="text-base">
|
||||||
|
{definition.title}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className={isFull ? "flex-1 overflow-auto" : "px-4 pb-4"}>
|
||||||
|
<PopRenderer
|
||||||
|
layout={modalLayout}
|
||||||
|
viewportWidth={rendererWidth}
|
||||||
|
currentScreenId={Number(screenId) || undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||||
|
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||||
|
|
||||||
interface EditModalState {
|
interface EditModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -1173,19 +1174,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
const masterRecordId = response.data?.id || formData.id;
|
const masterRecordId = response.data?.id || formData.id;
|
||||||
|
|
||||||
// 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("repeaterSave", {
|
|
||||||
detail: {
|
|
||||||
parentId: masterRecordId,
|
|
||||||
masterRecordId,
|
|
||||||
mainFormData: formData,
|
|
||||||
tableName: screenData.screenInfo.tableName,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
console.log("📋 [EditModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName: screenData.screenInfo.tableName });
|
|
||||||
|
|
||||||
toast.success("데이터가 생성되었습니다.");
|
toast.success("데이터가 생성되었습니다.");
|
||||||
|
|
||||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||||
|
|
@ -1233,14 +1221,48 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
|
||||||
|
try {
|
||||||
|
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||||
|
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||||
|
const handler = () => {
|
||||||
|
clearTimeout(fallbackTimeout);
|
||||||
|
window.removeEventListener("repeaterSaveComplete", handler);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
window.addEventListener("repeaterSaveComplete", handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", {
|
||||||
|
parentId: masterRecordId,
|
||||||
|
tableName: screenData.screenInfo.tableName,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("repeaterSave", {
|
||||||
|
detail: {
|
||||||
|
parentId: masterRecordId,
|
||||||
|
tableName: screenData.screenInfo.tableName,
|
||||||
|
mainFormData: formData,
|
||||||
|
masterRecordId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await repeaterSavePromise;
|
||||||
|
console.log("✅ [EditModal] INSERT 후 repeaterSave 완료");
|
||||||
|
} catch (repeaterError) {
|
||||||
|
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
|
||||||
|
}
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.message || "생성에 실패했습니다.");
|
throw new Error(response.message || "생성에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// UPDATE 모드 - PUT (전체 업데이트)
|
// UPDATE 모드 - PUT (전체 업데이트)
|
||||||
// originalData 비교 없이 formData 전체를 보냄
|
// VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조)
|
||||||
const recordId = formData.id;
|
const recordId = formData.master_id || formData.id;
|
||||||
|
|
||||||
if (!recordId) {
|
if (!recordId) {
|
||||||
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
|
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
|
||||||
|
|
@ -1293,15 +1315,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
toast.success("데이터가 수정되었습니다.");
|
toast.success("데이터가 수정되었습니다.");
|
||||||
|
|
||||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
|
||||||
if (modalState.onSave) {
|
|
||||||
try {
|
|
||||||
modalState.onSave();
|
|
||||||
} catch (callbackError) {
|
|
||||||
console.error("onSave 콜백 에러:", callbackError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
|
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
|
||||||
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||||
try {
|
try {
|
||||||
|
|
@ -1338,6 +1351,44 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
|
||||||
|
try {
|
||||||
|
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||||
|
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||||
|
const handler = () => {
|
||||||
|
clearTimeout(fallbackTimeout);
|
||||||
|
window.removeEventListener("repeaterSaveComplete", handler);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
window.addEventListener("repeaterSaveComplete", handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", {
|
||||||
|
parentId: recordId,
|
||||||
|
tableName: screenData.screenInfo.tableName,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("repeaterSave", {
|
||||||
|
detail: {
|
||||||
|
parentId: recordId,
|
||||||
|
tableName: screenData.screenInfo.tableName,
|
||||||
|
mainFormData: formData,
|
||||||
|
masterRecordId: recordId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await repeaterSavePromise;
|
||||||
|
console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료");
|
||||||
|
} catch (repeaterError) {
|
||||||
|
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리피터 저장 완료 후 메인 테이블 새로고침
|
||||||
|
if (modalState.onSave) {
|
||||||
|
try { modalState.onSave(); } catch {}
|
||||||
|
}
|
||||||
handleClose();
|
handleClose();
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.message || "수정에 실패했습니다.");
|
throw new Error(response.message || "수정에 실패했습니다.");
|
||||||
|
|
@ -1404,12 +1455,16 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : screenData ? (
|
) : screenData ? (
|
||||||
|
<ScreenContextProvider
|
||||||
|
screenId={modalState.screenId || undefined}
|
||||||
|
tableName={screenData.screenInfo?.tableName}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
data-screen-runtime="true"
|
data-screen-runtime="true"
|
||||||
className="relative bg-white"
|
className="relative bg-white"
|
||||||
style={{
|
style={{
|
||||||
width: screenDimensions?.width || 800,
|
width: screenDimensions?.width || 800,
|
||||||
// 🆕 조건부 레이어가 활성화되면 높이 자동 확장
|
// 조건부 레이어가 활성화되면 높이 자동 확장
|
||||||
height: (() => {
|
height: (() => {
|
||||||
const baseHeight = (screenDimensions?.height || 600) + 30;
|
const baseHeight = (screenDimensions?.height || 600) + 30;
|
||||||
if (activeConditionalComponents.length > 0) {
|
if (activeConditionalComponents.length > 0) {
|
||||||
|
|
@ -1565,6 +1620,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</ScreenContextProvider>
|
||||||
) : (
|
) : (
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -571,8 +571,38 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 리피터가 화면과 동일 테이블을 사용하는지 감지 (useCustomTable 미설정 = 동일 테이블)
|
||||||
|
const hasRepeaterOnSameTable = allComponents.some((c: any) => {
|
||||||
|
const compType = c.componentType || c.overrides?.type;
|
||||||
|
if (compType !== "v2-repeater") return false;
|
||||||
|
const compConfig = c.componentConfig || c.overrides || {};
|
||||||
|
return !compConfig.useCustomTable;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasRepeaterOnSameTable) {
|
||||||
|
// 동일 테이블 리피터: 마스터 저장 스킵, 리피터만 저장
|
||||||
|
// 리피터가 mainFormData를 각 행에 병합하여 N건 INSERT 처리
|
||||||
try {
|
try {
|
||||||
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("repeaterSave", {
|
||||||
|
detail: {
|
||||||
|
parentId: null,
|
||||||
|
masterRecordId: null,
|
||||||
|
mainFormData: formData,
|
||||||
|
tableName: screenInfo.tableName,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success("데이터가 성공적으로 저장되었습니다.");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
|
||||||
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
|
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
|
||||||
const masterFormData: Record<string, any> = {};
|
const masterFormData: Record<string, any> = {};
|
||||||
|
|
||||||
|
|
@ -591,11 +621,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
|
|
||||||
Object.entries(formData).forEach(([key, value]) => {
|
Object.entries(formData).forEach(([key, value]) => {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
// 배열이 아닌 값은 그대로 저장
|
|
||||||
masterFormData[key] = value;
|
masterFormData[key] = value;
|
||||||
} else if (mediaColumnNames.has(key)) {
|
} else if (mediaColumnNames.has(key)) {
|
||||||
// v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응)
|
|
||||||
// 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용
|
|
||||||
masterFormData[key] = value.length > 0 ? value[0] : null;
|
masterFormData[key] = value.length > 0 ? value[0] : null;
|
||||||
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
|
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -608,7 +635,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
data: masterFormData,
|
data: masterFormData,
|
||||||
};
|
};
|
||||||
|
|
||||||
// console.log("💾 저장 액션 실행:", saveData);
|
|
||||||
const response = await dynamicFormApi.saveData(saveData);
|
const response = await dynamicFormApi.saveData(saveData);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
|
@ -619,7 +645,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
new CustomEvent("repeaterSave", {
|
new CustomEvent("repeaterSave", {
|
||||||
detail: {
|
detail: {
|
||||||
parentId: masterRecordId,
|
parentId: masterRecordId,
|
||||||
masterRecordId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
|
masterRecordId,
|
||||||
mainFormData: formData,
|
mainFormData: formData,
|
||||||
tableName: screenInfo.tableName,
|
tableName: screenInfo.tableName,
|
||||||
},
|
},
|
||||||
|
|
@ -631,7 +657,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
toast.error(response.message || "저장에 실패했습니다.");
|
toast.error(response.message || "저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("저장 오류:", error);
|
|
||||||
toast.error("저장 중 오류가 발생했습니다.");
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -551,9 +551,12 @@ export default function ScreenDesigner({
|
||||||
originalRegion: null,
|
originalRegion: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
|
// 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
|
||||||
const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null);
|
const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null);
|
||||||
|
|
||||||
|
// 다른 레이어의 컴포넌트 메타 정보 캐시 (데이터 전달 타겟 선택용)
|
||||||
|
const [otherLayerComponents, setOtherLayerComponents] = useState<ComponentData[]>([]);
|
||||||
|
|
||||||
// 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기
|
// 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeLayerId <= 1 || !selectedScreen?.screenId) {
|
if (activeLayerId <= 1 || !selectedScreen?.screenId) {
|
||||||
|
|
@ -578,6 +581,41 @@ export default function ScreenDesigner({
|
||||||
findZone();
|
findZone();
|
||||||
}, [activeLayerId, selectedScreen?.screenId, zones]);
|
}, [activeLayerId, selectedScreen?.screenId, zones]);
|
||||||
|
|
||||||
|
// 다른 레이어의 컴포넌트 메타 정보 로드 (데이터 전달 타겟 선택용)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedScreen?.screenId) return;
|
||||||
|
const loadOtherLayerComponents = async () => {
|
||||||
|
try {
|
||||||
|
const allLayers = await screenApi.getScreenLayers(selectedScreen.screenId);
|
||||||
|
const currentLayerId = activeLayerIdRef.current || 1;
|
||||||
|
const otherLayers = allLayers.filter((l: any) => l.layer_id !== currentLayerId && l.layer_id > 0);
|
||||||
|
|
||||||
|
const components: ComponentData[] = [];
|
||||||
|
for (const layerInfo of otherLayers) {
|
||||||
|
try {
|
||||||
|
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerInfo.layer_id);
|
||||||
|
const rawComps = layerData?.components;
|
||||||
|
if (rawComps && Array.isArray(rawComps)) {
|
||||||
|
for (const comp of rawComps) {
|
||||||
|
components.push({
|
||||||
|
...comp,
|
||||||
|
_layerName: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`,
|
||||||
|
_layerId: String(layerInfo.layer_id),
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 개별 레이어 로드 실패 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOtherLayerComponents(components);
|
||||||
|
} catch {
|
||||||
|
setOtherLayerComponents([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadOtherLayerComponents();
|
||||||
|
}, [selectedScreen?.screenId, activeLayerId]);
|
||||||
|
|
||||||
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
|
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
|
||||||
const visibleComponents = useMemo(() => {
|
const visibleComponents = useMemo(() => {
|
||||||
return layout.components;
|
return layout.components;
|
||||||
|
|
@ -6516,8 +6554,8 @@ export default function ScreenDesigner({
|
||||||
updateComponentProperty(selectedComponent.id, "style", style);
|
updateComponentProperty(selectedComponent.id, "style", style);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
allComponents={layout.components} // 🆕 플로우 위젯 감지용
|
allComponents={[...layout.components, ...otherLayerComponents]}
|
||||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
menuObjid={menuObjid}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -92,13 +92,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
|
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
|
||||||
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
|
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
|
||||||
|
|
||||||
// 🆕 데이터 전달 필드 매핑용 상태
|
// 🆕 데이터 전달 필드 매핑용 상태 (멀티 테이블 매핑 지원)
|
||||||
const [mappingSourceColumns, setMappingSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
|
||||||
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||||
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<string, boolean>>({});
|
||||||
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<string, boolean>>({});
|
||||||
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<number, string>>({});
|
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<string, string>>({});
|
||||||
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<number, string>>({});
|
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<string, string>>({});
|
||||||
|
const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0);
|
||||||
|
|
||||||
// 🆕 openModalWithData 전용 필드 매핑 상태
|
// 🆕 openModalWithData 전용 필드 매핑 상태
|
||||||
const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||||
|
|
@ -295,57 +296,57 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드
|
// 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드
|
||||||
|
const loadMappingColumns = useCallback(async (tableName: string): Promise<Array<{ name: string; label: string }>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||||
|
if (response.data.success) {
|
||||||
|
let columnData = response.data.data;
|
||||||
|
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||||
|
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||||
|
|
||||||
|
if (Array.isArray(columnData)) {
|
||||||
|
return columnData.map((col: any) => ({
|
||||||
|
name: col.name || col.columnName,
|
||||||
|
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sourceTable = config.action?.dataTransfer?.sourceTable;
|
const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || [];
|
||||||
|
const legacySourceTable = config.action?.dataTransfer?.sourceTable;
|
||||||
const targetTable = config.action?.dataTransfer?.targetTable;
|
const targetTable = config.action?.dataTransfer?.targetTable;
|
||||||
|
|
||||||
const loadColumns = async () => {
|
const loadAll = async () => {
|
||||||
if (sourceTable) {
|
const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean);
|
||||||
try {
|
if (legacySourceTable && !sourceTableNames.includes(legacySourceTable)) {
|
||||||
const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`);
|
sourceTableNames.push(legacySourceTable);
|
||||||
if (response.data.success) {
|
|
||||||
let columnData = response.data.data;
|
|
||||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
|
||||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
|
||||||
|
|
||||||
if (Array.isArray(columnData)) {
|
|
||||||
const columns = columnData.map((col: any) => ({
|
|
||||||
name: col.name || col.columnName,
|
|
||||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
|
||||||
}));
|
|
||||||
setMappingSourceColumns(columns);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("소스 테이블 컬럼 로드 실패:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetTable) {
|
const newMap: Record<string, Array<{ name: string; label: string }>> = {};
|
||||||
try {
|
for (const tbl of sourceTableNames) {
|
||||||
const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`);
|
if (!mappingSourceColumnsMap[tbl]) {
|
||||||
if (response.data.success) {
|
newMap[tbl] = await loadMappingColumns(tbl);
|
||||||
let columnData = response.data.data;
|
}
|
||||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
}
|
||||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
if (Object.keys(newMap).length > 0) {
|
||||||
|
setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap }));
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(columnData)) {
|
if (targetTable && mappingTargetColumns.length === 0) {
|
||||||
const columns = columnData.map((col: any) => ({
|
const cols = await loadMappingColumns(targetTable);
|
||||||
name: col.name || col.columnName,
|
setMappingTargetColumns(cols);
|
||||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
|
||||||
}));
|
|
||||||
setMappingTargetColumns(columns);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("타겟 테이블 컬럼 로드 실패:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadColumns();
|
loadAll();
|
||||||
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
|
}, [config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable, loadMappingColumns]);
|
||||||
|
|
||||||
// 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드
|
// 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -2966,11 +2967,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{/* 데이터 제공 가능한 컴포넌트 필터링 */}
|
{/* 자동 탐색 옵션 (레이어별 테이블이 다를 때 유용) */}
|
||||||
|
<SelectItem value="__auto__">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium">자동 탐색 (현재 활성 테이블)</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">(auto)</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
{/* 데이터 제공 가능한 컴포넌트 필터링 (모든 레이어 포함) */}
|
||||||
{allComponents
|
{allComponents
|
||||||
.filter((comp: any) => {
|
.filter((comp: any) => {
|
||||||
const type = comp.componentType || comp.type || "";
|
const type = comp.componentType || comp.type || "";
|
||||||
// 데이터를 제공할 수 있는 컴포넌트 타입들
|
|
||||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||||
type.includes(t),
|
type.includes(t),
|
||||||
);
|
);
|
||||||
|
|
@ -2978,11 +2985,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
.map((comp: any) => {
|
.map((comp: any) => {
|
||||||
const compType = comp.componentType || comp.type || "unknown";
|
const compType = comp.componentType || comp.type || "unknown";
|
||||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||||
|
const layerName = comp._layerName;
|
||||||
return (
|
return (
|
||||||
<SelectItem key={comp.id} value={comp.id}>
|
<SelectItem key={comp.id} value={comp.id}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium">{compLabel}</span>
|
<span className="text-xs font-medium">{compLabel}</span>
|
||||||
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||||
|
{layerName && (
|
||||||
|
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
{layerName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
|
|
@ -2999,7 +3012,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-muted-foreground mt-1 text-xs">테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트</p>
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -3037,33 +3052,47 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={config.action?.dataTransfer?.targetComponentId || ""}
|
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => {
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value)
|
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value);
|
||||||
|
// 선택한 컴포넌트가 다른 레이어에 있으면 targetLayerId도 저장
|
||||||
|
const selectedComp = allComponents.find((c: any) => c.id === value);
|
||||||
|
if (selectedComp && (selectedComp as any)._layerId) {
|
||||||
|
onUpdateProperty(
|
||||||
|
"componentConfig.action.dataTransfer.targetLayerId",
|
||||||
|
(selectedComp as any)._layerId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.targetLayerId", undefined);
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
|
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{/* 데이터 수신 가능한 컴포넌트 필터링 (소스와 다른 컴포넌트만) */}
|
{/* 데이터 수신 가능한 컴포넌트 필터링 (모든 레이어 포함, 소스와 다른 컴포넌트만) */}
|
||||||
{allComponents
|
{allComponents
|
||||||
.filter((comp: any) => {
|
.filter((comp: any) => {
|
||||||
const type = comp.componentType || comp.type || "";
|
const type = comp.componentType || comp.type || "";
|
||||||
// 데이터를 받을 수 있는 컴포넌트 타입들
|
|
||||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||||
(t) => type.includes(t),
|
(t) => type.includes(t),
|
||||||
);
|
);
|
||||||
// 소스와 다른 컴포넌트만
|
|
||||||
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||||
})
|
})
|
||||||
.map((comp: any) => {
|
.map((comp: any) => {
|
||||||
const compType = comp.componentType || comp.type || "unknown";
|
const compType = comp.componentType || comp.type || "unknown";
|
||||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||||
|
const layerName = comp._layerName;
|
||||||
return (
|
return (
|
||||||
<SelectItem key={comp.id} value={comp.id}>
|
<SelectItem key={comp.id} value={comp.id}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium">{compLabel}</span>
|
<span className="text-xs font-medium">{compLabel}</span>
|
||||||
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||||
|
{layerName && (
|
||||||
|
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
{layerName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
|
|
@ -3261,70 +3290,69 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="additional-field-name" className="text-xs">
|
<Label htmlFor="additional-field-name" className="text-xs">
|
||||||
필드명 (선택사항)
|
타겟 필드명 (선택사항)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
|
||||||
id="additional-field-name"
|
|
||||||
placeholder="예: inbound_type (비워두면 전체 데이터)"
|
|
||||||
value={config.action?.dataTransfer?.additionalSources?.[0]?.fieldName || ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
|
||||||
const newSources = [...currentSources];
|
|
||||||
if (newSources.length === 0) {
|
|
||||||
newSources.push({ componentId: "", fieldName: e.target.value });
|
|
||||||
} else {
|
|
||||||
newSources[0] = { ...newSources[0], fieldName: e.target.value };
|
|
||||||
}
|
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
|
||||||
}}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs">타겟 테이블에 저장될 필드명</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 필드 매핑 규칙 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>필드 매핑 설정</Label>
|
|
||||||
|
|
||||||
{/* 소스/타겟 테이블 선택 */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">소스 테이블</Label>
|
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
<Button
|
||||||
{config.action?.dataTransfer?.sourceTable
|
variant="outline"
|
||||||
? availableTables.find((t) => t.name === config.action?.dataTransfer?.sourceTable)?.label ||
|
role="combobox"
|
||||||
config.action?.dataTransfer?.sourceTable
|
className="h-8 w-full justify-between text-xs"
|
||||||
: "테이블 선택"}
|
>
|
||||||
|
{(() => {
|
||||||
|
const fieldName = config.action?.dataTransfer?.additionalSources?.[0]?.fieldName;
|
||||||
|
if (!fieldName) return "필드 선택 (비워두면 전체 데이터)";
|
||||||
|
const cols = mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns;
|
||||||
|
const found = cols.find((c) => c.name === fieldName);
|
||||||
|
return found ? `${found.label || found.name}` : fieldName;
|
||||||
|
})()}
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[250px] p-0" align="start">
|
<PopoverContent className="w-[240px] p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{availableTables.map((table) => (
|
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={table.name}
|
value="__none__"
|
||||||
value={`${table.label} ${table.name}`}
|
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.sourceTable", table.name);
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||||
|
const newSources = [...currentSources];
|
||||||
|
if (newSources.length === 0) {
|
||||||
|
newSources.push({ componentId: "", fieldName: "" });
|
||||||
|
} else {
|
||||||
|
newSources[0] = { ...newSources[0], fieldName: "" };
|
||||||
|
}
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||||
}}
|
}}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
<Check
|
<Check className={cn("mr-2 h-3 w-3", !config.action?.dataTransfer?.additionalSources?.[0]?.fieldName ? "opacity-100" : "opacity-0")} />
|
||||||
className={cn(
|
<span className="text-muted-foreground">선택 안 함 (전체 데이터 병합)</span>
|
||||||
"mr-2 h-3 w-3",
|
</CommandItem>
|
||||||
config.action?.dataTransfer?.sourceTable === table.name ? "opacity-100" : "opacity-0",
|
{(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={`${col.label || ""} ${col.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||||
|
const newSources = [...currentSources];
|
||||||
|
if (newSources.length === 0) {
|
||||||
|
newSources.push({ componentId: "", fieldName: col.name });
|
||||||
|
} else {
|
||||||
|
newSources[0] = { ...newSources[0], fieldName: col.name };
|
||||||
|
}
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", config.action?.dataTransfer?.additionalSources?.[0]?.fieldName === col.name ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className="font-medium">{col.label || col.name}</span>
|
||||||
|
{col.label && col.label !== col.name && (
|
||||||
|
<span className="text-muted-foreground ml-1 text-[10px]">({col.name})</span>
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
<span className="font-medium">{table.label}</span>
|
|
||||||
<span className="text-muted-foreground ml-1">({table.name})</span>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
@ -3332,8 +3360,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">추가 데이터가 저장될 타겟 테이블 컬럼</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 멀티 테이블 필드 매핑 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>필드 매핑 설정</Label>
|
||||||
|
|
||||||
|
{/* 타겟 테이블 (공통) */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">타겟 테이블</Label>
|
<Label className="text-xs">타겟 테이블</Label>
|
||||||
<Popover>
|
<Popover>
|
||||||
|
|
@ -3342,7 +3378,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
{config.action?.dataTransfer?.targetTable
|
{config.action?.dataTransfer?.targetTable
|
||||||
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
|
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
|
||||||
config.action?.dataTransfer?.targetTable
|
config.action?.dataTransfer?.targetTable
|
||||||
: "테이블 선택"}
|
: "타겟 테이블 선택"}
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -3377,98 +3413,217 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 필드 매핑 규칙 */}
|
{/* 소스 테이블 매핑 그룹 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">필드 매핑 규칙</Label>
|
<Label className="text-xs">소스 테이블별 매핑</Label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 text-[10px]"
|
className="h-6 text-[10px]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentRules = config.action?.dataTransfer?.mappingRules || [];
|
const currentMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||||||
const newRule = { sourceField: "", targetField: "", transform: "" };
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", [
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", [...currentRules, newRule]);
|
...currentMappings,
|
||||||
|
{ sourceTable: "", mappingRules: [] },
|
||||||
|
]);
|
||||||
|
setActiveMappingGroupIndex(currentMappings.length);
|
||||||
}}
|
}}
|
||||||
disabled={!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable}
|
disabled={!config.action?.dataTransfer?.targetTable}
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
매핑 추가
|
소스 테이블 추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-[10px]">
|
<p className="text-muted-foreground text-[10px]">
|
||||||
소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다.
|
여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable ? (
|
{!config.action?.dataTransfer?.targetTable ? (
|
||||||
<div className="rounded-md border border-dashed p-3 text-center">
|
<div className="rounded-md border border-dashed p-3 text-center">
|
||||||
<p className="text-muted-foreground text-xs">먼저 소스 테이블과 타겟 테이블을 선택하세요.</p>
|
<p className="text-muted-foreground text-xs">먼저 타겟 테이블을 선택하세요.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? (
|
) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (
|
||||||
<div className="rounded-md border border-dashed p-3 text-center">
|
<div className="rounded-md border border-dashed p-3 text-center">
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다.
|
매핑 그룹이 없습니다. 소스 테이블을 추가하세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => (
|
{/* 소스 테이블 탭 */}
|
||||||
<div key={index} className="bg-background flex items-center gap-2 rounded-md border p-2">
|
<div className="flex flex-wrap gap-1">
|
||||||
{/* 소스 필드 선택 (Combobox) */}
|
{(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => (
|
||||||
<div className="flex-1">
|
<div key={gIdx} className="flex items-center gap-0.5">
|
||||||
<Popover
|
<Button
|
||||||
open={mappingSourcePopoverOpen[index] || false}
|
type="button"
|
||||||
onOpenChange={(open) => setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[10px]"
|
||||||
|
onClick={() => setActiveMappingGroupIndex(gIdx)}
|
||||||
>
|
>
|
||||||
|
{group.sourceTable
|
||||||
|
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
|
||||||
|
: `그룹 ${gIdx + 1}`}
|
||||||
|
{group.mappingRules?.length > 0 && (
|
||||||
|
<span className="bg-primary/20 ml-1 rounded-full px-1 text-[9px]">
|
||||||
|
{group.mappingRules.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:bg-destructive/10 h-5 w-5"
|
||||||
|
onClick={() => {
|
||||||
|
const mappings = [...(config.action?.dataTransfer?.multiTableMappings || [])];
|
||||||
|
mappings.splice(gIdx, 1);
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||||||
|
if (activeMappingGroupIndex >= mappings.length) {
|
||||||
|
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 활성 그룹 편집 영역 */}
|
||||||
|
{(() => {
|
||||||
|
const multiMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||||||
|
const activeGroup = multiMappings[activeMappingGroupIndex];
|
||||||
|
if (!activeGroup) return null;
|
||||||
|
|
||||||
|
const activeSourceTable = activeGroup.sourceTable || "";
|
||||||
|
const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
|
||||||
|
const activeRules: any[] = activeGroup.mappingRules || [];
|
||||||
|
|
||||||
|
const updateGroupField = (field: string, value: any) => {
|
||||||
|
const mappings = [...multiMappings];
|
||||||
|
mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
{/* 소스 테이블 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">소스 테이블</Label>
|
||||||
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||||
{rule.sourceField
|
{activeSourceTable
|
||||||
? mappingSourceColumns.find((c) => c.name === rule.sourceField)?.label ||
|
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
|
||||||
rule.sourceField
|
: "소스 테이블 선택"}
|
||||||
: "소스 필드"}
|
<ChevronsUpDown className="ml-2 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-[250px] p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||||
placeholder="컬럼 검색..."
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={mappingSourceSearch[index] || ""}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setMappingSourceSearch((prev) => ({ ...prev, [index]: value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty className="py-2 text-center text-xs">
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||||
컬럼을 찾을 수 없습니다
|
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{mappingSourceColumns.map((col) => (
|
{availableTables.map((table) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={col.name}
|
key={table.name}
|
||||||
value={`${col.label} ${col.name}`}
|
value={`${table.label} ${table.name}`}
|
||||||
onSelect={() => {
|
onSelect={async () => {
|
||||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
updateGroupField("sourceTable", table.name);
|
||||||
rules[index] = { ...rules[index], sourceField: col.name };
|
if (!mappingSourceColumnsMap[table.name]) {
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
const cols = await loadMappingColumns(table.name);
|
||||||
setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false }));
|
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-3 w-3",
|
"mr-2 h-3 w-3",
|
||||||
rule.sourceField === col.name ? "opacity-100" : "opacity-0",
|
activeSourceTable === table.name ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<span className="font-medium">{table.label}</span>
|
||||||
|
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 매핑 규칙 목록 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[10px]">매핑 규칙</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 text-[10px]"
|
||||||
|
onClick={() => {
|
||||||
|
updateGroupField("mappingRules", [...activeRules, { sourceField: "", targetField: "" }]);
|
||||||
|
}}
|
||||||
|
disabled={!activeSourceTable}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!activeSourceTable ? (
|
||||||
|
<p className="text-muted-foreground text-[10px]">소스 테이블을 먼저 선택하세요.</p>
|
||||||
|
) : activeRules.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-[10px]">매핑 없음 (동일 필드명 자동 매핑)</p>
|
||||||
|
) : (
|
||||||
|
activeRules.map((rule: any, rIdx: number) => {
|
||||||
|
const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`;
|
||||||
|
const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`;
|
||||||
|
return (
|
||||||
|
<div key={rIdx} className="bg-background flex items-center gap-2 rounded-md border p-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Popover
|
||||||
|
open={mappingSourcePopoverOpen[popoverKeyS] || false}
|
||||||
|
onOpenChange={(open) =>
|
||||||
|
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
||||||
|
{rule.sourceField
|
||||||
|
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField
|
||||||
|
: "소스 필드"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{activeSourceColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={`${col.label} ${col.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
const newRules = [...activeRules];
|
||||||
|
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
|
||||||
|
updateGroupField("mappingRules", newRules);
|
||||||
|
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: false }));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", rule.sourceField === col.name ? "opacity-100" : "opacity-0")} />
|
||||||
<span>{col.label}</span>
|
<span>{col.label}</span>
|
||||||
{col.label !== col.name && (
|
{col.label !== col.name && <span className="text-muted-foreground ml-1">({col.name})</span>}
|
||||||
<span className="text-muted-foreground ml-1">({col.name})</span>
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
@ -3480,58 +3635,42 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
|
|
||||||
<span className="text-muted-foreground text-xs">→</span>
|
<span className="text-muted-foreground text-xs">→</span>
|
||||||
|
|
||||||
{/* 타겟 필드 선택 (Combobox) */}
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Popover
|
<Popover
|
||||||
open={mappingTargetPopoverOpen[index] || false}
|
open={mappingTargetPopoverOpen[popoverKeyT] || false}
|
||||||
onOpenChange={(open) => setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
onOpenChange={(open) =>
|
||||||
|
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open }))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
||||||
{rule.targetField
|
{rule.targetField
|
||||||
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label ||
|
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
|
||||||
rule.targetField
|
|
||||||
: "타겟 필드"}
|
: "타겟 필드"}
|
||||||
<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-[200px] p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
placeholder="컬럼 검색..."
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={mappingTargetSearch[index] || ""}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty className="py-2 text-center text-xs">
|
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||||||
컬럼을 찾을 수 없습니다
|
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{mappingTargetColumns.map((col) => (
|
{mappingTargetColumns.map((col) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={col.name}
|
key={col.name}
|
||||||
value={`${col.label} ${col.name}`}
|
value={`${col.label} ${col.name}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
const newRules = [...activeRules];
|
||||||
rules[index] = { ...rules[index], targetField: col.name };
|
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
updateGroupField("mappingRules", newRules);
|
||||||
setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false }));
|
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: false }));
|
||||||
}}
|
}}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
<Check
|
<Check className={cn("mr-2 h-3 w-3", rule.targetField === col.name ? "opacity-100" : "opacity-0")} />
|
||||||
className={cn(
|
|
||||||
"mr-2 h-3 w-3",
|
|
||||||
rule.targetField === col.name ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>{col.label}</span>
|
<span>{col.label}</span>
|
||||||
{col.label !== col.name && (
|
{col.label !== col.name && <span className="text-muted-foreground ml-1">({col.name})</span>}
|
||||||
<span className="text-muted-foreground ml-1">({col.name})</span>
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
@ -3547,15 +3686,21 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
const newRules = [...activeRules];
|
||||||
rules.splice(index, 1);
|
newRules.splice(rIdx, 1);
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
updateGroupField("mappingRules", newRules);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3567,9 +3712,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<br />
|
<br />
|
||||||
1. 소스 컴포넌트에서 데이터를 선택합니다
|
1. 소스 컴포넌트에서 데이터를 선택합니다
|
||||||
<br />
|
<br />
|
||||||
2. 필드 매핑 규칙을 설정합니다 (예: 품번 → 품목코드)
|
2. 소스 테이블별로 필드 매핑 규칙을 설정합니다
|
||||||
<br />
|
<br />
|
||||||
3. 이 버튼을 클릭하면 매핑된 데이터가 타겟으로 전달됩니다
|
3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
addMonths,
|
||||||
|
subMonths,
|
||||||
|
startOfMonth,
|
||||||
|
endOfMonth,
|
||||||
|
eachDayOfInterval,
|
||||||
|
isSameMonth,
|
||||||
|
isSameDay,
|
||||||
|
isToday,
|
||||||
|
} from "date-fns";
|
||||||
|
import { ko } from "date-fns/locale";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface FormDatePickerProps {
|
||||||
|
id?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
includeTime?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormDatePicker: React.FC<FormDatePickerProps> = ({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
includeTime = false,
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
|
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||||
|
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||||
|
const [timeValue, setTimeValue] = useState("00:00");
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const [typingValue, setTypingValue] = useState("");
|
||||||
|
|
||||||
|
const parseDate = (val: string): Date | undefined => {
|
||||||
|
if (!val) return undefined;
|
||||||
|
try {
|
||||||
|
const date = new Date(val);
|
||||||
|
if (isNaN(date.getTime())) return undefined;
|
||||||
|
return date;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedDate = parseDate(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setViewMode("calendar");
|
||||||
|
if (selectedDate) {
|
||||||
|
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||||
|
setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12);
|
||||||
|
if (includeTime) {
|
||||||
|
const hours = String(selectedDate.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(selectedDate.getMinutes()).padStart(2, "0");
|
||||||
|
setTimeValue(`${hours}:${minutes}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCurrentMonth(new Date());
|
||||||
|
setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12);
|
||||||
|
setTimeValue("00:00");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsTyping(false);
|
||||||
|
setTypingValue("");
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const formatDisplayValue = (): string => {
|
||||||
|
if (!selectedDate) return "";
|
||||||
|
if (includeTime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko });
|
||||||
|
return format(selectedDate, "yyyy-MM-dd", { locale: ko });
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDateStr = (date: Date, time?: string) => {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(date.getDate()).padStart(2, "0");
|
||||||
|
if (includeTime) return `${y}-${m}-${d}T${time || timeValue}`;
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateClick = (date: Date) => {
|
||||||
|
onChange(buildDateStr(date));
|
||||||
|
if (!includeTime) setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeChange = (newTime: string) => {
|
||||||
|
setTimeValue(newTime);
|
||||||
|
if (selectedDate) onChange(buildDateStr(selectedDate, newTime));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetToday = () => {
|
||||||
|
const today = new Date();
|
||||||
|
if (includeTime) {
|
||||||
|
const t = `${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`;
|
||||||
|
onChange(buildDateStr(today, t));
|
||||||
|
} else {
|
||||||
|
onChange(buildDateStr(today));
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onChange("");
|
||||||
|
setIsTyping(false);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTriggerInput = (raw: string) => {
|
||||||
|
setIsTyping(true);
|
||||||
|
setTypingValue(raw);
|
||||||
|
if (!isOpen) setIsOpen(true);
|
||||||
|
const digitsOnly = raw.replace(/\D/g, "");
|
||||||
|
if (digitsOnly.length === 8) {
|
||||||
|
const y = parseInt(digitsOnly.slice(0, 4), 10);
|
||||||
|
const m = parseInt(digitsOnly.slice(4, 6), 10) - 1;
|
||||||
|
const d = parseInt(digitsOnly.slice(6, 8), 10);
|
||||||
|
const date = new Date(y, m, d);
|
||||||
|
if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) {
|
||||||
|
onChange(buildDateStr(date));
|
||||||
|
setCurrentMonth(new Date(y, m, 1));
|
||||||
|
if (!includeTime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400);
|
||||||
|
else setIsTyping(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthStart = startOfMonth(currentMonth);
|
||||||
|
const monthEnd = endOfMonth(currentMonth);
|
||||||
|
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||||
|
const dayOfWeek = monthStart.getDay();
|
||||||
|
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||||
|
const allDays = [...Array(paddingDays).fill(null), ...days];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={(open) => { if (!open) { setIsOpen(false); setIsTyping(false); } }}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
className={cn(
|
||||||
|
"border-input bg-background flex h-10 w-full cursor-pointer items-center rounded-md border px-3",
|
||||||
|
(disabled || readOnly) && "cursor-not-allowed opacity-50",
|
||||||
|
!selectedDate && !isTyping && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => { if (!disabled && !readOnly) setIsOpen(true); }}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={isTyping ? typingValue : (formatDisplayValue() || "")}
|
||||||
|
placeholder={placeholder || "날짜를 선택하세요"}
|
||||||
|
disabled={disabled || readOnly}
|
||||||
|
onChange={(e) => handleTriggerInput(e.target.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onFocus={() => { if (!disabled && !readOnly && !isOpen) setIsOpen(true); }}
|
||||||
|
onBlur={() => { if (!isOpen) setIsTyping(false); }}
|
||||||
|
className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
{selectedDate && !disabled && !readOnly && !isTyping && (
|
||||||
|
<X
|
||||||
|
className="text-muted-foreground hover:text-foreground ml-auto h-3.5 w-3.5 shrink-0 cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClear();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleSetToday}>
|
||||||
|
오늘
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === "year" ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||||
|
<Button
|
||||||
|
key={year}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-9 text-xs",
|
||||||
|
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||||
|
setViewMode("month");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : viewMode === "month" ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||||
|
setViewMode("year");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentMonth.getFullYear()}년
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||||
|
<Button
|
||||||
|
key={month}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-9 text-xs",
|
||||||
|
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||||
|
setViewMode("calendar");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{month + 1}월
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||||
|
setViewMode("year");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||||
|
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||||
|
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||||
|
{allDays.map((date, index) => {
|
||||||
|
if (!date) return <div key={index} className="p-2" />;
|
||||||
|
|
||||||
|
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||||
|
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
|
||||||
|
const isTodayDate = isToday(date);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={date.toISOString()}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 p-0 text-xs",
|
||||||
|
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||||
|
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
isTodayDate && !isSelected && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => handleDateClick(date)}
|
||||||
|
disabled={!isCurrentMonth}
|
||||||
|
>
|
||||||
|
{format(date, "d")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{includeTime && viewMode === "calendar" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs">시간:</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={timeValue}
|
||||||
|
onChange={(e) => handleTimeChange(e.target.value)}
|
||||||
|
className="border-input h-8 rounded-md border px-2 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
addMonths,
|
||||||
|
subMonths,
|
||||||
|
startOfMonth,
|
||||||
|
endOfMonth,
|
||||||
|
eachDayOfInterval,
|
||||||
|
isSameMonth,
|
||||||
|
isSameDay,
|
||||||
|
isToday,
|
||||||
|
} from "date-fns";
|
||||||
|
import { ko } from "date-fns/locale";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface InlineCellDatePickerProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
inputRef?: React.RefObject<HTMLInputElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InlineCellDatePicker: React.FC<InlineCellDatePickerProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
onKeyDown,
|
||||||
|
inputRef,
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
|
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||||
|
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||||
|
const localInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const actualInputRef = inputRef || localInputRef;
|
||||||
|
|
||||||
|
const parseDate = (val: string): Date | undefined => {
|
||||||
|
if (!val) return undefined;
|
||||||
|
try {
|
||||||
|
const date = new Date(val);
|
||||||
|
if (isNaN(date.getTime())) return undefined;
|
||||||
|
return date;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedDate = parseDate(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDate) {
|
||||||
|
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDateClick = (date: Date) => {
|
||||||
|
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
|
onChange(dateStr);
|
||||||
|
setIsOpen(false);
|
||||||
|
setTimeout(() => onSave(), 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetToday = () => {
|
||||||
|
const today = new Date();
|
||||||
|
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||||
|
onChange(dateStr);
|
||||||
|
setIsOpen(false);
|
||||||
|
setTimeout(() => onSave(), 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onChange("");
|
||||||
|
setIsOpen(false);
|
||||||
|
setTimeout(() => onSave(), 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (raw: string) => {
|
||||||
|
onChange(raw);
|
||||||
|
const digitsOnly = raw.replace(/\D/g, "");
|
||||||
|
if (digitsOnly.length === 8) {
|
||||||
|
const y = parseInt(digitsOnly.slice(0, 4), 10);
|
||||||
|
const m = parseInt(digitsOnly.slice(4, 6), 10) - 1;
|
||||||
|
const d = parseInt(digitsOnly.slice(6, 8), 10);
|
||||||
|
const date = new Date(y, m, d);
|
||||||
|
if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) {
|
||||||
|
const dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||||
|
onChange(dateStr);
|
||||||
|
setIsOpen(false);
|
||||||
|
setTimeout(() => onSave(), 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopoverClose = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setIsOpen(false);
|
||||||
|
onSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthStart = startOfMonth(currentMonth);
|
||||||
|
const monthEnd = endOfMonth(currentMonth);
|
||||||
|
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||||
|
|
||||||
|
const startDate = new Date(monthStart);
|
||||||
|
const dayOfWeek = startDate.getDay();
|
||||||
|
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||||
|
const allDays = [...Array(paddingDays).fill(null), ...days];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={handlePopoverClose}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<input
|
||||||
|
ref={actualInputRef as any}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleInputChange(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
placeholder="YYYYMMDD"
|
||||||
|
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleSetToday}>
|
||||||
|
오늘
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleClear}>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === "year" ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<div className="text-xs font-medium">
|
||||||
|
{yearRangeStart} - {yearRangeStart + 11}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||||
|
<Button
|
||||||
|
key={year}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-8 text-xs",
|
||||||
|
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||||
|
setViewMode("month");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : viewMode === "month" ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-accent rounded-md px-2 py-0.5 text-xs font-medium transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||||
|
setViewMode("year");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentMonth.getFullYear()}년
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||||
|
<Button
|
||||||
|
key={month}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-8 text-xs",
|
||||||
|
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||||
|
setViewMode("calendar");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{month + 1}월
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-accent rounded-md px-2 py-0.5 text-xs font-medium transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||||
|
setViewMode("year");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-1 grid grid-cols-7 gap-0.5">
|
||||||
|
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||||
|
<div key={day} className="text-muted-foreground p-1 text-center text-[10px] font-medium">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-0.5">
|
||||||
|
{allDays.map((date, index) => {
|
||||||
|
if (!date) return <div key={index} className="p-1" />;
|
||||||
|
|
||||||
|
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||||
|
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
|
||||||
|
const isTodayDate = isToday(date);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={date.toISOString()}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-7 p-0 text-[11px]",
|
||||||
|
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||||
|
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
isTodayDate && !isSelected && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => handleDateClick(date)}
|
||||||
|
disabled={!isCurrentMonth}
|
||||||
|
>
|
||||||
|
{format(date, "d")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -34,6 +34,8 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
|
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
|
||||||
|
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||||
|
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||||
|
|
||||||
// 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장)
|
// 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장)
|
||||||
const [tempValue, setTempValue] = useState<DateRangeValue>(value || {});
|
const [tempValue, setTempValue] = useState<DateRangeValue>(value || {});
|
||||||
|
|
@ -43,6 +45,7 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setTempValue(value || {});
|
setTempValue(value || {});
|
||||||
setSelectingType("from");
|
setSelectingType("from");
|
||||||
|
setViewMode("calendar");
|
||||||
}
|
}
|
||||||
}, [isOpen, value]);
|
}, [isOpen, value]);
|
||||||
|
|
||||||
|
|
@ -234,13 +237,101 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 월 네비게이션 */}
|
{viewMode === "year" ? (
|
||||||
|
<>
|
||||||
|
{/* 년도 선택 뷰 */}
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="text-sm font-medium">{format(currentMonth, "yyyy년 MM월", { locale: ko })}</div>
|
<div className="text-sm font-medium">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
{yearRangeStart} - {yearRangeStart + 11}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||||
|
<Button
|
||||||
|
key={year}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-9 text-xs",
|
||||||
|
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||||
|
setViewMode("month");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : viewMode === "month" ? (
|
||||||
|
<>
|
||||||
|
{/* 월 선택 뷰 */}
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||||
|
setViewMode("year");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentMonth.getFullYear()}년
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||||
|
<Button
|
||||||
|
key={month}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-9 text-xs",
|
||||||
|
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||||
|
setViewMode("calendar");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{month + 1}월
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 월 네비게이션 */}
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||||
|
setViewMode("year");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -288,6 +379,8 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 선택된 범위 표시 */}
|
{/* 선택된 범위 표시 */}
|
||||||
{(tempValue.from || tempValue.to) && (
|
{(tempValue.from || tempValue.to) && (
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
const extraProps: Record<string, any> = {};
|
const extraProps: Record<string, any> = {};
|
||||||
if (componentId === "v2-select") {
|
if (componentId === "v2-select") {
|
||||||
extraProps.inputType = inputType;
|
extraProps.inputType = inputType;
|
||||||
|
extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||||
|
extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
|
||||||
}
|
}
|
||||||
if (componentId === "v2-list") {
|
if (componentId === "v2-list") {
|
||||||
extraProps.currentTableName = currentTableName;
|
extraProps.currentTableName = currentTableName;
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ export const GroupingPanel: React.FC<Props> = ({
|
||||||
전체 해제
|
전체 해제
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
|
||||||
{selectedColumns.map((colName, index) => {
|
{selectedColumns.map((colName, index) => {
|
||||||
const col = table?.columns.find(
|
const col = table?.columns.find(
|
||||||
(c) => c.columnName === colName
|
(c) => c.columnName === colName
|
||||||
|
|
|
||||||
|
|
@ -557,7 +557,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
||||||
전체 해제
|
전체 해제
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
|
||||||
{selectedGroupColumns.map((colName, index) => {
|
{selectedGroupColumns.map((colName, index) => {
|
||||||
const col = table?.columns.find((c) => c.columnName === colName);
|
const col = table?.columns.find((c) => c.columnName === colName);
|
||||||
if (!col) return null;
|
if (!col) return null;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,22 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
addMonths,
|
||||||
|
subMonths,
|
||||||
|
startOfMonth,
|
||||||
|
endOfMonth,
|
||||||
|
eachDayOfInterval,
|
||||||
|
isSameMonth,
|
||||||
|
isSameDay,
|
||||||
|
isToday,
|
||||||
|
} from "date-fns";
|
||||||
|
import { ko } from "date-fns/locale";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||||
import { WidgetComponent, DateTypeConfig } from "@/types/screen";
|
import { WidgetComponent, DateTypeConfig } from "@/types/screen";
|
||||||
|
|
||||||
|
|
@ -10,99 +25,341 @@ export const DateWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
||||||
const { placeholder, required, style } = widget;
|
const { placeholder, required, style } = widget;
|
||||||
const config = widget.webTypeConfig as DateTypeConfig | undefined;
|
const config = widget.webTypeConfig as DateTypeConfig | undefined;
|
||||||
|
|
||||||
// 사용자가 테두리를 설정했는지 확인
|
|
||||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||||
const borderClass = hasCustomBorder ? "!border-0" : "";
|
const borderClass = hasCustomBorder ? "!border-0" : "";
|
||||||
|
|
||||||
// 날짜 포맷팅 함수
|
const isDatetime = widget.widgetType === "datetime";
|
||||||
const formatDateValue = (val: string) => {
|
|
||||||
if (!val) return "";
|
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
|
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||||
|
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||||
|
const [timeValue, setTimeValue] = useState("00:00");
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const [typingValue, setTypingValue] = useState("");
|
||||||
|
|
||||||
|
const parseDate = (val: string | undefined): Date | undefined => {
|
||||||
|
if (!val) return undefined;
|
||||||
try {
|
try {
|
||||||
const date = new Date(val);
|
const date = new Date(val);
|
||||||
if (isNaN(date.getTime())) return val;
|
if (isNaN(date.getTime())) return undefined;
|
||||||
|
return date;
|
||||||
if (widget.widgetType === "datetime") {
|
|
||||||
return date.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
|
|
||||||
} else {
|
|
||||||
return date.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
return val;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 날짜 유효성 검증
|
const getDefaultValue = (): string => {
|
||||||
const validateDate = (dateStr: string): boolean => {
|
|
||||||
if (!dateStr) return true;
|
|
||||||
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
if (isNaN(date.getTime())) return false;
|
|
||||||
|
|
||||||
// 최소/최대 날짜 검증
|
|
||||||
if (config?.minDate) {
|
|
||||||
const minDate = new Date(config.minDate);
|
|
||||||
if (date < minDate) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.maxDate) {
|
|
||||||
const maxDate = new Date(config.maxDate);
|
|
||||||
if (date > maxDate) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 입력값 처리
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const inputValue = e.target.value;
|
|
||||||
|
|
||||||
if (validateDate(inputValue)) {
|
|
||||||
onChange?.(inputValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 웹타입에 따른 input type 결정
|
|
||||||
const getInputType = () => {
|
|
||||||
switch (widget.widgetType) {
|
|
||||||
case "datetime":
|
|
||||||
return "datetime-local";
|
|
||||||
case "date":
|
|
||||||
default:
|
|
||||||
return "date";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 기본값 설정 (현재 날짜/시간)
|
|
||||||
const getDefaultValue = () => {
|
|
||||||
if (config?.defaultValue === "current") {
|
if (config?.defaultValue === "current") {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (widget.widgetType === "datetime") {
|
if (isDatetime) return now.toISOString().slice(0, 16);
|
||||||
return now.toISOString().slice(0, 16);
|
|
||||||
} else {
|
|
||||||
return now.toISOString().slice(0, 10);
|
return now.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const finalValue = value || getDefaultValue();
|
const finalValue = value || getDefaultValue();
|
||||||
|
const selectedDate = parseDate(finalValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setViewMode("calendar");
|
||||||
|
if (selectedDate) {
|
||||||
|
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||||
|
if (isDatetime) {
|
||||||
|
const hours = String(selectedDate.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(selectedDate.getMinutes()).padStart(2, "0");
|
||||||
|
setTimeValue(`${hours}:${minutes}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCurrentMonth(new Date());
|
||||||
|
setTimeValue("00:00");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsTyping(false);
|
||||||
|
setTypingValue("");
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const formatDisplayValue = (): string => {
|
||||||
|
if (!selectedDate) return "";
|
||||||
|
if (isDatetime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko });
|
||||||
|
return format(selectedDate, "yyyy-MM-dd", { locale: ko });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateClick = (date: Date) => {
|
||||||
|
let dateStr: string;
|
||||||
|
if (isDatetime) {
|
||||||
|
const [hours, minutes] = timeValue.split(":").map(Number);
|
||||||
|
const dt = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours || 0, minutes || 0);
|
||||||
|
dateStr = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}T${timeValue}`;
|
||||||
|
} else {
|
||||||
|
dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
onChange?.(dateStr);
|
||||||
|
if (!isDatetime) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeChange = (newTime: string) => {
|
||||||
|
setTimeValue(newTime);
|
||||||
|
if (selectedDate) {
|
||||||
|
const dateStr = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}T${newTime}`;
|
||||||
|
onChange?.(dateStr);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onChange?.("");
|
||||||
|
setIsTyping(false);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTriggerInput = (raw: string) => {
|
||||||
|
setIsTyping(true);
|
||||||
|
setTypingValue(raw);
|
||||||
|
if (!isOpen) setIsOpen(true);
|
||||||
|
const digitsOnly = raw.replace(/\D/g, "");
|
||||||
|
if (digitsOnly.length === 8) {
|
||||||
|
const y = parseInt(digitsOnly.slice(0, 4), 10);
|
||||||
|
const m = parseInt(digitsOnly.slice(4, 6), 10) - 1;
|
||||||
|
const d = parseInt(digitsOnly.slice(6, 8), 10);
|
||||||
|
const date = new Date(y, m, d);
|
||||||
|
if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) {
|
||||||
|
let dateStr: string;
|
||||||
|
if (isDatetime) {
|
||||||
|
dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T${timeValue}`;
|
||||||
|
} else {
|
||||||
|
dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
onChange?.(dateStr);
|
||||||
|
setCurrentMonth(new Date(y, m, 1));
|
||||||
|
if (!isDatetime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400);
|
||||||
|
else setIsTyping(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetToday = () => {
|
||||||
|
const today = new Date();
|
||||||
|
if (isDatetime) {
|
||||||
|
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`;
|
||||||
|
onChange?.(dateStr);
|
||||||
|
} else {
|
||||||
|
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||||
|
onChange?.(dateStr);
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthStart = startOfMonth(currentMonth);
|
||||||
|
const monthEnd = endOfMonth(currentMonth);
|
||||||
|
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||||
|
|
||||||
|
const startDate = new Date(monthStart);
|
||||||
|
const dayOfWeek = startDate.getDay();
|
||||||
|
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||||
|
const allDays = [...Array(paddingDays).fill(null), ...days];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Popover open={isOpen} onOpenChange={(v) => { if (!v) { setIsOpen(false); setIsTyping(false); } }}>
|
||||||
type={getInputType()}
|
<PopoverTrigger asChild>
|
||||||
value={formatDateValue(finalValue)}
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
|
||||||
|
readonly && "cursor-not-allowed opacity-50",
|
||||||
|
!selectedDate && !isTyping && "text-muted-foreground",
|
||||||
|
borderClass,
|
||||||
|
)}
|
||||||
|
onClick={() => { if (!readonly) setIsOpen(true); }}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={isTyping ? typingValue : (formatDisplayValue() || "")}
|
||||||
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
|
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
|
||||||
onChange={handleChange}
|
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
required={required}
|
onChange={(e) => handleTriggerInput(e.target.value)}
|
||||||
className={`h-full w-full ${borderClass}`}
|
onClick={(e) => e.stopPropagation()}
|
||||||
min={config?.minDate}
|
onFocus={() => { if (!readonly && !isOpen) setIsOpen(true); }}
|
||||||
max={config?.maxDate}
|
onBlur={() => { if (!isOpen) setIsTyping(false); }}
|
||||||
|
className="h-full w-full truncate bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
|
{selectedDate && !readonly && !isTyping && (
|
||||||
|
<X
|
||||||
|
className="text-muted-foreground hover:text-foreground ml-auto h-3.5 w-3.5 shrink-0 cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClear();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleSetToday}>
|
||||||
|
오늘
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === "year" ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{yearRangeStart} - {yearRangeStart + 11}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||||
|
<Button
|
||||||
|
key={year}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-9 text-xs",
|
||||||
|
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||||
|
setViewMode("month");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : viewMode === "month" ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||||
|
setViewMode("year");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentMonth.getFullYear()}년
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||||
|
<Button
|
||||||
|
key={month}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-9 text-xs",
|
||||||
|
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||||
|
setViewMode("calendar");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{month + 1}월
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||||
|
setViewMode("year");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||||
|
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||||
|
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||||
|
{allDays.map((date, index) => {
|
||||||
|
if (!date) return <div key={index} className="p-2" />;
|
||||||
|
|
||||||
|
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||||
|
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
|
||||||
|
const isTodayDate = isToday(date);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={date.toISOString()}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 p-0 text-xs",
|
||||||
|
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||||
|
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
isTodayDate && !isSelected && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => handleDateClick(date)}
|
||||||
|
disabled={!isCurrentMonth}
|
||||||
|
>
|
||||||
|
{format(date, "d")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* datetime 타입: 시간 입력 */}
|
||||||
|
{isDatetime && viewMode === "calendar" && (
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs">시간:</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={timeValue}
|
||||||
|
onChange={(e) => handleTimeChange(e.target.value)}
|
||||||
|
className="border-input h-8 rounded-md border px-2 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
DateWidget.displayName = "DateWidget";
|
DateWidget.displayName = "DateWidget";
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||||
import { FolderTree, Loader2, Search, X } from "lucide-react";
|
import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
interface CategoryColumn {
|
interface CategoryColumn {
|
||||||
|
|
@ -30,6 +30,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
const [columns, setColumns] = useState<CategoryColumn[]>([]);
|
const [columns, setColumns] = useState<CategoryColumn[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 검색어로 필터링된 컬럼 목록
|
// 검색어로 필터링된 컬럼 목록
|
||||||
const filteredColumns = useMemo(() => {
|
const filteredColumns = useMemo(() => {
|
||||||
|
|
@ -49,6 +50,44 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
});
|
});
|
||||||
}, [columns, searchQuery]);
|
}, [columns, searchQuery]);
|
||||||
|
|
||||||
|
// 테이블별로 그룹화된 컬럼 목록
|
||||||
|
const groupedColumns = useMemo(() => {
|
||||||
|
const groups: { tableName: string; tableLabel: string; columns: CategoryColumn[] }[] = [];
|
||||||
|
const groupMap = new Map<string, CategoryColumn[]>();
|
||||||
|
|
||||||
|
for (const col of filteredColumns) {
|
||||||
|
const key = col.tableName;
|
||||||
|
if (!groupMap.has(key)) {
|
||||||
|
groupMap.set(key, []);
|
||||||
|
}
|
||||||
|
groupMap.get(key)!.push(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [tblName, cols] of groupMap) {
|
||||||
|
groups.push({
|
||||||
|
tableName: tblName,
|
||||||
|
tableLabel: cols[0]?.tableLabel || tblName,
|
||||||
|
columns: cols,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [filteredColumns]);
|
||||||
|
|
||||||
|
// 선택된 컬럼이 있는 그룹을 자동 펼침
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedColumn) return;
|
||||||
|
const tableName = selectedColumn.split(".")[0];
|
||||||
|
if (tableName) {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
if (prev.has(tableName)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(tableName);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedColumn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회
|
// 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회
|
||||||
loadCategoryColumnsByMenu();
|
loadCategoryColumnsByMenu();
|
||||||
|
|
@ -72,9 +111,10 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
allColumns = response.data;
|
allColumns = response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// category 타입 컬럼만 필터링
|
// category 타입 중 자체 카테고리만 필터링 (참조 컬럼 제외)
|
||||||
const categoryColumns = allColumns.filter(
|
const categoryColumns = allColumns.filter(
|
||||||
(col: any) => col.inputType === "category" || col.input_type === "category"
|
(col: any) => (col.inputType === "category" || col.input_type === "category")
|
||||||
|
&& !col.categoryRef && !col.category_ref
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("✅ 카테고리 컬럼 필터링 완료:", {
|
console.log("✅ 카테고리 컬럼 필터링 완료:", {
|
||||||
|
|
@ -278,15 +318,26 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
{filteredColumns.length === 0 && searchQuery ? (
|
{filteredColumns.length === 0 && searchQuery ? (
|
||||||
<div className="text-muted-foreground py-4 text-center text-xs">
|
<div className="text-muted-foreground py-4 text-center text-xs">
|
||||||
'{searchQuery}'에 대한 검색 결과가 없습니다
|
'{searchQuery}'에 대한 검색 결과가 없습니다
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{filteredColumns.map((column) => {
|
{groupedColumns.map((group) => {
|
||||||
|
const isExpanded = expandedGroups.has(group.tableName);
|
||||||
|
const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0);
|
||||||
|
const hasSelectedInGroup = group.columns.some(
|
||||||
|
(c) => selectedColumn === `${c.tableName}.${c.columnName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 그룹이 1개뿐이면 드롭다운 없이 바로 표시
|
||||||
|
if (groupedColumns.length <= 1) {
|
||||||
|
return (
|
||||||
|
<div key={group.tableName} className="space-y-1.5">
|
||||||
|
{group.columns.map((column) => {
|
||||||
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
||||||
const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교
|
const isSelected = selectedColumn === uniqueKey;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={uniqueKey}
|
key={uniqueKey}
|
||||||
|
|
@ -311,6 +362,74 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.tableName} className="overflow-hidden rounded-lg border">
|
||||||
|
{/* 드롭다운 헤더 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(group.tableName)) {
|
||||||
|
next.delete(group.tableName);
|
||||||
|
} else {
|
||||||
|
next.add(group.tableName);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left transition-colors ${
|
||||||
|
hasSelectedInGroup ? "bg-primary/5" : "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={`h-3.5 w-3.5 shrink-0 transition-transform duration-200 ${
|
||||||
|
isExpanded ? "rotate-90" : ""
|
||||||
|
} ${hasSelectedInGroup ? "text-primary" : "text-muted-foreground"}`}
|
||||||
|
/>
|
||||||
|
<span className={`flex-1 text-xs font-semibold ${hasSelectedInGroup ? "text-primary" : ""}`}>
|
||||||
|
{group.tableLabel}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
{group.columns.length}개 컬럼 / {totalValues}개 값
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 펼쳐진 컬럼 목록 */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="space-y-1 border-t px-2 py-2">
|
||||||
|
{group.columns.map((column) => {
|
||||||
|
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
||||||
|
const isSelected = selectedColumn === uniqueKey;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={uniqueKey}
|
||||||
|
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
|
||||||
|
className={`cursor-pointer rounded-md px-3 py-1.5 transition-all ${
|
||||||
|
isSelected ? "bg-primary/10 font-semibold text-primary" : "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderTree
|
||||||
|
className={`h-3.5 w-3.5 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-xs">{column.columnLabel || column.columnName}</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
{column.valueCount !== undefined ? `${column.valueCount}개` : "..."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-[999] bg-black/80",
|
"fixed inset-0 z-[1050] bg-black/80",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
|
"bg-background fixed top-[50%] left-[50%] z-[1100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,13 @@
|
||||||
* - range 옵션: 범위 선택 (시작~종료)
|
* - range 옵션: 범위 선택 (시작~종료)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { forwardRef, useCallback, useMemo, useState } from "react";
|
import React, { forwardRef, useCallback, useMemo, useState, useEffect } from "react";
|
||||||
import { format, parse, isValid } from "date-fns";
|
import { format, parse, isValid, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, isToday as isTodayFn } from "date-fns";
|
||||||
import { ko } from "date-fns/locale";
|
import { ko } from "date-fns/locale";
|
||||||
import { Calendar as CalendarIcon, Clock } from "lucide-react";
|
import { Calendar as CalendarIcon, Clock, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { V2DateProps, V2DateType } from "@/types/v2-components";
|
import { V2DateProps, V2DateType } from "@/types/v2-components";
|
||||||
|
|
@ -60,11 +59,24 @@ function formatDate(date: Date | undefined, formatStr: string): string {
|
||||||
return format(date, dateFnsFormat);
|
return format(date, dateFnsFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YYYYMMDD 또는 YYYY-MM-DD 문자열 → 유효한 Date 객체 반환 (유효하지 않으면 null)
|
||||||
|
function parseManualDateInput(raw: string): Date | null {
|
||||||
|
const digits = raw.replace(/\D/g, "");
|
||||||
|
if (digits.length !== 8) return null;
|
||||||
|
const y = parseInt(digits.slice(0, 4), 10);
|
||||||
|
const m = parseInt(digits.slice(4, 6), 10) - 1;
|
||||||
|
const d = parseInt(digits.slice(6, 8), 10);
|
||||||
|
const date = new Date(y, m, d);
|
||||||
|
if (date.getFullYear() !== y || date.getMonth() !== m || date.getDate() !== d) return null;
|
||||||
|
if (y < 1900 || y > 2100) return null;
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 단일 날짜 선택 컴포넌트
|
* 단일 날짜 선택 컴포넌트
|
||||||
*/
|
*/
|
||||||
const SingleDatePicker = forwardRef<
|
const SingleDatePicker = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLDivElement,
|
||||||
{
|
{
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
|
@ -83,81 +95,228 @@ const SingleDatePicker = forwardRef<
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
|
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||||
|
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const [typingValue, setTypingValue] = useState("");
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]);
|
const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]);
|
||||||
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
|
|
||||||
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
|
|
||||||
|
|
||||||
// 표시할 날짜 텍스트 계산 (ISO 형식이면 포맷팅, 아니면 그대로)
|
|
||||||
const displayText = useMemo(() => {
|
const displayText = useMemo(() => {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
// Date 객체로 변환 후 포맷팅
|
if (date && isValid(date)) return formatDate(date, dateFormat);
|
||||||
if (date && isValid(date)) {
|
|
||||||
return formatDate(date, dateFormat);
|
|
||||||
}
|
|
||||||
return value;
|
return value;
|
||||||
}, [value, date, dateFormat]);
|
}, [value, date, dateFormat]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
useEffect(() => {
|
||||||
(selectedDate: Date | undefined) => {
|
if (open) {
|
||||||
if (selectedDate) {
|
setViewMode("calendar");
|
||||||
onChange?.(formatDate(selectedDate, dateFormat));
|
if (date && isValid(date)) {
|
||||||
setOpen(false);
|
setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1));
|
||||||
|
setYearRangeStart(Math.floor(date.getFullYear() / 12) * 12);
|
||||||
|
} else {
|
||||||
|
setCurrentMonth(new Date());
|
||||||
|
setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12);
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
[dateFormat, onChange],
|
setIsTyping(false);
|
||||||
);
|
setTypingValue("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleDateClick = useCallback((clickedDate: Date) => {
|
||||||
|
onChange?.(formatDate(clickedDate, dateFormat));
|
||||||
|
setIsTyping(false);
|
||||||
|
setOpen(false);
|
||||||
|
}, [dateFormat, onChange]);
|
||||||
|
|
||||||
const handleToday = useCallback(() => {
|
const handleToday = useCallback(() => {
|
||||||
onChange?.(formatDate(new Date(), dateFormat));
|
onChange?.(formatDate(new Date(), dateFormat));
|
||||||
|
setIsTyping(false);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [dateFormat, onChange]);
|
}, [dateFormat, onChange]);
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
onChange?.("");
|
onChange?.("");
|
||||||
|
setIsTyping(false);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [onChange]);
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleTriggerInput = useCallback((raw: string) => {
|
||||||
|
setIsTyping(true);
|
||||||
|
setTypingValue(raw);
|
||||||
|
if (!open) setOpen(true);
|
||||||
|
const digitsOnly = raw.replace(/\D/g, "");
|
||||||
|
if (digitsOnly.length === 8) {
|
||||||
|
const parsed = parseManualDateInput(digitsOnly);
|
||||||
|
if (parsed) {
|
||||||
|
onChange?.(formatDate(parsed, dateFormat));
|
||||||
|
setCurrentMonth(new Date(parsed.getFullYear(), parsed.getMonth(), 1));
|
||||||
|
setTimeout(() => { setIsTyping(false); setOpen(false); }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [dateFormat, onChange, open]);
|
||||||
|
|
||||||
|
const mStart = startOfMonth(currentMonth);
|
||||||
|
const mEnd = endOfMonth(currentMonth);
|
||||||
|
const days = eachDayOfInterval({ start: mStart, end: mEnd });
|
||||||
|
const dow = mStart.getDay();
|
||||||
|
const padding = dow === 0 ? 6 : dow - 1;
|
||||||
|
const allDays = [...Array(padding).fill(null), ...days];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={(v) => { if (!v) { setOpen(false); setIsTyping(false); } }}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
variant="outline"
|
|
||||||
disabled={disabled || readonly}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full w-full justify-start text-left font-normal",
|
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
|
||||||
!displayText && "text-muted-foreground",
|
(disabled || readonly) && "cursor-not-allowed opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onClick={() => { if (!disabled && !readonly) setOpen(true); }}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||||
{displayText || placeholder}
|
<input
|
||||||
</Button>
|
ref={inputRef}
|
||||||
</PopoverTrigger>
|
type="text"
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
inputMode="numeric"
|
||||||
<Calendar
|
value={isTyping ? typingValue : (displayText || "")}
|
||||||
mode="single"
|
placeholder={placeholder}
|
||||||
selected={date}
|
disabled={disabled || readonly}
|
||||||
onSelect={handleSelect}
|
onChange={(e) => handleTriggerInput(e.target.value)}
|
||||||
initialFocus
|
onClick={(e) => e.stopPropagation()}
|
||||||
locale={ko}
|
onFocus={() => { if (!disabled && !readonly && !open) setOpen(true); }}
|
||||||
disabled={(date) => {
|
onBlur={() => { if (!open) setIsTyping(false); }}
|
||||||
if (minDateObj && date < minDateObj) return true;
|
className={cn(
|
||||||
if (maxDateObj && date > maxDateObj) return true;
|
"h-full w-full bg-transparent text-sm outline-none",
|
||||||
return false;
|
"placeholder:text-muted-foreground disabled:cursor-not-allowed",
|
||||||
}}
|
!displayText && !isTyping && "text-muted-foreground",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2 p-3 pt-0">
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
{showToday && (
|
{showToday && (
|
||||||
<Button variant="outline" size="sm" onClick={handleToday}>
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleToday}>
|
||||||
오늘
|
오늘
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="sm" onClick={handleClear}>
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
|
||||||
초기화
|
초기화
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{viewMode === "year" ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||||
|
<Button
|
||||||
|
key={year}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-9 text-xs",
|
||||||
|
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => { setCurrentMonth(new Date(year, currentMonth.getMonth(), 1)); setViewMode("month"); }}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : viewMode === "month" ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>
|
||||||
|
{currentMonth.getFullYear()}년
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||||
|
<Button
|
||||||
|
key={month}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-9 text-xs",
|
||||||
|
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => { setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1)); setViewMode("calendar"); }}
|
||||||
|
>
|
||||||
|
{month + 1}월
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>
|
||||||
|
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||||
|
{["월", "화", "수", "목", "금", "토", "일"].map((d) => (
|
||||||
|
<div key={d} className="text-muted-foreground p-2 text-center text-xs font-medium">{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{allDays.map((d, idx) => {
|
||||||
|
if (!d) return <div key={idx} className="p-2" />;
|
||||||
|
const isCur = isSameMonth(d, currentMonth);
|
||||||
|
const isSel = date ? isSameDay(d, date) : false;
|
||||||
|
const isT = isTodayFn(d);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={d.toISOString()}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 p-0 text-xs",
|
||||||
|
!isCur && "text-muted-foreground opacity-50",
|
||||||
|
isSel && "bg-primary text-primary-foreground hover:bg-primary",
|
||||||
|
isT && !isSel && "border-primary border",
|
||||||
|
)}
|
||||||
|
onClick={() => handleDateClick(d)}
|
||||||
|
disabled={!isCur}
|
||||||
|
>
|
||||||
|
{format(d, "d")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|
@ -168,6 +327,149 @@ SingleDatePicker.displayName = "SingleDatePicker";
|
||||||
/**
|
/**
|
||||||
* 날짜 범위 선택 컴포넌트
|
* 날짜 범위 선택 컴포넌트
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 범위 날짜 팝오버 내부 캘린더 (drill-down 지원)
|
||||||
|
*/
|
||||||
|
const RangeCalendarPopover: React.FC<{
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
selectedDate?: Date;
|
||||||
|
onSelect: (date: Date) => void;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
displayValue?: string;
|
||||||
|
}> = ({ open, onOpenChange, selectedDate, onSelect, label, disabled, readonly, displayValue }) => {
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
|
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||||
|
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const [typingValue, setTypingValue] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setViewMode("calendar");
|
||||||
|
if (selectedDate && isValid(selectedDate)) {
|
||||||
|
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||||
|
setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12);
|
||||||
|
} else {
|
||||||
|
setCurrentMonth(new Date());
|
||||||
|
setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsTyping(false);
|
||||||
|
setTypingValue("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleTriggerInput = (raw: string) => {
|
||||||
|
setIsTyping(true);
|
||||||
|
setTypingValue(raw);
|
||||||
|
const digitsOnly = raw.replace(/\D/g, "");
|
||||||
|
if (digitsOnly.length === 8) {
|
||||||
|
const parsed = parseManualDateInput(digitsOnly);
|
||||||
|
if (parsed) {
|
||||||
|
setIsTyping(false);
|
||||||
|
onSelect(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mStart = startOfMonth(currentMonth);
|
||||||
|
const mEnd = endOfMonth(currentMonth);
|
||||||
|
const days = eachDayOfInterval({ start: mStart, end: mEnd });
|
||||||
|
const dow = mStart.getDay();
|
||||||
|
const padding = dow === 0 ? 6 : dow - 1;
|
||||||
|
const allDays = [...Array(padding).fill(null), ...days];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={(v) => { if (!v) { setIsTyping(false); } onOpenChange(v); }}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-input bg-background flex h-full flex-1 cursor-pointer items-center rounded-md border px-3",
|
||||||
|
(disabled || readonly) && "cursor-not-allowed opacity-50",
|
||||||
|
!displayValue && !isTyping && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => { if (!disabled && !readonly) onOpenChange(true); }}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={isTyping ? typingValue : (displayValue || "")}
|
||||||
|
placeholder={label}
|
||||||
|
disabled={disabled || readonly}
|
||||||
|
onChange={(e) => handleTriggerInput(e.target.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onFocus={() => { if (!disabled && !readonly && !open) onOpenChange(true); }}
|
||||||
|
onBlur={() => { if (!open) setIsTyping(false); }}
|
||||||
|
className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||||
|
<div className="p-4">
|
||||||
|
{viewMode === "year" ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}><ChevronLeft className="h-4 w-4" /></Button>
|
||||||
|
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}><ChevronRight className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||||
|
<Button key={year} variant="ghost" size="sm" className={cn("h-9 text-xs", year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary", year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border")}
|
||||||
|
onClick={() => { setCurrentMonth(new Date(year, currentMonth.getMonth(), 1)); setViewMode("month"); }}>{year}</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : viewMode === "month" ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}><ChevronLeft className="h-4 w-4" /></Button>
|
||||||
|
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>{currentMonth.getFullYear()}년</button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}><ChevronRight className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||||
|
<Button key={month} variant="ghost" size="sm" className={cn("h-9 text-xs", month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary", month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border")}
|
||||||
|
onClick={() => { setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1)); setViewMode("calendar"); }}>{month + 1}월</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}><ChevronLeft className="h-4 w-4" /></Button>
|
||||||
|
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>{format(currentMonth, "yyyy년 MM월", { locale: ko })}</button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}><ChevronRight className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||||
|
{["월", "화", "수", "목", "금", "토", "일"].map((d) => (
|
||||||
|
<div key={d} className="text-muted-foreground p-2 text-center text-xs font-medium">{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{allDays.map((d, idx) => {
|
||||||
|
if (!d) return <div key={idx} className="p-2" />;
|
||||||
|
const isCur = isSameMonth(d, currentMonth);
|
||||||
|
const isSel = selectedDate ? isSameDay(d, selectedDate) : false;
|
||||||
|
const isT = isTodayFn(d);
|
||||||
|
return (
|
||||||
|
<Button key={d.toISOString()} variant="ghost" size="sm" className={cn("h-8 w-8 p-0 text-xs", !isCur && "text-muted-foreground opacity-50", isSel && "bg-primary text-primary-foreground hover:bg-primary", isT && !isSel && "border-primary border")}
|
||||||
|
onClick={() => onSelect(d)} disabled={!isCur}>{format(d, "d")}</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const RangeDatePicker = forwardRef<
|
const RangeDatePicker = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
{
|
{
|
||||||
|
|
@ -186,102 +488,38 @@ const RangeDatePicker = forwardRef<
|
||||||
|
|
||||||
const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]);
|
const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]);
|
||||||
const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]);
|
const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]);
|
||||||
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
|
|
||||||
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
|
|
||||||
|
|
||||||
const handleStartSelect = useCallback(
|
const handleStartSelect = useCallback(
|
||||||
(date: Date | undefined) => {
|
(date: Date) => {
|
||||||
if (date) {
|
|
||||||
const newStart = formatDate(date, dateFormat);
|
const newStart = formatDate(date, dateFormat);
|
||||||
// 시작일이 종료일보다 크면 종료일도 같이 변경
|
|
||||||
if (endDate && date > endDate) {
|
if (endDate && date > endDate) {
|
||||||
onChange?.([newStart, newStart]);
|
onChange?.([newStart, newStart]);
|
||||||
} else {
|
} else {
|
||||||
onChange?.([newStart, value[1]]);
|
onChange?.([newStart, value[1]]);
|
||||||
}
|
}
|
||||||
setOpenStart(false);
|
setOpenStart(false);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[value, dateFormat, endDate, onChange],
|
[value, dateFormat, endDate, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEndSelect = useCallback(
|
const handleEndSelect = useCallback(
|
||||||
(date: Date | undefined) => {
|
(date: Date) => {
|
||||||
if (date) {
|
|
||||||
const newEnd = formatDate(date, dateFormat);
|
const newEnd = formatDate(date, dateFormat);
|
||||||
// 종료일이 시작일보다 작으면 시작일도 같이 변경
|
|
||||||
if (startDate && date < startDate) {
|
if (startDate && date < startDate) {
|
||||||
onChange?.([newEnd, newEnd]);
|
onChange?.([newEnd, newEnd]);
|
||||||
} else {
|
} else {
|
||||||
onChange?.([value[0], newEnd]);
|
onChange?.([value[0], newEnd]);
|
||||||
}
|
}
|
||||||
setOpenEnd(false);
|
setOpenEnd(false);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[value, dateFormat, startDate, onChange],
|
[value, dateFormat, startDate, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={cn("flex items-center gap-2 h-full", className)}>
|
<div ref={ref} className={cn("flex items-center gap-2 h-full", className)}>
|
||||||
{/* 시작 날짜 */}
|
<RangeCalendarPopover open={openStart} onOpenChange={setOpenStart} selectedDate={startDate} onSelect={handleStartSelect} label="시작일" disabled={disabled} readonly={readonly} displayValue={value[0]} />
|
||||||
<Popover open={openStart} onOpenChange={setOpenStart}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={disabled || readonly}
|
|
||||||
className={cn("h-full flex-1 justify-start text-left font-normal", !value[0] && "text-muted-foreground")}
|
|
||||||
>
|
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
||||||
{value[0] || "시작일"}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={startDate}
|
|
||||||
onSelect={handleStartSelect}
|
|
||||||
initialFocus
|
|
||||||
locale={ko}
|
|
||||||
disabled={(date) => {
|
|
||||||
if (minDateObj && date < minDateObj) return true;
|
|
||||||
if (maxDateObj && date > maxDateObj) return true;
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<span className="text-muted-foreground">~</span>
|
<span className="text-muted-foreground">~</span>
|
||||||
|
<RangeCalendarPopover open={openEnd} onOpenChange={setOpenEnd} selectedDate={endDate} onSelect={handleEndSelect} label="종료일" disabled={disabled} readonly={readonly} displayValue={value[1]} />
|
||||||
{/* 종료 날짜 */}
|
|
||||||
<Popover open={openEnd} onOpenChange={setOpenEnd}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={disabled || readonly}
|
|
||||||
className={cn("h-full flex-1 justify-start text-left font-normal", !value[1] && "text-muted-foreground")}
|
|
||||||
>
|
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
||||||
{value[1] || "종료일"}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={endDate}
|
|
||||||
onSelect={handleEndSelect}
|
|
||||||
initialFocus
|
|
||||||
locale={ko}
|
|
||||||
disabled={(date) => {
|
|
||||||
if (minDateObj && date < minDateObj) return true;
|
|
||||||
if (maxDateObj && date > maxDateObj) return true;
|
|
||||||
// 시작일보다 이전 날짜는 선택 불가
|
|
||||||
if (startDate && date < startDate) return true;
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ import {
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { allocateNumberingCode } from "@/lib/api/numberingRule";
|
import { allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||||
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
||||||
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
|
import { DataReceivable } from "@/types/data-transfer";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// modal-repeater-table 컴포넌트 재사용
|
// modal-repeater-table 컴포넌트 재사용
|
||||||
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
|
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
|
||||||
|
|
@ -38,13 +41,23 @@ declare global {
|
||||||
|
|
||||||
export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
config: propConfig,
|
config: propConfig,
|
||||||
|
componentId,
|
||||||
parentId,
|
parentId,
|
||||||
data: initialData,
|
data: initialData,
|
||||||
onDataChange,
|
onDataChange,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
className,
|
className,
|
||||||
formData: parentFormData,
|
formData: parentFormData,
|
||||||
|
...restProps
|
||||||
}) => {
|
}) => {
|
||||||
|
// ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용)
|
||||||
|
const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData;
|
||||||
|
|
||||||
|
// componentId 결정: 직접 전달 또는 component 객체에서 추출
|
||||||
|
const effectiveComponentId = componentId || (restProps as any).component?.id;
|
||||||
|
|
||||||
|
// ScreenContext 연동 (DataReceiver 등록, Provider 없으면 null)
|
||||||
|
const screenContext = useScreenContextOptional();
|
||||||
// 설정 병합
|
// 설정 병합
|
||||||
const config: V2RepeaterConfig = useMemo(
|
const config: V2RepeaterConfig = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -62,9 +75,119 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// 저장 이벤트 핸들러에서 항상 최신 data를 참조하기 위한 ref
|
||||||
|
const dataRef = useRef<any[]>(data);
|
||||||
|
useEffect(() => {
|
||||||
|
dataRef.current = data;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// 수정 모드에서 로드된 원본 ID 목록 (삭제 추적용)
|
||||||
|
const loadedIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
||||||
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
||||||
|
|
||||||
|
// ScreenContext DataReceiver 등록 (데이터 전달 액션 수신)
|
||||||
|
const onDataChangeRef = useRef(onDataChange);
|
||||||
|
onDataChangeRef.current = onDataChange;
|
||||||
|
|
||||||
|
const handleReceiveData = useCallback(
|
||||||
|
async (incomingData: any[], configOrMode?: any) => {
|
||||||
|
console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode });
|
||||||
|
|
||||||
|
if (!incomingData || incomingData.length === 0) {
|
||||||
|
toast.warning("전달할 데이터가 없습니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거
|
||||||
|
const metaFieldsToStrip = new Set([
|
||||||
|
"id",
|
||||||
|
"created_date",
|
||||||
|
"updated_date",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
"company_code",
|
||||||
|
]);
|
||||||
|
const normalizedData = incomingData.map((item: any) => {
|
||||||
|
let raw = item;
|
||||||
|
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
|
||||||
|
const { 0: originalData, ...additionalFields } = item;
|
||||||
|
raw = { ...originalData, ...additionalFields };
|
||||||
|
}
|
||||||
|
const cleaned: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (!metaFieldsToStrip.has(key)) {
|
||||||
|
cleaned[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mode = configOrMode?.mode || configOrMode || "append";
|
||||||
|
|
||||||
|
// 카테고리 코드 → 라벨 변환
|
||||||
|
// allCategoryColumns 또는 fromMainForm 컬럼의 값을 라벨로 변환
|
||||||
|
const codesToResolve = new Set<string>();
|
||||||
|
for (const item of normalizedData) {
|
||||||
|
for (const [key, val] of Object.entries(item)) {
|
||||||
|
if (key.startsWith("_")) continue;
|
||||||
|
if (typeof val === "string" && val && !categoryLabelMapRef.current[val]) {
|
||||||
|
codesToResolve.add(val as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codesToResolve.size > 0) {
|
||||||
|
try {
|
||||||
|
const resp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: Array.from(codesToResolve),
|
||||||
|
});
|
||||||
|
if (resp.data?.success && resp.data.data) {
|
||||||
|
const labelData = resp.data.data as Record<string, string>;
|
||||||
|
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||||
|
for (const item of normalizedData) {
|
||||||
|
for (const key of Object.keys(item)) {
|
||||||
|
if (key.startsWith("_")) continue;
|
||||||
|
const val = item[key];
|
||||||
|
if (typeof val === "string" && labelData[val]) {
|
||||||
|
item[key] = labelData[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 변환 실패 시 코드 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setData((prev) => {
|
||||||
|
const next = mode === "replace" ? normalizedData : [...prev, ...normalizedData];
|
||||||
|
onDataChangeRef.current?.(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (screenContext && effectiveComponentId) {
|
||||||
|
const receiver: DataReceivable = {
|
||||||
|
componentId: effectiveComponentId,
|
||||||
|
componentType: "v2-repeater",
|
||||||
|
receiveData: handleReceiveData,
|
||||||
|
};
|
||||||
|
console.log("📋 [V2Repeater] ScreenContext에 데이터 수신자 등록:", effectiveComponentId);
|
||||||
|
screenContext.registerDataReceiver(effectiveComponentId, receiver);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
screenContext.unregisterDataReceiver(effectiveComponentId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [screenContext, effectiveComponentId, handleReceiveData]);
|
||||||
|
|
||||||
// 소스 테이블 컬럼 라벨 매핑
|
// 소스 테이블 컬럼 라벨 매핑
|
||||||
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
|
@ -73,6 +196,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
|
|
||||||
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
|
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
|
||||||
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
||||||
|
const categoryLabelMapRef = useRef<Record<string, string>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
categoryLabelMapRef.current = categoryLabelMap;
|
||||||
|
}, [categoryLabelMap]);
|
||||||
|
|
||||||
// 현재 테이블 컬럼 정보 (inputType 매핑용)
|
// 현재 테이블 컬럼 정보 (inputType 매핑용)
|
||||||
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
|
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
|
||||||
|
|
@ -106,35 +233,54 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
};
|
};
|
||||||
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
|
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
|
||||||
|
|
||||||
// 저장 이벤트 리스너
|
// 저장 이벤트 리스너 (dataRef/categoryLabelMapRef를 사용하여 항상 최신 상태 참조)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSaveEvent = async (event: CustomEvent) => {
|
const handleSaveEvent = async (event: CustomEvent) => {
|
||||||
// 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
|
const currentData = dataRef.current;
|
||||||
const tableName =
|
const currentCategoryMap = categoryLabelMapRef.current;
|
||||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
||||||
const eventParentId = event.detail?.parentId;
|
|
||||||
const mainFormData = event.detail?.mainFormData;
|
|
||||||
|
|
||||||
// 🆕 마스터 테이블에서 생성된 ID (FK 연결용)
|
const configTableName =
|
||||||
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||||
|
const tableName = configTableName || event.detail?.tableName;
|
||||||
|
const mainFormData = event.detail?.mainFormData;
|
||||||
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
|
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
|
||||||
|
|
||||||
if (!tableName || data.length === 0) {
|
console.log("🔵 [V2Repeater] repeaterSave 이벤트 수신:", {
|
||||||
|
configTableName,
|
||||||
|
tableName,
|
||||||
|
masterRecordId,
|
||||||
|
dataLength: currentData.length,
|
||||||
|
foreignKeyColumn: config.foreignKeyColumn,
|
||||||
|
foreignKeySourceColumn: config.foreignKeySourceColumn,
|
||||||
|
dataSnapshot: currentData.map((r: any) => ({ id: r.id, item_name: r.item_name })),
|
||||||
|
});
|
||||||
|
toast.info(`[디버그] V2Repeater 이벤트 수신: ${currentData.length}건, table=${tableName}`);
|
||||||
|
|
||||||
|
if (!tableName || currentData.length === 0) {
|
||||||
|
console.warn("🔴 [V2Repeater] 저장 스킵:", { tableName, dataLength: currentData.length });
|
||||||
|
toast.warning(`[디버그] V2Repeater 저장 스킵: data=${currentData.length}, table=${tableName}`);
|
||||||
|
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// V2Repeater 저장 시작
|
if (config.foreignKeyColumn) {
|
||||||
const saveInfo = {
|
const sourceCol = config.foreignKeySourceColumn;
|
||||||
|
const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined;
|
||||||
|
if (!hasFkSource && !masterRecordId) {
|
||||||
|
console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵");
|
||||||
|
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("V2Repeater 저장 시작", {
|
||||||
tableName,
|
tableName,
|
||||||
useCustomTable: config.useCustomTable,
|
|
||||||
mainTableName: config.mainTableName,
|
|
||||||
foreignKeyColumn: config.foreignKeyColumn,
|
foreignKeyColumn: config.foreignKeyColumn,
|
||||||
masterRecordId,
|
masterRecordId,
|
||||||
dataLength: data.length,
|
dataLength: currentData.length,
|
||||||
};
|
});
|
||||||
console.log("V2Repeater 저장 시작", saveInfo);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 테이블 유효 컬럼 조회
|
|
||||||
let validColumns: Set<string> = new Set();
|
let validColumns: Set<string> = new Set();
|
||||||
try {
|
try {
|
||||||
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||||
|
|
@ -145,13 +291,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
console.warn("테이블 컬럼 정보 조회 실패");
|
console.warn("테이블 컬럼 정보 조회 실패");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < currentData.length; i++) {
|
||||||
const row = data[i];
|
const row = currentData[i];
|
||||||
|
|
||||||
// 내부 필드 제거
|
|
||||||
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
|
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
|
||||||
|
|
||||||
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
|
|
||||||
let mergedData: Record<string, any>;
|
let mergedData: Record<string, any>;
|
||||||
if (config.useCustomTable && config.mainTableName) {
|
if (config.useCustomTable && config.mainTableName) {
|
||||||
mergedData = { ...cleanRow };
|
mergedData = { ...cleanRow };
|
||||||
|
|
@ -178,59 +321,83 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유효하지 않은 컬럼 제거
|
|
||||||
const filteredData: Record<string, any> = {};
|
const filteredData: Record<string, any> = {};
|
||||||
for (const [key, value] of Object.entries(mergedData)) {
|
for (const [key, value] of Object.entries(mergedData)) {
|
||||||
if (validColumns.size === 0 || validColumns.has(key)) {
|
if (validColumns.size === 0 || validColumns.has(key)) {
|
||||||
|
if (typeof value === "string" && currentCategoryMap[value]) {
|
||||||
|
filteredData[key] = currentCategoryMap[value];
|
||||||
|
} else {
|
||||||
filteredData[key] = value;
|
filteredData[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 기존 행(id 존재)은 UPDATE, 새 행은 INSERT
|
|
||||||
const rowId = row.id;
|
const rowId = row.id;
|
||||||
|
console.log(`🔧 [V2Repeater] 행 ${i} 저장:`, {
|
||||||
|
rowId,
|
||||||
|
isUpdate: rowId && typeof rowId === "string" && rowId.includes("-"),
|
||||||
|
filteredDataKeys: Object.keys(filteredData),
|
||||||
|
});
|
||||||
if (rowId && typeof rowId === "string" && rowId.includes("-")) {
|
if (rowId && typeof rowId === "string" && rowId.includes("-")) {
|
||||||
// UUID 형태의 id가 있으면 기존 데이터 → UPDATE
|
|
||||||
const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData;
|
const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData;
|
||||||
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
|
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
|
||||||
originalData: { id: rowId },
|
originalData: { id: rowId },
|
||||||
updatedData: updateFields,
|
updatedData: updateFields,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 새 행 → INSERT
|
|
||||||
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
|
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 삭제된 행 처리: 원본에는 있었지만 현재 data에 없는 ID를 DELETE
|
||||||
|
const currentIds = new Set(currentData.map((r) => r.id).filter(Boolean));
|
||||||
|
const deletedIds = Array.from(loadedIdsRef.current).filter((id) => !currentIds.has(id));
|
||||||
|
if (deletedIds.length > 0) {
|
||||||
|
console.log("🗑️ [V2Repeater] 삭제할 행:", deletedIds);
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/table-management/tables/${tableName}/delete`, {
|
||||||
|
data: deletedIds.map((id) => ({ id })),
|
||||||
|
});
|
||||||
|
console.log(`✅ [V2Repeater] ${deletedIds.length}건 삭제 완료`);
|
||||||
|
} catch (deleteError) {
|
||||||
|
console.error("❌ [V2Repeater] 삭제 실패:", deleteError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장 완료 후 loadedIdsRef 갱신
|
||||||
|
loadedIdsRef.current = new Set(currentData.map((r) => r.id).filter(Boolean));
|
||||||
|
|
||||||
|
toast.success(`V2Repeater ${currentData.length}건 저장 완료`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ V2Repeater 저장 실패:", error);
|
console.error("❌ V2Repeater 저장 실패:", error);
|
||||||
throw error;
|
toast.error(`V2Repeater 저장 실패: ${error}`);
|
||||||
|
} finally {
|
||||||
|
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// V2 EventBus 구독
|
|
||||||
const unsubscribe = v2EventBus.subscribe(
|
const unsubscribe = v2EventBus.subscribe(
|
||||||
V2_EVENTS.REPEATER_SAVE,
|
V2_EVENTS.REPEATER_SAVE,
|
||||||
async (payload) => {
|
async (payload) => {
|
||||||
const tableName =
|
const configTableName =
|
||||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||||
if (payload.tableName === tableName) {
|
if (!configTableName || payload.tableName === configTableName) {
|
||||||
await handleSaveEvent({ detail: payload } as CustomEvent);
|
await handleSaveEvent({ detail: payload } as CustomEvent);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
|
{ componentId: `v2-repeater-${config.dataSource?.tableName || "same-table"}` },
|
||||||
);
|
);
|
||||||
|
|
||||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
|
||||||
window.addEventListener("repeaterSave" as any, handleSaveEvent);
|
window.addEventListener("repeaterSave" as any, handleSaveEvent);
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
|
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
data,
|
|
||||||
config.dataSource?.tableName,
|
config.dataSource?.tableName,
|
||||||
config.useCustomTable,
|
config.useCustomTable,
|
||||||
config.mainTableName,
|
config.mainTableName,
|
||||||
config.foreignKeyColumn,
|
config.foreignKeyColumn,
|
||||||
|
config.foreignKeySourceColumn,
|
||||||
parentId,
|
parentId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -261,7 +428,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
{
|
{
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
search: { [config.foreignKeyColumn]: fkValue },
|
dataFilter: {
|
||||||
|
enabled: true,
|
||||||
|
filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }],
|
||||||
|
},
|
||||||
autoFilter: true,
|
autoFilter: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -298,7 +468,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
// 각 행에 소스 테이블의 표시 데이터 병합
|
// 각 행에 소스 테이블의 표시 데이터 병합
|
||||||
// RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함
|
|
||||||
rows.forEach((row: any) => {
|
rows.forEach((row: any) => {
|
||||||
const sourceRecord = sourceMap.get(String(row[fkColumn]));
|
const sourceRecord = sourceMap.get(String(row[fkColumn]));
|
||||||
if (sourceRecord) {
|
if (sourceRecord) {
|
||||||
|
|
@ -316,12 +485,50 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환
|
||||||
|
const codesToResolve = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const val of Object.values(row)) {
|
||||||
|
if (typeof val === "string" && val.startsWith("CATEGORY_")) {
|
||||||
|
codesToResolve.add(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codesToResolve.size > 0) {
|
||||||
|
try {
|
||||||
|
const labelResp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: Array.from(codesToResolve),
|
||||||
|
});
|
||||||
|
if (labelResp.data?.success && labelResp.data.data) {
|
||||||
|
const labelData = labelResp.data.data as Record<string, string>;
|
||||||
|
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
|
if (key.startsWith("_")) continue;
|
||||||
|
const val = row[key];
|
||||||
|
if (typeof val === "string" && labelData[val]) {
|
||||||
|
row[key] = labelData[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 라벨 변환 실패 시 코드 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원본 ID 목록 기록 (삭제 추적용)
|
||||||
|
const ids = rows.map((r: any) => r.id).filter(Boolean);
|
||||||
|
loadedIdsRef.current = new Set(ids);
|
||||||
|
console.log("📋 [V2Repeater] 원본 ID 기록:", ids);
|
||||||
|
|
||||||
setData(rows);
|
setData(rows);
|
||||||
dataLoadedRef.current = true;
|
dataLoadedRef.current = true;
|
||||||
if (onDataChange) onDataChange(rows);
|
if (onDataChange) onDataChange(rows);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error);
|
console.error("[V2Repeater] 기존 데이터 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -343,16 +550,28 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
if (!tableName) return;
|
if (!tableName) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
const [colResponse, typeResponse] = await Promise.all([
|
||||||
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
apiClient.get(`/table-management/tables/${tableName}/columns`),
|
||||||
|
apiClient.get(`/table-management/tables/${tableName}/web-types`),
|
||||||
|
]);
|
||||||
|
const columns = colResponse.data?.data?.columns || colResponse.data?.columns || colResponse.data || [];
|
||||||
|
const inputTypes = typeResponse.data?.data || [];
|
||||||
|
|
||||||
|
// inputType/categoryRef 매핑 생성
|
||||||
|
const typeMap: Record<string, any> = {};
|
||||||
|
inputTypes.forEach((t: any) => {
|
||||||
|
typeMap[t.columnName] = t;
|
||||||
|
});
|
||||||
|
|
||||||
const columnMap: Record<string, any> = {};
|
const columnMap: Record<string, any> = {};
|
||||||
columns.forEach((col: any) => {
|
columns.forEach((col: any) => {
|
||||||
const name = col.columnName || col.column_name || col.name;
|
const name = col.columnName || col.column_name || col.name;
|
||||||
|
const typeInfo = typeMap[name];
|
||||||
columnMap[name] = {
|
columnMap[name] = {
|
||||||
inputType: col.inputType || col.input_type || col.webType || "text",
|
inputType: typeInfo?.inputType || col.inputType || col.input_type || col.webType || "text",
|
||||||
displayName: col.displayName || col.display_name || col.label || name,
|
displayName: col.displayName || col.display_name || col.label || name,
|
||||||
detailSettings: col.detailSettings || col.detail_settings,
|
detailSettings: col.detailSettings || col.detail_settings,
|
||||||
|
categoryRef: typeInfo?.categoryRef || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setCurrentTableColumnInfo(columnMap);
|
setCurrentTableColumnInfo(columnMap);
|
||||||
|
|
@ -484,16 +703,20 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
else if (inputType === "code") type = "select";
|
else if (inputType === "code") type = "select";
|
||||||
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
|
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
|
||||||
|
|
||||||
// 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식)
|
// 카테고리 참조 ID 결정
|
||||||
// category 타입인 경우 현재 테이블명과 컬럼명을 조합
|
// DB의 category_ref 설정 우선, 없으면 자기 테이블.컬럼명 사용
|
||||||
let categoryRef: string | undefined;
|
let categoryRef: string | undefined;
|
||||||
if (inputType === "category") {
|
if (inputType === "category") {
|
||||||
// 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용
|
const dbCategoryRef = colInfo?.detailSettings?.categoryRef || colInfo?.categoryRef;
|
||||||
|
if (dbCategoryRef) {
|
||||||
|
categoryRef = dbCategoryRef;
|
||||||
|
} else {
|
||||||
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
|
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
|
||||||
if (tableName) {
|
if (tableName) {
|
||||||
categoryRef = `${tableName}.${col.key}`;
|
categoryRef = `${tableName}.${col.key}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
field: col.key,
|
field: col.key,
|
||||||
|
|
@ -509,55 +732,79 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
});
|
});
|
||||||
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
|
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
|
||||||
|
|
||||||
// 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
|
// 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지
|
||||||
useEffect(() => {
|
// repeaterColumns의 resolved type 사용 (config + DB 메타데이터 모두 반영)
|
||||||
const loadCategoryLabels = async () => {
|
const allCategoryColumns = useMemo(() => {
|
||||||
if (sourceCategoryColumns.length === 0 || data.length === 0) {
|
const fromRepeater = repeaterColumns
|
||||||
return;
|
.filter((col) => col.type === "category")
|
||||||
}
|
.map((col) => col.field.replace(/^_display_/, ""));
|
||||||
|
const merged = new Set([...sourceCategoryColumns, ...fromRepeater]);
|
||||||
// 데이터에서 카테고리 컬럼의 모든 고유 코드 수집
|
return Array.from(merged);
|
||||||
const allCodes = new Set<string>();
|
}, [sourceCategoryColumns, repeaterColumns]);
|
||||||
for (const row of data) {
|
|
||||||
for (const col of sourceCategoryColumns) {
|
|
||||||
// _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인
|
|
||||||
const val = row[`_display_${col}`] || row[col];
|
|
||||||
if (val && typeof val === "string") {
|
|
||||||
const codes = val
|
|
||||||
.split(",")
|
|
||||||
.map((c: string) => c.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
for (const code of codes) {
|
|
||||||
if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) {
|
|
||||||
allCodes.add(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allCodes.size === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// CATEGORY_ 코드 배열을 받아 라벨을 일괄 조회하는 함수
|
||||||
|
const fetchCategoryLabels = useCallback(async (codes: string[]) => {
|
||||||
|
if (codes.length === 0) return;
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post("/table-categories/labels-by-codes", {
|
const response = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
valueCodes: Array.from(allCodes),
|
valueCodes: codes,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data?.success && response.data.data) {
|
if (response.data?.success && response.data.data) {
|
||||||
setCategoryLabelMap((prev) => ({
|
setCategoryLabelMap((prev) => ({ ...prev, ...response.data.data }));
|
||||||
...prev,
|
|
||||||
...response.data.data,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("카테고리 라벨 조회 실패:", error);
|
console.error("카테고리 라벨 조회 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
loadCategoryLabels();
|
// parentFormData(마스터 행)에서 카테고리 코드를 미리 로드
|
||||||
}, [data, sourceCategoryColumns]);
|
// fromMainForm autoFill에서 참조할 마스터 필드의 라벨을 사전에 확보
|
||||||
|
useEffect(() => {
|
||||||
|
if (!parentFormData) return;
|
||||||
|
const codes: string[] = [];
|
||||||
|
|
||||||
|
// fromMainForm autoFill의 sourceField 값 중 카테고리 컬럼에 해당하는 것만 수집
|
||||||
|
for (const col of config.columns) {
|
||||||
|
if (col.autoFill?.type === "fromMainForm" && col.autoFill.sourceField) {
|
||||||
|
const val = parentFormData[col.autoFill.sourceField];
|
||||||
|
if (typeof val === "string" && val && !categoryLabelMap[val]) {
|
||||||
|
codes.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// receiveFromParent 패턴
|
||||||
|
if ((col as any).receiveFromParent) {
|
||||||
|
const parentField = (col as any).parentFieldName || col.key;
|
||||||
|
const val = parentFormData[parentField];
|
||||||
|
if (typeof val === "string" && val && !categoryLabelMap[val]) {
|
||||||
|
codes.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codes.length > 0) {
|
||||||
|
fetchCategoryLabels(codes);
|
||||||
|
}
|
||||||
|
}, [parentFormData, config.columns, fetchCategoryLabels]);
|
||||||
|
|
||||||
|
// 데이터 변경 시 카테고리 라벨 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.length === 0) return;
|
||||||
|
|
||||||
|
const allCodes = new Set<string>();
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
for (const col of allCategoryColumns) {
|
||||||
|
const val = row[`_display_${col}`] || row[col];
|
||||||
|
if (val && typeof val === "string") {
|
||||||
|
val.split(",").map((c: string) => c.trim()).filter(Boolean).forEach((code: string) => {
|
||||||
|
if (!categoryLabelMap[code]) allCodes.add(code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCategoryLabels(Array.from(allCodes));
|
||||||
|
}, [data, allCategoryColumns, fetchCategoryLabels]);
|
||||||
|
|
||||||
// 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능)
|
// 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능)
|
||||||
const applyCalculationRules = useCallback(
|
const applyCalculationRules = useCallback(
|
||||||
|
|
@ -674,18 +921,32 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
|
|
||||||
case "fromMainForm":
|
case "fromMainForm":
|
||||||
if (col.autoFill.sourceField && mainFormData) {
|
if (col.autoFill.sourceField && mainFormData) {
|
||||||
return mainFormData[col.autoFill.sourceField];
|
const rawValue = mainFormData[col.autoFill.sourceField];
|
||||||
|
// categoryLabelMap에 매핑이 있으면 라벨로 변환 (접두사 무관)
|
||||||
|
if (typeof rawValue === "string" && categoryLabelMap[rawValue]) {
|
||||||
|
return categoryLabelMap[rawValue];
|
||||||
|
}
|
||||||
|
return rawValue;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
|
|
||||||
case "fixed":
|
case "fixed":
|
||||||
return col.autoFill.fixedValue ?? "";
|
return col.autoFill.fixedValue ?? "";
|
||||||
|
|
||||||
|
case "parentSequence": {
|
||||||
|
const parentField = col.autoFill.parentField;
|
||||||
|
const separator = col.autoFill.separator ?? "-";
|
||||||
|
const seqLength = col.autoFill.sequenceLength ?? 2;
|
||||||
|
const parentValue = parentField && mainFormData ? String(mainFormData[parentField] ?? "") : "";
|
||||||
|
const seqNum = String(rowIndex + 1).padStart(seqLength, "0");
|
||||||
|
return parentValue ? `${parentValue}${separator}${seqNum}` : seqNum;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[categoryLabelMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 채번 API 호출 (비동기)
|
// 🆕 채번 API 호출 (비동기)
|
||||||
|
|
@ -707,7 +968,121 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
|
// 모달에서 전달된 groupedData를 초기 행 데이터로 변환 (컬럼 매핑 포함)
|
||||||
|
const groupedDataProcessedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupedData || !Array.isArray(groupedData) || groupedData.length === 0) return;
|
||||||
|
if (groupedDataProcessedRef.current) return;
|
||||||
|
|
||||||
|
groupedDataProcessedRef.current = true;
|
||||||
|
|
||||||
|
const newRows = groupedData.map((item: any, index: number) => {
|
||||||
|
const row: any = { _id: `grouped_${Date.now()}_${index}` };
|
||||||
|
|
||||||
|
for (const col of config.columns) {
|
||||||
|
let sourceValue = item[(col as any).sourceKey || col.key];
|
||||||
|
|
||||||
|
// 카테고리 코드 → 라벨 변환 (접두사 무관, categoryLabelMap 기반)
|
||||||
|
if (typeof sourceValue === "string" && categoryLabelMap[sourceValue]) {
|
||||||
|
sourceValue = categoryLabelMap[sourceValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.isSourceDisplay) {
|
||||||
|
row[col.key] = sourceValue ?? "";
|
||||||
|
row[`_display_${col.key}`] = sourceValue ?? "";
|
||||||
|
} else if (col.autoFill && col.autoFill.type !== "none") {
|
||||||
|
const autoValue = generateAutoFillValueSync(col, index, parentFormData);
|
||||||
|
if (autoValue !== undefined) {
|
||||||
|
row[col.key] = autoValue;
|
||||||
|
} else {
|
||||||
|
row[col.key] = "";
|
||||||
|
}
|
||||||
|
} else if (sourceValue !== undefined) {
|
||||||
|
row[col.key] = sourceValue;
|
||||||
|
} else {
|
||||||
|
row[col.key] = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관)
|
||||||
|
const categoryColSet = new Set(allCategoryColumns);
|
||||||
|
const codesToResolve = new Set<string>();
|
||||||
|
for (const row of newRows) {
|
||||||
|
for (const col of config.columns) {
|
||||||
|
const val = row[col.key] || row[`_display_${col.key}`];
|
||||||
|
if (typeof val === "string" && val && (categoryColSet.has(col.key) || col.autoFill?.type === "fromMainForm")) {
|
||||||
|
if (!categoryLabelMap[val]) {
|
||||||
|
codesToResolve.add(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codesToResolve.size > 0) {
|
||||||
|
apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: Array.from(codesToResolve),
|
||||||
|
}).then((resp) => {
|
||||||
|
if (resp.data?.success && resp.data.data) {
|
||||||
|
const labelData = resp.data.data as Record<string, string>;
|
||||||
|
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||||
|
const convertedRows = newRows.map((row) => {
|
||||||
|
const updated = { ...row };
|
||||||
|
for (const col of config.columns) {
|
||||||
|
const val = updated[col.key];
|
||||||
|
if (typeof val === "string" && labelData[val]) {
|
||||||
|
updated[col.key] = labelData[val];
|
||||||
|
}
|
||||||
|
const dispKey = `_display_${col.key}`;
|
||||||
|
const dispVal = updated[dispKey];
|
||||||
|
if (typeof dispVal === "string" && labelData[dispVal]) {
|
||||||
|
updated[dispKey] = labelData[dispVal];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
setData(convertedRows);
|
||||||
|
onDataChange?.(convertedRows);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(newRows);
|
||||||
|
onDataChange?.(newRows);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [groupedData, config.columns, generateAutoFillValueSync]);
|
||||||
|
|
||||||
|
// parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.length === 0) return;
|
||||||
|
|
||||||
|
const parentSeqColumns = config.columns.filter(
|
||||||
|
(col) => col.autoFill?.type === "parentSequence" && col.autoFill.parentField,
|
||||||
|
);
|
||||||
|
if (parentSeqColumns.length === 0) return;
|
||||||
|
|
||||||
|
let needsUpdate = false;
|
||||||
|
const updatedData = data.map((row, index) => {
|
||||||
|
const updatedRow = { ...row };
|
||||||
|
for (const col of parentSeqColumns) {
|
||||||
|
const newValue = generateAutoFillValueSync(col, index, parentFormData);
|
||||||
|
if (newValue !== undefined && newValue !== row[col.key]) {
|
||||||
|
updatedRow[col.key] = newValue;
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatedRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (needsUpdate) {
|
||||||
|
setData(updatedData);
|
||||||
|
onDataChange?.(updatedData);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [parentFormData, config.columns, generateAutoFillValueSync]);
|
||||||
|
|
||||||
|
// 행 추가 (inline 모드 또는 모달 열기)
|
||||||
const handleAddRow = useCallback(async () => {
|
const handleAddRow = useCallback(async () => {
|
||||||
if (isModalMode) {
|
if (isModalMode) {
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
|
|
@ -715,11 +1090,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
const newRow: any = { _id: `new_${Date.now()}` };
|
const newRow: any = { _id: `new_${Date.now()}` };
|
||||||
const currentRowCount = data.length;
|
const currentRowCount = data.length;
|
||||||
|
|
||||||
// 먼저 동기적 자동 입력 값 적용
|
// 동기적 자동 입력 값 적용
|
||||||
for (const col of config.columns) {
|
for (const col of config.columns) {
|
||||||
const autoValue = generateAutoFillValueSync(col, currentRowCount);
|
const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData);
|
||||||
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
||||||
// 채번 규칙: 즉시 API 호출
|
|
||||||
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
||||||
} else if (autoValue !== undefined) {
|
} else if (autoValue !== undefined) {
|
||||||
newRow[col.key] = autoValue;
|
newRow[col.key] = autoValue;
|
||||||
|
|
@ -728,10 +1102,51 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fromMainForm 등으로 넘어온 카테고리 코드 → 라벨 변환
|
||||||
|
// allCategoryColumns에 해당하는 컬럼이거나 categoryLabelMap에 매핑이 있으면 변환
|
||||||
|
const categoryColSet = new Set(allCategoryColumns);
|
||||||
|
const unresolvedCodes: string[] = [];
|
||||||
|
for (const col of config.columns) {
|
||||||
|
const val = newRow[col.key];
|
||||||
|
if (typeof val !== "string" || !val) continue;
|
||||||
|
|
||||||
|
// 이 컬럼이 카테고리 타입이거나, fromMainForm으로 가져온 값인 경우
|
||||||
|
const isCategoryCol = categoryColSet.has(col.key);
|
||||||
|
const isFromMainForm = col.autoFill?.type === "fromMainForm";
|
||||||
|
|
||||||
|
if (isCategoryCol || isFromMainForm) {
|
||||||
|
if (categoryLabelMap[val]) {
|
||||||
|
newRow[col.key] = categoryLabelMap[val];
|
||||||
|
} else {
|
||||||
|
unresolvedCodes.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unresolvedCodes.length > 0) {
|
||||||
|
try {
|
||||||
|
const resp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: unresolvedCodes,
|
||||||
|
});
|
||||||
|
if (resp.data?.success && resp.data.data) {
|
||||||
|
const labelData = resp.data.data as Record<string, string>;
|
||||||
|
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||||
|
for (const col of config.columns) {
|
||||||
|
const val = newRow[col.key];
|
||||||
|
if (typeof val === "string" && labelData[val]) {
|
||||||
|
newRow[col.key] = labelData[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 변환 실패 시 코드 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newData = [...data, newRow];
|
const newData = [...data, newRow];
|
||||||
handleDataChange(newData);
|
handleDataChange(newData);
|
||||||
}
|
}
|
||||||
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode]);
|
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData, categoryLabelMap, allCategoryColumns]);
|
||||||
|
|
||||||
// 모달에서 항목 선택 - 비동기로 변경
|
// 모달에서 항목 선택 - 비동기로 변경
|
||||||
const handleSelectItems = useCallback(
|
const handleSelectItems = useCallback(
|
||||||
|
|
@ -756,11 +1171,15 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
// 모든 컬럼 처리 (순서대로)
|
// 모든 컬럼 처리 (순서대로)
|
||||||
for (const col of config.columns) {
|
for (const col of config.columns) {
|
||||||
if (col.isSourceDisplay) {
|
if (col.isSourceDisplay) {
|
||||||
// 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용)
|
let displayVal = item[col.key] || "";
|
||||||
row[`_display_${col.key}`] = item[col.key] || "";
|
// 카테고리 컬럼이면 코드→라벨 변환 (접두사 무관)
|
||||||
|
if (typeof displayVal === "string" && categoryLabelMap[displayVal]) {
|
||||||
|
displayVal = categoryLabelMap[displayVal];
|
||||||
|
}
|
||||||
|
row[`_display_${col.key}`] = displayVal;
|
||||||
} else {
|
} else {
|
||||||
// 자동 입력 값 적용
|
// 자동 입력 값 적용
|
||||||
const autoValue = generateAutoFillValueSync(col, currentRowCount + index);
|
const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData);
|
||||||
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
||||||
// 채번 규칙: 즉시 API 호출
|
// 채번 규칙: 즉시 API 호출
|
||||||
row[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
row[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
||||||
|
|
@ -777,6 +1196,43 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 카테고리/fromMainForm 컬럼에서 미해결 코드 수집 및 변환
|
||||||
|
const categoryColSet = new Set(allCategoryColumns);
|
||||||
|
const unresolvedCodes = new Set<string>();
|
||||||
|
for (const row of newRows) {
|
||||||
|
for (const col of config.columns) {
|
||||||
|
const val = row[col.key];
|
||||||
|
if (typeof val !== "string" || !val) continue;
|
||||||
|
const isCategoryCol = categoryColSet.has(col.key);
|
||||||
|
const isFromMainForm = col.autoFill?.type === "fromMainForm";
|
||||||
|
if ((isCategoryCol || isFromMainForm) && !categoryLabelMap[val]) {
|
||||||
|
unresolvedCodes.add(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unresolvedCodes.size > 0) {
|
||||||
|
try {
|
||||||
|
const resp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: Array.from(unresolvedCodes),
|
||||||
|
});
|
||||||
|
if (resp.data?.success && resp.data.data) {
|
||||||
|
const labelData = resp.data.data as Record<string, string>;
|
||||||
|
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||||
|
for (const row of newRows) {
|
||||||
|
for (const col of config.columns) {
|
||||||
|
const val = row[col.key];
|
||||||
|
if (typeof val === "string" && labelData[val]) {
|
||||||
|
row[col.key] = labelData[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 변환 실패 시 코드 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newData = [...data, ...newRows];
|
const newData = [...data, ...newRows];
|
||||||
handleDataChange(newData);
|
handleDataChange(newData);
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
|
|
@ -789,6 +1245,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
handleDataChange,
|
handleDataChange,
|
||||||
generateAutoFillValueSync,
|
generateAutoFillValueSync,
|
||||||
generateNumberingCode,
|
generateNumberingCode,
|
||||||
|
parentFormData,
|
||||||
|
categoryLabelMap,
|
||||||
|
allCategoryColumns,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -801,9 +1260,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}, [config.columns]);
|
}, [config.columns]);
|
||||||
|
|
||||||
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
|
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
|
||||||
const dataRef = useRef(data);
|
|
||||||
dataRef.current = data;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeFormSave = async (event: Event) => {
|
const handleBeforeFormSave = async (event: Event) => {
|
||||||
const customEvent = event as CustomEvent;
|
const customEvent = event as CustomEvent;
|
||||||
|
|
@ -1032,7 +1488,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
onSelectionChange={setSelectedRows}
|
onSelectionChange={setSelectedRows}
|
||||||
equalizeWidthsTrigger={autoWidthTrigger}
|
equalizeWidthsTrigger={autoWidthTrigger}
|
||||||
categoryColumns={sourceCategoryColumns}
|
categoryColumns={allCategoryColumns}
|
||||||
categoryLabelMap={categoryLabelMap}
|
categoryLabelMap={categoryLabelMap}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { V2SelectProps, SelectOption } from "@/types/v2-components";
|
import { V2SelectProps, SelectOption, V2SelectFilter } from "@/types/v2-components";
|
||||||
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
|
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import V2FormContext from "./V2FormContext";
|
import V2FormContext from "./V2FormContext";
|
||||||
|
|
@ -655,6 +655,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
const labelColumn = config.labelColumn;
|
const labelColumn = config.labelColumn;
|
||||||
const apiEndpoint = config.apiEndpoint;
|
const apiEndpoint = config.apiEndpoint;
|
||||||
const staticOptions = config.options;
|
const staticOptions = config.options;
|
||||||
|
const configFilters = config.filters;
|
||||||
|
|
||||||
// 계층 코드 연쇄 선택 관련
|
// 계층 코드 연쇄 선택 관련
|
||||||
const hierarchical = config.hierarchical;
|
const hierarchical = config.hierarchical;
|
||||||
|
|
@ -663,6 +664,54 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
// FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null)
|
// FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null)
|
||||||
const formContext = useContext(V2FormContext);
|
const formContext = useContext(V2FormContext);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 조건을 API 전달용 JSON으로 변환
|
||||||
|
* field/user 타입은 런타임 값으로 치환
|
||||||
|
*/
|
||||||
|
const resolvedFiltersJson = useMemo(() => {
|
||||||
|
if (!configFilters || configFilters.length === 0) return undefined;
|
||||||
|
|
||||||
|
const resolved: Array<{ column: string; operator: string; value: unknown }> = [];
|
||||||
|
|
||||||
|
for (const f of configFilters) {
|
||||||
|
const vt = f.valueType || "static";
|
||||||
|
|
||||||
|
// isNull/isNotNull은 값 불필요
|
||||||
|
if (f.operator === "isNull" || f.operator === "isNotNull") {
|
||||||
|
resolved.push({ column: f.column, operator: f.operator, value: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedValue: unknown = f.value;
|
||||||
|
|
||||||
|
if (vt === "field" && f.fieldRef) {
|
||||||
|
// 다른 폼 필드 참조
|
||||||
|
if (formContext) {
|
||||||
|
resolvedValue = formContext.getValue(f.fieldRef);
|
||||||
|
} else {
|
||||||
|
const fd = (props as any).formData;
|
||||||
|
resolvedValue = fd?.[f.fieldRef];
|
||||||
|
}
|
||||||
|
// 참조 필드 값이 비어있으면 이 필터 건너뜀
|
||||||
|
if (resolvedValue === undefined || resolvedValue === null || resolvedValue === "") continue;
|
||||||
|
} else if (vt === "user" && f.userField) {
|
||||||
|
// 로그인 사용자 정보 참조 (props에서 가져옴)
|
||||||
|
const userMap: Record<string, string | undefined> = {
|
||||||
|
companyCode: (props as any).companyCode,
|
||||||
|
userId: (props as any).userId,
|
||||||
|
deptCode: (props as any).deptCode,
|
||||||
|
userName: (props as any).userName,
|
||||||
|
};
|
||||||
|
resolvedValue = userMap[f.userField];
|
||||||
|
if (!resolvedValue) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved.push({ column: f.column, operator: f.operator, value: resolvedValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved.length > 0 ? JSON.stringify(resolved) : undefined;
|
||||||
|
}, [configFilters, formContext, props]);
|
||||||
|
|
||||||
// 부모 필드의 값 계산
|
// 부모 필드의 값 계산
|
||||||
const parentValue = useMemo(() => {
|
const parentValue = useMemo(() => {
|
||||||
if (!hierarchical || !parentField) return null;
|
if (!hierarchical || !parentField) return null;
|
||||||
|
|
@ -684,6 +733,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
}
|
}
|
||||||
}, [parentValue, hierarchical, source]);
|
}, [parentValue, hierarchical, source]);
|
||||||
|
|
||||||
|
// 필터 조건이 변경되면 옵션 다시 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (resolvedFiltersJson !== undefined) {
|
||||||
|
setOptionsLoaded(false);
|
||||||
|
}
|
||||||
|
}, [resolvedFiltersJson]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외)
|
// 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외)
|
||||||
if (optionsLoaded && source !== "static") {
|
if (optionsLoaded && source !== "static") {
|
||||||
|
|
@ -731,11 +787,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
}
|
}
|
||||||
} else if (source === "db" && table) {
|
} else if (source === "db" && table) {
|
||||||
// DB 테이블에서 로드
|
// DB 테이블에서 로드
|
||||||
const response = await apiClient.get(`/entity/${table}/options`, {
|
const dbParams: Record<string, any> = {
|
||||||
params: {
|
|
||||||
value: valueColumn || "id",
|
value: valueColumn || "id",
|
||||||
label: labelColumn || "name",
|
label: labelColumn || "name",
|
||||||
},
|
};
|
||||||
|
if (resolvedFiltersJson) dbParams.filters = resolvedFiltersJson;
|
||||||
|
const response = await apiClient.get(`/entity/${table}/options`, {
|
||||||
|
params: dbParams,
|
||||||
});
|
});
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
|
|
@ -745,8 +803,10 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
// 엔티티(참조 테이블)에서 로드
|
// 엔티티(참조 테이블)에서 로드
|
||||||
const valueCol = entityValueColumn || "id";
|
const valueCol = entityValueColumn || "id";
|
||||||
const labelCol = entityLabelColumn || "name";
|
const labelCol = entityLabelColumn || "name";
|
||||||
|
const entityParams: Record<string, any> = { value: valueCol, label: labelCol };
|
||||||
|
if (resolvedFiltersJson) entityParams.filters = resolvedFiltersJson;
|
||||||
const response = await apiClient.get(`/entity/${entityTable}/options`, {
|
const response = await apiClient.get(`/entity/${entityTable}/options`, {
|
||||||
params: { value: valueCol, label: labelCol },
|
params: entityParams,
|
||||||
});
|
});
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
|
|
@ -790,11 +850,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
}
|
}
|
||||||
} else if (source === "select" || source === "distinct") {
|
} else if (source === "select" || source === "distinct") {
|
||||||
// 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
|
// 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
|
||||||
// tableName, columnName은 props에서 가져옴
|
|
||||||
// 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀
|
|
||||||
const isValidColumnName = columnName && !columnName.startsWith("comp_");
|
const isValidColumnName = columnName && !columnName.startsWith("comp_");
|
||||||
if (tableName && isValidColumnName) {
|
if (tableName && isValidColumnName) {
|
||||||
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`);
|
const distinctParams: Record<string, any> = {};
|
||||||
|
if (resolvedFiltersJson) distinctParams.filters = resolvedFiltersJson;
|
||||||
|
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`, {
|
||||||
|
params: distinctParams,
|
||||||
|
});
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
|
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
|
||||||
|
|
@ -818,7 +880,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
};
|
};
|
||||||
|
|
||||||
loadOptions();
|
loadOptions();
|
||||||
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]);
|
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue, resolvedFiltersJson]);
|
||||||
|
|
||||||
// 레거시 평문값 → 카테고리 코드 자동 정규화 (한글 텍스트로 저장된 데이터 대응)
|
// 레거시 평문값 → 카테고리 코드 자동 정규화 (한글 텍스트로 저장된 데이터 대응)
|
||||||
const resolvedValue = useMemo(() => {
|
const resolvedValue = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||||
{ value: "numbering", label: "채번 규칙" },
|
{ value: "numbering", label: "채번 규칙" },
|
||||||
{ value: "fromMainForm", label: "메인 폼에서 복사" },
|
{ value: "fromMainForm", label: "메인 폼에서 복사" },
|
||||||
{ value: "fixed", label: "고정값" },
|
{ value: "fixed", label: "고정값" },
|
||||||
|
{ value: "parentSequence", label: "부모채번+순번 (예: WO-001-01)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 🆕 대상 메뉴 목록 로드 (사용자 메뉴의 레벨 2)
|
// 🆕 대상 메뉴 목록 로드 (사용자 메뉴의 레벨 2)
|
||||||
|
|
@ -1213,13 +1214,21 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||||
<Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" />
|
<Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 편집 가능 체크박스 */}
|
{/* 편집 가능 토글 */}
|
||||||
{!col.isSourceDisplay && (
|
{!col.isSourceDisplay && (
|
||||||
<Checkbox
|
<button
|
||||||
checked={col.editable ?? true}
|
type="button"
|
||||||
onCheckedChange={(checked) => updateColumnProp(col.key, "editable", !!checked)}
|
onClick={() => updateColumnProp(col.key, "editable", !(col.editable ?? true))}
|
||||||
title="편집 가능"
|
className={cn(
|
||||||
/>
|
"shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors",
|
||||||
|
(col.editable ?? true)
|
||||||
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||||
|
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
|
||||||
|
)}
|
||||||
|
title={(col.editable ?? true) ? "편집 가능 (클릭하여 읽기 전용으로 변경)" : "읽기 전용 (클릭하여 편집 가능으로 변경)"}
|
||||||
|
>
|
||||||
|
{(col.editable ?? true) ? "편집" : "읽기"}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1393,6 +1402,56 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 부모채번+순번 설정 */}
|
||||||
|
{col.autoFill?.type === "parentSequence" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-600">부모 번호 필드명</Label>
|
||||||
|
<Input
|
||||||
|
value={col.autoFill?.parentField || ""}
|
||||||
|
onChange={(e) => updateColumnProp(col.key, "autoFill", {
|
||||||
|
...col.autoFill,
|
||||||
|
parentField: e.target.value,
|
||||||
|
})}
|
||||||
|
placeholder="work_order_no"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-gray-400">메인 폼에서 가져올 부모 채번 필드</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-600">구분자</Label>
|
||||||
|
<Input
|
||||||
|
value={col.autoFill?.separator ?? "-"}
|
||||||
|
onChange={(e) => updateColumnProp(col.key, "autoFill", {
|
||||||
|
...col.autoFill,
|
||||||
|
separator: e.target.value,
|
||||||
|
})}
|
||||||
|
placeholder="-"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-600">순번 자릿수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={col.autoFill?.sequenceLength ?? 2}
|
||||||
|
onChange={(e) => updateColumnProp(col.key, "autoFill", {
|
||||||
|
...col.autoFill,
|
||||||
|
sequenceLength: parseInt(e.target.value) || 2,
|
||||||
|
})}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-green-600">
|
||||||
|
예시: WO-20260223-005{col.autoFill?.separator ?? "-"}{String(1).padStart(col.autoFill?.sequenceLength ?? 2, "0")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,56 +5,401 @@
|
||||||
* 통합 선택 컴포넌트의 세부 설정을 관리합니다.
|
* 통합 선택 컴포넌트의 세부 설정을 관리합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, Trash2, Loader2 } from "lucide-react";
|
import { Plus, Trash2, Loader2, Filter } from "lucide-react";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import type { V2SelectFilter } from "@/types/v2-components";
|
||||||
|
|
||||||
interface ColumnOption {
|
interface ColumnOption {
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CategoryValueOption {
|
||||||
|
valueCode: string;
|
||||||
|
valueLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPERATOR_OPTIONS = [
|
||||||
|
{ value: "=", label: "같음 (=)" },
|
||||||
|
{ value: "!=", label: "다름 (!=)" },
|
||||||
|
{ value: ">", label: "초과 (>)" },
|
||||||
|
{ value: "<", label: "미만 (<)" },
|
||||||
|
{ value: ">=", label: "이상 (>=)" },
|
||||||
|
{ value: "<=", label: "이하 (<=)" },
|
||||||
|
{ value: "in", label: "포함 (IN)" },
|
||||||
|
{ value: "notIn", label: "미포함 (NOT IN)" },
|
||||||
|
{ value: "like", label: "유사 (LIKE)" },
|
||||||
|
{ value: "isNull", label: "NULL" },
|
||||||
|
{ value: "isNotNull", label: "NOT NULL" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const VALUE_TYPE_OPTIONS = [
|
||||||
|
{ value: "static", label: "고정값" },
|
||||||
|
{ value: "field", label: "폼 필드 참조" },
|
||||||
|
{ value: "user", label: "로그인 사용자" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const USER_FIELD_OPTIONS = [
|
||||||
|
{ value: "companyCode", label: "회사코드" },
|
||||||
|
{ value: "userId", label: "사용자ID" },
|
||||||
|
{ value: "deptCode", label: "부서코드" },
|
||||||
|
{ value: "userName", label: "사용자명" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 조건 설정 서브 컴포넌트
|
||||||
|
*/
|
||||||
|
const FilterConditionsSection: React.FC<{
|
||||||
|
filters: V2SelectFilter[];
|
||||||
|
columns: ColumnOption[];
|
||||||
|
loadingColumns: boolean;
|
||||||
|
targetTable: string;
|
||||||
|
onFiltersChange: (filters: V2SelectFilter[]) => void;
|
||||||
|
}> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => {
|
||||||
|
|
||||||
|
const addFilter = () => {
|
||||||
|
onFiltersChange([
|
||||||
|
...filters,
|
||||||
|
{ column: "", operator: "=", valueType: "static", value: "" },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFilter = (index: number, patch: Partial<V2SelectFilter>) => {
|
||||||
|
const updated = [...filters];
|
||||||
|
updated[index] = { ...updated[index], ...patch };
|
||||||
|
|
||||||
|
// valueType 변경 시 관련 필드 초기화
|
||||||
|
if (patch.valueType) {
|
||||||
|
if (patch.valueType === "static") {
|
||||||
|
updated[index].fieldRef = undefined;
|
||||||
|
updated[index].userField = undefined;
|
||||||
|
} else if (patch.valueType === "field") {
|
||||||
|
updated[index].value = undefined;
|
||||||
|
updated[index].userField = undefined;
|
||||||
|
} else if (patch.valueType === "user") {
|
||||||
|
updated[index].value = undefined;
|
||||||
|
updated[index].fieldRef = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNull/isNotNull 연산자는 값 불필요
|
||||||
|
if (patch.operator === "isNull" || patch.operator === "isNotNull") {
|
||||||
|
updated[index].value = undefined;
|
||||||
|
updated[index].fieldRef = undefined;
|
||||||
|
updated[index].userField = undefined;
|
||||||
|
updated[index].valueType = "static";
|
||||||
|
}
|
||||||
|
|
||||||
|
onFiltersChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFilter = (index: number) => {
|
||||||
|
onFiltersChange(filters.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Label className="text-xs font-medium">데이터 필터 조건</Label>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={addFilter}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
{targetTable} 테이블에서 옵션을 불러올 때 적용할 조건
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loadingColumns && (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
컬럼 목록 로딩 중...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filters.length === 0 && (
|
||||||
|
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||||
|
필터 조건이 없습니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filters.map((filter, index) => (
|
||||||
|
<div key={index} className="space-y-1.5 rounded-md border p-2">
|
||||||
|
{/* 행 1: 컬럼 + 연산자 + 삭제 */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* 컬럼 선택 */}
|
||||||
|
<Select
|
||||||
|
value={filter.column || ""}
|
||||||
|
onValueChange={(v) => updateFilter(index, { column: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 flex-1 text-[11px]">
|
||||||
|
<SelectValue placeholder="컬럼" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 연산자 선택 */}
|
||||||
|
<Select
|
||||||
|
value={filter.operator || "="}
|
||||||
|
onValueChange={(v) => updateFilter(index, { operator: v as V2SelectFilter["operator"] })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-[90px] shrink-0 text-[11px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{OPERATOR_OPTIONS.map((op) => (
|
||||||
|
<SelectItem key={op.value} value={op.value}>
|
||||||
|
{op.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeFilter(index)}
|
||||||
|
className="text-destructive h-7 w-7 shrink-0 p-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 행 2: 값 유형 + 값 입력 (isNull/isNotNull 제외) */}
|
||||||
|
{needsValue(filter.operator) && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* 값 유형 */}
|
||||||
|
<Select
|
||||||
|
value={filter.valueType || "static"}
|
||||||
|
onValueChange={(v) => updateFilter(index, { valueType: v as V2SelectFilter["valueType"] })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-[100px] shrink-0 text-[11px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{VALUE_TYPE_OPTIONS.map((vt) => (
|
||||||
|
<SelectItem key={vt.value} value={vt.value}>
|
||||||
|
{vt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 값 입력 영역 */}
|
||||||
|
{(filter.valueType || "static") === "static" && (
|
||||||
|
<Input
|
||||||
|
value={String(filter.value ?? "")}
|
||||||
|
onChange={(e) => updateFilter(index, { value: e.target.value })}
|
||||||
|
placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"}
|
||||||
|
className="h-7 flex-1 text-[11px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filter.valueType === "field" && (
|
||||||
|
<Input
|
||||||
|
value={filter.fieldRef || ""}
|
||||||
|
onChange={(e) => updateFilter(index, { fieldRef: e.target.value })}
|
||||||
|
placeholder="참조할 필드명 (columnName)"
|
||||||
|
className="h-7 flex-1 text-[11px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filter.valueType === "user" && (
|
||||||
|
<Select
|
||||||
|
value={filter.userField || ""}
|
||||||
|
onValueChange={(v) => updateFilter(index, { userField: v as V2SelectFilter["userField"] })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 flex-1 text-[11px]">
|
||||||
|
<SelectValue placeholder="사용자 필드" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{USER_FIELD_OPTIONS.map((uf) => (
|
||||||
|
<SelectItem key={uf.value} value={uf.value}>
|
||||||
|
{uf.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface V2SelectConfigPanelProps {
|
interface V2SelectConfigPanelProps {
|
||||||
config: Record<string, any>;
|
config: Record<string, any>;
|
||||||
onChange: (config: Record<string, any>) => void;
|
onChange: (config: Record<string, any>) => void;
|
||||||
/** 컬럼의 inputType (entity 타입인 경우에만 엔티티 소스 표시) */
|
/** 컬럼의 inputType (entity/category 타입 확인용) */
|
||||||
inputType?: string;
|
inputType?: string;
|
||||||
|
/** 현재 테이블명 (카테고리 값 조회용) */
|
||||||
|
tableName?: string;
|
||||||
|
/** 현재 컬럼명 (카테고리 값 조회용) */
|
||||||
|
columnName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config, onChange, inputType }) => {
|
export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
||||||
// 엔티티 타입인지 확인
|
config,
|
||||||
|
onChange,
|
||||||
|
inputType,
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
}) => {
|
||||||
const isEntityType = inputType === "entity";
|
const isEntityType = inputType === "entity";
|
||||||
// 엔티티 테이블의 컬럼 목록
|
const isCategoryType = inputType === "category";
|
||||||
|
|
||||||
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
|
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
|
||||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||||
|
|
||||||
// 설정 업데이트 핸들러
|
// 카테고리 값 목록
|
||||||
|
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
|
||||||
|
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
|
||||||
|
|
||||||
|
// 필터용 컬럼 목록 (옵션 데이터 소스 테이블의 컬럼)
|
||||||
|
const [filterColumns, setFilterColumns] = useState<ColumnOption[]>([]);
|
||||||
|
const [loadingFilterColumns, setLoadingFilterColumns] = useState(false);
|
||||||
|
|
||||||
const updateConfig = (field: string, value: any) => {
|
const updateConfig = (field: string, value: any) => {
|
||||||
onChange({ ...config, [field]: value });
|
onChange({ ...config, [field]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 필터 대상 테이블 결정
|
||||||
|
const filterTargetTable = useMemo(() => {
|
||||||
|
const src = config.source || "static";
|
||||||
|
if (src === "entity") return config.entityTable;
|
||||||
|
if (src === "db") return config.table;
|
||||||
|
if (src === "distinct" || src === "select") return tableName;
|
||||||
|
return null;
|
||||||
|
}, [config.source, config.entityTable, config.table, tableName]);
|
||||||
|
|
||||||
|
// 필터 대상 테이블의 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filterTargetTable) {
|
||||||
|
setFilterColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFilterColumns = async () => {
|
||||||
|
setLoadingFilterColumns(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`);
|
||||||
|
const data = response.data.data || response.data;
|
||||||
|
const columns = data.columns || data || [];
|
||||||
|
setFilterColumns(
|
||||||
|
columns.map((col: any) => ({
|
||||||
|
columnName: col.columnName || col.column_name || col.name,
|
||||||
|
columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setFilterColumns([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingFilterColumns(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadFilterColumns();
|
||||||
|
}, [filterTargetTable]);
|
||||||
|
|
||||||
|
// 카테고리 타입이면 source를 자동으로 category로 설정
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCategoryType && config.source !== "category") {
|
||||||
|
onChange({ ...config, source: "category" });
|
||||||
|
}
|
||||||
|
}, [isCategoryType]);
|
||||||
|
|
||||||
|
// 카테고리 값 로드
|
||||||
|
const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => {
|
||||||
|
if (!catTable || !catColumn) {
|
||||||
|
setCategoryValues([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingCategoryValues(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success && data.data) {
|
||||||
|
const flattenTree = (items: any[], depth: number = 0): CategoryValueOption[] => {
|
||||||
|
const result: CategoryValueOption[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
result.push({
|
||||||
|
valueCode: item.valueCode,
|
||||||
|
valueLabel: depth > 0 ? `${" ".repeat(depth)}${item.valueLabel}` : item.valueLabel,
|
||||||
|
});
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
result.push(...flattenTree(item.children, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
setCategoryValues(flattenTree(data.data));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 값 조회 실패:", error);
|
||||||
|
setCategoryValues([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingCategoryValues(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 카테고리 소스일 때 값 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (config.source === "category") {
|
||||||
|
const catTable = config.categoryTable || tableName;
|
||||||
|
const catColumn = config.categoryColumn || columnName;
|
||||||
|
if (catTable && catColumn) {
|
||||||
|
loadCategoryValues(catTable, catColumn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [config.source, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]);
|
||||||
|
|
||||||
// 엔티티 테이블 변경 시 컬럼 목록 조회
|
// 엔티티 테이블 변경 시 컬럼 목록 조회
|
||||||
const loadEntityColumns = useCallback(async (tableName: string) => {
|
const loadEntityColumns = useCallback(async (tblName: string) => {
|
||||||
if (!tableName) {
|
if (!tblName) {
|
||||||
setEntityColumns([]);
|
setEntityColumns([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoadingColumns(true);
|
setLoadingColumns(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=500`);
|
const response = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`);
|
||||||
const data = response.data.data || response.data;
|
const data = response.data.data || response.data;
|
||||||
const columns = data.columns || data || [];
|
const columns = data.columns || data || [];
|
||||||
|
|
||||||
const columnOptions: ColumnOption[] = columns.map((col: any) => {
|
const columnOptions: ColumnOption[] = columns.map((col: any) => {
|
||||||
const name = col.columnName || col.column_name || col.name;
|
const name = col.columnName || col.column_name || col.name;
|
||||||
// displayName 우선 사용
|
|
||||||
const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name;
|
const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -72,7 +417,6 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 엔티티 테이블이 변경되면 컬럼 목록 로드
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.source === "entity" && config.entityTable) {
|
if (config.source === "entity" && config.entityTable) {
|
||||||
loadEntityColumns(config.entityTable);
|
loadEntityColumns(config.entityTable);
|
||||||
|
|
@ -98,6 +442,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
||||||
updateConfig("options", newOptions);
|
updateConfig("options", newOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 현재 source 결정 (카테고리 타입이면 강제 category)
|
||||||
|
const effectiveSource = isCategoryType ? "category" : config.source || "static";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 선택 모드 */}
|
{/* 선택 모드 */}
|
||||||
|
|
@ -125,6 +472,11 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
||||||
{/* 데이터 소스 */}
|
{/* 데이터 소스 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium">데이터 소스</Label>
|
<Label className="text-xs font-medium">데이터 소스</Label>
|
||||||
|
{isCategoryType ? (
|
||||||
|
<div className="bg-muted flex h-8 items-center rounded-md px-3">
|
||||||
|
<span className="text-xs font-medium text-emerald-600">카테고리 (자동 설정)</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Select value={config.source || "static"} onValueChange={(value) => updateConfig("source", value)}>
|
<Select value={config.source || "static"} onValueChange={(value) => updateConfig("source", value)}>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="소스 선택" />
|
<SelectValue placeholder="소스 선택" />
|
||||||
|
|
@ -132,14 +484,90 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="static">정적 옵션</SelectItem>
|
<SelectItem value="static">정적 옵션</SelectItem>
|
||||||
<SelectItem value="code">공통 코드</SelectItem>
|
<SelectItem value="code">공통 코드</SelectItem>
|
||||||
{/* 엔티티 타입일 때만 엔티티 옵션 표시 */}
|
<SelectItem value="category">카테고리</SelectItem>
|
||||||
{isEntityType && <SelectItem value="entity">엔티티</SelectItem>}
|
{isEntityType && <SelectItem value="entity">엔티티</SelectItem>}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 설정 */}
|
||||||
|
{effectiveSource === "category" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">카테고리 정보</Label>
|
||||||
|
<div className="bg-muted rounded-md p-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-[10px]">테이블</p>
|
||||||
|
<p className="text-xs font-medium">{config.categoryTable || tableName || "-"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-[10px]">컬럼</p>
|
||||||
|
<p className="text-xs font-medium">{config.categoryColumn || columnName || "-"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 값 로딩 중 */}
|
||||||
|
{loadingCategoryValues && (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
카테고리 값 로딩 중...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카테고리 값 목록 표시 */}
|
||||||
|
{categoryValues.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">카테고리 값 ({categoryValues.length}개)</Label>
|
||||||
|
<div className="bg-muted max-h-32 space-y-0.5 overflow-y-auto rounded-md p-1.5">
|
||||||
|
{categoryValues.map((cv) => (
|
||||||
|
<div key={cv.valueCode} className="flex items-center gap-2 px-1.5 py-0.5">
|
||||||
|
<span className="text-muted-foreground shrink-0 font-mono text-[10px]">{cv.valueCode}</span>
|
||||||
|
<span className="truncate text-xs">{cv.valueLabel}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 기본값 설정 */}
|
||||||
|
{categoryValues.length > 0 && (
|
||||||
|
<div className="border-t pt-2">
|
||||||
|
<Label className="text-xs font-medium">기본값</Label>
|
||||||
|
<Select
|
||||||
|
value={config.defaultValue || "_none_"}
|
||||||
|
onValueChange={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||||
|
<SelectValue placeholder="기본값 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_none_">선택 안함</SelectItem>
|
||||||
|
{categoryValues.map((cv) => (
|
||||||
|
<SelectItem key={cv.valueCode} value={cv.valueCode}>
|
||||||
|
{cv.valueLabel}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">화면 로드 시 자동 선택될 카테고리 값</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카테고리 값 없음 안내 */}
|
||||||
|
{!loadingCategoryValues && categoryValues.length === 0 && (
|
||||||
|
<p className="text-[10px] text-amber-600">
|
||||||
|
카테고리 값이 없습니다. 테이블 카테고리 관리에서 값을 추가해주세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 정적 옵션 관리 */}
|
{/* 정적 옵션 관리 */}
|
||||||
{(config.source || "static") === "static" && (
|
{effectiveSource === "static" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs font-medium">옵션 목록</Label>
|
<Label className="text-xs font-medium">옵션 목록</Label>
|
||||||
|
|
@ -199,8 +627,8 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 공통 코드 설정 - 테이블 타입 관리에서 설정되므로 정보만 표시 */}
|
{/* 공통 코드 설정 */}
|
||||||
{config.source === "code" && (
|
{effectiveSource === "code" && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-medium">코드 그룹</Label>
|
<Label className="text-xs font-medium">코드 그룹</Label>
|
||||||
{config.codeGroup ? (
|
{config.codeGroup ? (
|
||||||
|
|
@ -212,7 +640,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 엔티티(참조 테이블) 설정 */}
|
{/* 엔티티(참조 테이블) 설정 */}
|
||||||
{config.source === "entity" && (
|
{effectiveSource === "entity" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium">참조 테이블</Label>
|
<Label className="text-xs font-medium">참조 테이블</Label>
|
||||||
|
|
@ -228,7 +656,6 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 로딩 중 표시 */}
|
|
||||||
{loadingColumns && (
|
{loadingColumns && (
|
||||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
|
@ -236,7 +663,6 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 컬럼 선택 - 테이블이 설정되어 있고 컬럼 목록이 있는 경우 Select로 표시 */}
|
|
||||||
<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">
|
||||||
<Label className="text-xs font-medium">값 컬럼 (코드)</Label>
|
<Label className="text-xs font-medium">값 컬럼 (코드)</Label>
|
||||||
|
|
@ -296,18 +722,17 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 컬럼이 없는 경우 안내 */}
|
|
||||||
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
|
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
|
||||||
<p className="text-[10px] text-amber-600">
|
<p className="text-[10px] text-amber-600">
|
||||||
테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.
|
테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 자동 채움 안내 */}
|
|
||||||
{config.entityTable && entityColumns.length > 0 && (
|
{config.entityTable && entityColumns.length > 0 && (
|
||||||
<div className="border-t pt-3">
|
<div className="border-t pt-3">
|
||||||
<p className="text-muted-foreground text-[10px]">
|
<p className="text-muted-foreground text-[10px]">
|
||||||
같은 폼에 참조 테이블({config.entityTable})의 컬럼이 배치되어 있으면, 엔티티 선택 시 해당 필드가 자동으로 채워집니다.
|
같은 폼에 참조 테이블({config.entityTable})의 컬럼이 배치되어 있으면, 엔티티 선택 시 해당 필드가 자동으로
|
||||||
|
채워집니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -368,6 +793,20 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 데이터 필터 조건 - static 소스 외 모든 소스에서 사용 */}
|
||||||
|
{effectiveSource !== "static" && filterTargetTable && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<FilterConditionsSection
|
||||||
|
filters={(config.filters as V2SelectFilter[]) || []}
|
||||||
|
columns={filterColumns}
|
||||||
|
loadingColumns={loadingFilterColumns}
|
||||||
|
targetTable={filterTargetTable}
|
||||||
|
onFiltersChange={(filters) => updateConfig("filters", filters)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
|
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
|
||||||
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
|
import type { DataProvidable, DataReceivable, DataReceiverConfig } 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대기 중인 데이터 전달 항목
|
||||||
|
* 타겟 컴포넌트가 아직 마운트되지 않은 경우 (조건부 레이어 등) 버퍼에 저장
|
||||||
|
*/
|
||||||
|
export interface PendingTransfer {
|
||||||
|
targetComponentId: string;
|
||||||
|
data: any[];
|
||||||
|
config: DataReceiverConfig;
|
||||||
|
timestamp: number;
|
||||||
|
targetLayerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ScreenContextValue {
|
interface ScreenContextValue {
|
||||||
screenId?: number;
|
screenId?: number;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
menuObjid?: number; // 메뉴 OBJID (카테고리 값 조회 시 필요)
|
menuObjid?: number;
|
||||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
splitPanelPosition?: SplitPanelPosition;
|
||||||
|
|
||||||
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
|
||||||
formData: Record<string, any>;
|
formData: Record<string, any>;
|
||||||
updateFormData: (fieldName: string, value: any) => void;
|
updateFormData: (fieldName: string, value: any) => void;
|
||||||
|
|
||||||
|
|
@ -33,6 +44,11 @@ interface ScreenContextValue {
|
||||||
// 모든 컴포넌트 조회
|
// 모든 컴포넌트 조회
|
||||||
getAllDataProviders: () => Map<string, DataProvidable>;
|
getAllDataProviders: () => Map<string, DataProvidable>;
|
||||||
getAllDataReceivers: () => Map<string, DataReceivable>;
|
getAllDataReceivers: () => Map<string, DataReceivable>;
|
||||||
|
|
||||||
|
// 대기 중인 데이터 전달 (레이어 내부 컴포넌트 미마운트 대응)
|
||||||
|
addPendingTransfer: (transfer: PendingTransfer) => void;
|
||||||
|
getPendingTransfer: (componentId: string) => PendingTransfer | undefined;
|
||||||
|
clearPendingTransfer: (componentId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ScreenContext = createContext<ScreenContextValue | null>(null);
|
const ScreenContext = createContext<ScreenContextValue | null>(null);
|
||||||
|
|
@ -57,11 +73,10 @@ export function ScreenContextProvider({
|
||||||
}: ScreenContextProviderProps) {
|
}: 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());
|
||||||
|
const pendingTransfersRef = useRef<Map<string, PendingTransfer>>(new Map());
|
||||||
|
|
||||||
// 🆕 폼 데이터 상태 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
|
||||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// 🆕 폼 데이터 업데이트 함수
|
|
||||||
const updateFormData = useCallback((fieldName: string, value: any) => {
|
const updateFormData = useCallback((fieldName: string, value: any) => {
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const updated = { ...prev, [fieldName]: value };
|
const updated = { ...prev, [fieldName]: value };
|
||||||
|
|
@ -87,6 +102,25 @@ export function ScreenContextProvider({
|
||||||
const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => {
|
const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => {
|
||||||
dataReceiversRef.current.set(componentId, receiver);
|
dataReceiversRef.current.set(componentId, receiver);
|
||||||
logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType });
|
logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType });
|
||||||
|
|
||||||
|
// 대기 중인 데이터 전달이 있으면 즉시 수신 처리
|
||||||
|
const pending = pendingTransfersRef.current.get(componentId);
|
||||||
|
if (pending) {
|
||||||
|
logger.info("대기 중인 데이터 전달 자동 수신", {
|
||||||
|
componentId,
|
||||||
|
dataCount: pending.data.length,
|
||||||
|
waitedMs: Date.now() - pending.timestamp,
|
||||||
|
});
|
||||||
|
receiver
|
||||||
|
.receiveData(pending.data, pending.config)
|
||||||
|
.then(() => {
|
||||||
|
pendingTransfersRef.current.delete(componentId);
|
||||||
|
logger.info("대기 데이터 전달 완료", { componentId });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error("대기 데이터 전달 실패", { componentId, error: err });
|
||||||
|
});
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const unregisterDataReceiver = useCallback((componentId: string) => {
|
const unregisterDataReceiver = useCallback((componentId: string) => {
|
||||||
|
|
@ -110,7 +144,24 @@ export function ScreenContextProvider({
|
||||||
return new Map(dataReceiversRef.current);
|
return new Map(dataReceiversRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
const addPendingTransfer = useCallback((transfer: PendingTransfer) => {
|
||||||
|
pendingTransfersRef.current.set(transfer.targetComponentId, transfer);
|
||||||
|
logger.info("데이터 전달 대기열 추가", {
|
||||||
|
targetComponentId: transfer.targetComponentId,
|
||||||
|
dataCount: transfer.data.length,
|
||||||
|
targetLayerId: transfer.targetLayerId,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getPendingTransfer = useCallback((componentId: string) => {
|
||||||
|
return pendingTransfersRef.current.get(componentId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearPendingTransfer = useCallback((componentId: string) => {
|
||||||
|
pendingTransfersRef.current.delete(componentId);
|
||||||
|
logger.debug("대기 데이터 전달 클리어", { componentId });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const value = React.useMemo<ScreenContextValue>(
|
const value = React.useMemo<ScreenContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
screenId,
|
screenId,
|
||||||
|
|
@ -127,6 +178,9 @@ export function ScreenContextProvider({
|
||||||
getDataReceiver,
|
getDataReceiver,
|
||||||
getAllDataProviders,
|
getAllDataProviders,
|
||||||
getAllDataReceivers,
|
getAllDataReceivers,
|
||||||
|
addPendingTransfer,
|
||||||
|
getPendingTransfer,
|
||||||
|
clearPendingTransfer,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
screenId,
|
screenId,
|
||||||
|
|
@ -143,6 +197,9 @@ export function ScreenContextProvider({
|
||||||
getDataReceiver,
|
getDataReceiver,
|
||||||
getAllDataProviders,
|
getAllDataProviders,
|
||||||
getAllDataReceivers,
|
getAllDataReceivers,
|
||||||
|
addPendingTransfer,
|
||||||
|
getPendingTransfer,
|
||||||
|
clearPendingTransfer,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
/**
|
||||||
|
* executePopAction - POP 액션 실행 순수 함수
|
||||||
|
*
|
||||||
|
* pop-button, pop-string-list 등 여러 컴포넌트에서 재사용 가능한
|
||||||
|
* 액션 실행 코어 로직. React 훅에 의존하지 않음.
|
||||||
|
*
|
||||||
|
* 사용처:
|
||||||
|
* - usePopAction 훅 (pop-button용 래퍼)
|
||||||
|
* - pop-string-list 카드 버튼 (직접 호출)
|
||||||
|
* - 향후 pop-table 행 액션 등
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ButtonMainAction } from "@/lib/registry/pop-components/pop-button";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { dataApi } from "@/lib/api/data";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 타입 정의
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/** 액션 실행 결과 */
|
||||||
|
export interface ActionResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이벤트 발행 함수 시그니처 (usePopEvent의 publish와 동일) */
|
||||||
|
type PublishFn = (eventName: string, payload?: unknown) => void;
|
||||||
|
|
||||||
|
/** executePopAction 옵션 */
|
||||||
|
interface ExecuteOptions {
|
||||||
|
/** 필드 매핑 (소스 컬럼명 → 타겟 컬럼명) */
|
||||||
|
fieldMapping?: Record<string, string>;
|
||||||
|
/** 화면 ID (이벤트 발행 시 사용) */
|
||||||
|
screenId?: string;
|
||||||
|
/** 이벤트 발행 함수 (순수 함수이므로 외부에서 주입) */
|
||||||
|
publish?: PublishFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 내부 헬퍼
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 매핑 적용
|
||||||
|
* 소스 데이터의 컬럼명을 타겟 테이블 컬럼명으로 변환
|
||||||
|
*/
|
||||||
|
function applyFieldMapping(
|
||||||
|
rowData: Record<string, unknown>,
|
||||||
|
mapping?: Record<string, string>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!mapping || Object.keys(mapping).length === 0) {
|
||||||
|
return { ...rowData };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [sourceKey, value] of Object.entries(rowData)) {
|
||||||
|
// 매핑이 있으면 타겟 키로 변환, 없으면 원본 키 유지
|
||||||
|
const targetKey = mapping[sourceKey] || sourceKey;
|
||||||
|
result[targetKey] = value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rowData에서 PK 추출
|
||||||
|
* id > pk 순서로 시도, 없으면 rowData 전체를 복합키로 사용
|
||||||
|
*/
|
||||||
|
function extractPrimaryKey(
|
||||||
|
rowData: Record<string, unknown>
|
||||||
|
): string | number | Record<string, unknown> {
|
||||||
|
if (rowData.id != null) return rowData.id as string | number;
|
||||||
|
if (rowData.pk != null) return rowData.pk as string | number;
|
||||||
|
// 복합키: rowData 전체를 Record로 전달 (dataApi.deleteRecord가 object 지원)
|
||||||
|
return rowData as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메인 함수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 액션 실행 (순수 함수)
|
||||||
|
*
|
||||||
|
* @param action - 버튼 메인 액션 설정
|
||||||
|
* @param rowData - 대상 행 데이터 (리스트 컴포넌트에서 전달)
|
||||||
|
* @param options - 필드 매핑, screenId, publish 함수
|
||||||
|
* @returns 실행 결과
|
||||||
|
*/
|
||||||
|
export async function executePopAction(
|
||||||
|
action: ButtonMainAction,
|
||||||
|
rowData?: Record<string, unknown>,
|
||||||
|
options?: ExecuteOptions
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const { fieldMapping, publish } = options || {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (action.type) {
|
||||||
|
// ── 저장 ──
|
||||||
|
case "save": {
|
||||||
|
if (!action.targetTable) {
|
||||||
|
return { success: false, error: "저장 대상 테이블이 설정되지 않았습니다." };
|
||||||
|
}
|
||||||
|
const data = rowData
|
||||||
|
? applyFieldMapping(rowData, fieldMapping)
|
||||||
|
: {};
|
||||||
|
const result = await dataApi.createRecord(action.targetTable, data);
|
||||||
|
return { success: !!result?.success, data: result?.data, error: result?.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 삭제 ──
|
||||||
|
case "delete": {
|
||||||
|
if (!action.targetTable) {
|
||||||
|
return { success: false, error: "삭제 대상 테이블이 설정되지 않았습니다." };
|
||||||
|
}
|
||||||
|
if (!rowData) {
|
||||||
|
return { success: false, error: "삭제할 데이터가 없습니다." };
|
||||||
|
}
|
||||||
|
const mappedData = applyFieldMapping(rowData, fieldMapping);
|
||||||
|
const pk = extractPrimaryKey(mappedData);
|
||||||
|
const result = await dataApi.deleteRecord(action.targetTable, pk);
|
||||||
|
return { success: !!result?.success, error: result?.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API 호출 ──
|
||||||
|
case "api": {
|
||||||
|
if (!action.apiEndpoint) {
|
||||||
|
return { success: false, error: "API 엔드포인트가 설정되지 않았습니다." };
|
||||||
|
}
|
||||||
|
const body = rowData
|
||||||
|
? applyFieldMapping(rowData, fieldMapping)
|
||||||
|
: undefined;
|
||||||
|
const method = (action.apiMethod || "POST").toUpperCase();
|
||||||
|
|
||||||
|
let response;
|
||||||
|
switch (method) {
|
||||||
|
case "GET":
|
||||||
|
response = await apiClient.get(action.apiEndpoint, { params: body });
|
||||||
|
break;
|
||||||
|
case "POST":
|
||||||
|
response = await apiClient.post(action.apiEndpoint, body);
|
||||||
|
break;
|
||||||
|
case "PUT":
|
||||||
|
response = await apiClient.put(action.apiEndpoint, body);
|
||||||
|
break;
|
||||||
|
case "DELETE":
|
||||||
|
response = await apiClient.delete(action.apiEndpoint, { data: body });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
response = await apiClient.post(action.apiEndpoint, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resData = response?.data;
|
||||||
|
return {
|
||||||
|
success: resData?.success !== false,
|
||||||
|
data: resData?.data ?? resData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 모달 열기 ──
|
||||||
|
case "modal": {
|
||||||
|
if (!publish) {
|
||||||
|
return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." };
|
||||||
|
}
|
||||||
|
publish("__pop_modal_open__", {
|
||||||
|
modalId: action.modalScreenId,
|
||||||
|
title: action.modalTitle,
|
||||||
|
mode: action.modalMode,
|
||||||
|
items: action.modalItems,
|
||||||
|
rowData,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 이벤트 발행 ──
|
||||||
|
case "event": {
|
||||||
|
if (!publish) {
|
||||||
|
return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." };
|
||||||
|
}
|
||||||
|
if (!action.eventName) {
|
||||||
|
return { success: false, error: "이벤트 이름이 설정되지 않았습니다." };
|
||||||
|
}
|
||||||
|
publish(action.eventName, {
|
||||||
|
...(action.eventPayload || {}),
|
||||||
|
row: rowData,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { success: false, error: `알 수 없는 액션 타입: ${action.type}` };
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다.";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* POP 공통 훅 배럴 파일
|
||||||
|
*
|
||||||
|
* 사용법: import { usePopEvent, useDataSource } from "@/hooks/pop";
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 이벤트 통신 훅
|
||||||
|
export { usePopEvent, cleanupScreen } from "./usePopEvent";
|
||||||
|
|
||||||
|
// 데이터 CRUD 훅
|
||||||
|
export { useDataSource } from "./useDataSource";
|
||||||
|
export type { MutationResult, DataSourceResult } from "./useDataSource";
|
||||||
|
|
||||||
|
// 액션 실행 순수 함수
|
||||||
|
export { executePopAction } from "./executePopAction";
|
||||||
|
export type { ActionResult } from "./executePopAction";
|
||||||
|
|
||||||
|
// 액션 실행 React 훅
|
||||||
|
export { usePopAction } from "./usePopAction";
|
||||||
|
export type { PendingConfirmState } from "./usePopAction";
|
||||||
|
|
||||||
|
// 연결 해석기
|
||||||
|
export { useConnectionResolver } from "./useConnectionResolver";
|
||||||
|
|
||||||
|
// 장바구니 동기화 훅
|
||||||
|
export { useCartSync } from "./useCartSync";
|
||||||
|
export type { UseCartSyncReturn } from "./useCartSync";
|
||||||
|
|
||||||
|
// SQL 빌더 유틸 (고급 사용 시)
|
||||||
|
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
/**
|
||||||
|
* POP 공통 SQL 빌더
|
||||||
|
*
|
||||||
|
* DataSourceConfig를 SQL 문자열로 변환하는 순수 유틸리티.
|
||||||
|
* 원본: pop-dashboard/utils/dataFetcher.ts에서 추출 (로직 동일).
|
||||||
|
*
|
||||||
|
* 대시보드(dataFetcher.ts)는 기존 코드를 그대로 유지하고,
|
||||||
|
* 새 컴포넌트(useDataSource 등)는 이 파일을 사용한다.
|
||||||
|
* 향후 대시보드 교체 시 dataFetcher.ts가 이 파일을 import하도록 변경 예정.
|
||||||
|
*
|
||||||
|
* 보안:
|
||||||
|
* - escapeSQL: SQL 인젝션 방지 (문자열 이스케이프)
|
||||||
|
* - sanitizeIdentifier: 테이블명/컬럼명에서 위험 문자 제거
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types";
|
||||||
|
|
||||||
|
// ===== SQL 값 이스케이프 =====
|
||||||
|
|
||||||
|
/** SQL 문자열 값 이스케이프 (SQL 인젝션 방지) */
|
||||||
|
function escapeSQL(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "NULL";
|
||||||
|
if (typeof value === "number") return String(value);
|
||||||
|
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
||||||
|
// 문자열: 작은따옴표 이스케이프
|
||||||
|
const str = String(value).replace(/'/g, "''");
|
||||||
|
return `'${str}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 식별자 검증 (테이블명, 컬럼명) =====
|
||||||
|
|
||||||
|
/** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */
|
||||||
|
function sanitizeIdentifier(name: string): string {
|
||||||
|
// 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용
|
||||||
|
return name.replace(/[^a-zA-Z0-9_.]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 설정 완료 여부 검증 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataSourceConfig의 필수값이 모두 채워졌는지 검증
|
||||||
|
* 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는
|
||||||
|
* SQL을 생성하지 않도록 사전 차단
|
||||||
|
*
|
||||||
|
* @returns null이면 유효, 문자열이면 미완료 사유
|
||||||
|
*/
|
||||||
|
export function validateDataSourceConfig(config: DataSourceConfig): string | null {
|
||||||
|
// 테이블명 필수
|
||||||
|
if (!config.tableName || !config.tableName.trim()) {
|
||||||
|
return "테이블이 선택되지 않았습니다";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 집계 함수가 설정되었으면 대상 컬럼도 필수
|
||||||
|
// (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능)
|
||||||
|
if (config.aggregation) {
|
||||||
|
const aggType = config.aggregation.type?.toLowerCase();
|
||||||
|
const aggCol = config.aggregation.column?.trim();
|
||||||
|
if (aggType !== "count" && !aggCol) {
|
||||||
|
return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조인이 있으면 조인 조건 필수
|
||||||
|
if (config.joins?.length) {
|
||||||
|
for (const join of config.joins) {
|
||||||
|
if (!join.targetTable?.trim()) {
|
||||||
|
return "조인 대상 테이블이 선택되지 않았습니다";
|
||||||
|
}
|
||||||
|
if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
|
||||||
|
return "조인 조건 컬럼이 설정되지 않았습니다";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 필터 조건 SQL 생성 =====
|
||||||
|
|
||||||
|
/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */
|
||||||
|
function buildWhereClause(filters: DataSourceFilter[]): string {
|
||||||
|
// 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어)
|
||||||
|
const validFilters = filters.filter((f) => f.column?.trim());
|
||||||
|
if (!validFilters.length) return "";
|
||||||
|
|
||||||
|
const conditions = validFilters.map((f) => {
|
||||||
|
const col = sanitizeIdentifier(f.column);
|
||||||
|
|
||||||
|
switch (f.operator) {
|
||||||
|
case "between": {
|
||||||
|
const arr = Array.isArray(f.value) ? f.value : [f.value, f.value];
|
||||||
|
return `${col} BETWEEN ${escapeSQL(arr[0])} AND ${escapeSQL(arr[1])}`;
|
||||||
|
}
|
||||||
|
case "in": {
|
||||||
|
const arr = Array.isArray(f.value) ? f.value : [f.value];
|
||||||
|
const vals = arr.map(escapeSQL).join(", ");
|
||||||
|
return `${col} IN (${vals})`;
|
||||||
|
}
|
||||||
|
case "like":
|
||||||
|
return `${col} LIKE ${escapeSQL(f.value)}`;
|
||||||
|
default:
|
||||||
|
return `${col} ${f.operator} ${escapeSQL(f.value)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return `WHERE ${conditions.join(" AND ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 집계 SQL 빌더 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataSourceConfig를 SELECT SQL로 변환
|
||||||
|
*
|
||||||
|
* @param config - 데이터 소스 설정
|
||||||
|
* @returns SQL 문자열
|
||||||
|
*/
|
||||||
|
export function buildAggregationSQL(config: DataSourceConfig): string {
|
||||||
|
const tableName = sanitizeIdentifier(config.tableName);
|
||||||
|
|
||||||
|
// SELECT 절
|
||||||
|
let selectClause: string;
|
||||||
|
if (config.aggregation) {
|
||||||
|
const aggType = config.aggregation.type.toUpperCase();
|
||||||
|
const aggCol = config.aggregation.column?.trim()
|
||||||
|
? sanitizeIdentifier(config.aggregation.column)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수
|
||||||
|
if (!aggCol) {
|
||||||
|
selectClause = aggType === "COUNT"
|
||||||
|
? "COUNT(*) as value"
|
||||||
|
: `${aggType}(${tableName}.*) as value`;
|
||||||
|
} else {
|
||||||
|
selectClause = `${aggType}(${aggCol}) as value`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GROUP BY가 있으면 해당 컬럼도 SELECT에 포함
|
||||||
|
if (config.aggregation.groupBy?.length) {
|
||||||
|
const groupCols = config.aggregation.groupBy.map(sanitizeIdentifier).join(", ");
|
||||||
|
selectClause = `${groupCols}, ${selectClause}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectClause = "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
// FROM 절 (조인 포함 - 조건이 완전한 조인만 적용)
|
||||||
|
let fromClause = tableName;
|
||||||
|
if (config.joins?.length) {
|
||||||
|
for (const join of config.joins) {
|
||||||
|
// 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어)
|
||||||
|
if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const joinTable = sanitizeIdentifier(join.targetTable);
|
||||||
|
const joinType = join.joinType.toUpperCase();
|
||||||
|
const srcCol = sanitizeIdentifier(join.on.sourceColumn);
|
||||||
|
const tgtCol = sanitizeIdentifier(join.on.targetColumn);
|
||||||
|
fromClause += ` ${joinType} JOIN ${joinTable} ON ${tableName}.${srcCol} = ${joinTable}.${tgtCol}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHERE 절
|
||||||
|
const whereClause = config.filters?.length
|
||||||
|
? buildWhereClause(config.filters)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// GROUP BY 절
|
||||||
|
let groupByClause = "";
|
||||||
|
if (config.aggregation?.groupBy?.length) {
|
||||||
|
groupByClause = `GROUP BY ${config.aggregation.groupBy.map(sanitizeIdentifier).join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ORDER BY 절
|
||||||
|
let orderByClause = "";
|
||||||
|
if (config.sort?.length) {
|
||||||
|
const sortCols = config.sort
|
||||||
|
.map((s) => `${sanitizeIdentifier(s.column)} ${s.direction.toUpperCase()}`)
|
||||||
|
.join(", ");
|
||||||
|
orderByClause = `ORDER BY ${sortCols}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIMIT 절
|
||||||
|
const limitClause = config.limit ? `LIMIT ${Math.max(1, Math.floor(config.limit))}` : "";
|
||||||
|
|
||||||
|
return [
|
||||||
|
`SELECT ${selectClause}`,
|
||||||
|
`FROM ${fromClause}`,
|
||||||
|
whereClause,
|
||||||
|
groupByClause,
|
||||||
|
orderByClause,
|
||||||
|
limitClause,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,338 @@
|
||||||
|
/**
|
||||||
|
* useCartSync - 장바구니 DB 동기화 훅
|
||||||
|
*
|
||||||
|
* DB(cart_items 테이블) <-> 로컬 상태를 동기화하고 변경사항(dirty)을 감지한다.
|
||||||
|
*
|
||||||
|
* 동작 방식:
|
||||||
|
* 1. 마운트 시 DB에서 해당 screen_id + user_id의 장바구니를 로드
|
||||||
|
* 2. addItem/removeItem/updateItem은 로컬 상태만 변경 (DB 미반영, dirty 상태)
|
||||||
|
* 3. saveToDb 호출 시 로컬 상태를 DB에 일괄 반영 (추가/수정/삭제)
|
||||||
|
* 4. isDirty = 로컬 상태와 DB 마지막 로드 상태의 차이 존재 여부
|
||||||
|
*
|
||||||
|
* 사용 예시:
|
||||||
|
* ```typescript
|
||||||
|
* const cart = useCartSync("SCR-001", "item_info");
|
||||||
|
*
|
||||||
|
* // 품목 추가 (로컬만, DB 미반영)
|
||||||
|
* cart.addItem({ row, quantity: 10 }, "D1710008");
|
||||||
|
*
|
||||||
|
* // DB 저장 (pop-icon 확인 모달에서 호출)
|
||||||
|
* await cart.saveToDb();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
import { dataApi } from "@/lib/api/data";
|
||||||
|
import type {
|
||||||
|
CartItem,
|
||||||
|
CartItemWithId,
|
||||||
|
CartSyncStatus,
|
||||||
|
CartItemStatus,
|
||||||
|
} from "@/lib/registry/pop-components/types";
|
||||||
|
|
||||||
|
// ===== 반환 타입 =====
|
||||||
|
|
||||||
|
export interface UseCartSyncReturn {
|
||||||
|
cartItems: CartItemWithId[];
|
||||||
|
savedItems: CartItemWithId[];
|
||||||
|
syncStatus: CartSyncStatus;
|
||||||
|
cartCount: number;
|
||||||
|
isDirty: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
|
||||||
|
addItem: (item: CartItem, rowKey: string) => void;
|
||||||
|
removeItem: (rowKey: string) => void;
|
||||||
|
updateItemQuantity: (rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => void;
|
||||||
|
isItemInCart: (rowKey: string) => boolean;
|
||||||
|
getCartItem: (rowKey: string) => CartItemWithId | undefined;
|
||||||
|
|
||||||
|
saveToDb: (selectedColumns?: string[]) => Promise<boolean>;
|
||||||
|
loadFromDb: () => Promise<void>;
|
||||||
|
resetToSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== DB 행 -> CartItemWithId 변환 =====
|
||||||
|
|
||||||
|
function dbRowToCartItem(dbRow: Record<string, unknown>): CartItemWithId {
|
||||||
|
let rowData: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const raw = dbRow.row_data;
|
||||||
|
if (typeof raw === "string" && raw.trim()) {
|
||||||
|
rowData = JSON.parse(raw);
|
||||||
|
} else if (typeof raw === "object" && raw !== null) {
|
||||||
|
rowData = raw as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
rowData = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let packageEntries: CartItem["packageEntries"] | undefined;
|
||||||
|
try {
|
||||||
|
const raw = dbRow.package_entries;
|
||||||
|
if (typeof raw === "string" && raw.trim()) {
|
||||||
|
packageEntries = JSON.parse(raw);
|
||||||
|
} else if (Array.isArray(raw)) {
|
||||||
|
packageEntries = raw;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
packageEntries = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
row: rowData,
|
||||||
|
quantity: Number(dbRow.quantity) || 0,
|
||||||
|
packageUnit: (dbRow.package_unit as string) || undefined,
|
||||||
|
packageEntries,
|
||||||
|
cartId: (dbRow.id as string) || undefined,
|
||||||
|
sourceTable: (dbRow.source_table as string) || "",
|
||||||
|
rowKey: (dbRow.row_key as string) || "",
|
||||||
|
status: ((dbRow.status as string) || "in_cart") as CartItemStatus,
|
||||||
|
_origin: "db",
|
||||||
|
memo: (dbRow.memo as string) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CartItemWithId -> DB 저장용 레코드 변환 =====
|
||||||
|
|
||||||
|
function cartItemToDbRecord(
|
||||||
|
item: CartItemWithId,
|
||||||
|
screenId: string,
|
||||||
|
cartType: string = "pop",
|
||||||
|
selectedColumns?: string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
// selectedColumns가 있으면 해당 컬럼만 추출, 없으면 전체 저장
|
||||||
|
const rowData =
|
||||||
|
selectedColumns && selectedColumns.length > 0
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(item.row).filter(([k]) => selectedColumns.includes(k)),
|
||||||
|
)
|
||||||
|
: item.row;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cart_type: cartType,
|
||||||
|
screen_id: screenId,
|
||||||
|
source_table: item.sourceTable,
|
||||||
|
row_key: item.rowKey,
|
||||||
|
row_data: JSON.stringify(rowData),
|
||||||
|
quantity: String(item.quantity),
|
||||||
|
unit: "",
|
||||||
|
package_unit: item.packageUnit || "",
|
||||||
|
package_entries: item.packageEntries ? JSON.stringify(item.packageEntries) : "",
|
||||||
|
status: item.status,
|
||||||
|
memo: item.memo || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== dirty check: 두 배열의 내용이 동일한지 비교 =====
|
||||||
|
|
||||||
|
function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
|
||||||
|
const serialize = (items: CartItemWithId[]) =>
|
||||||
|
items
|
||||||
|
.map((item) => `${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}`)
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
|
||||||
|
return serialize(a) === serialize(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 훅 본체 =====
|
||||||
|
|
||||||
|
export function useCartSync(
|
||||||
|
screenId: string,
|
||||||
|
sourceTable: string,
|
||||||
|
cartType?: string,
|
||||||
|
): UseCartSyncReturn {
|
||||||
|
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
|
||||||
|
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
|
||||||
|
const [syncStatus, setSyncStatus] = useState<CartSyncStatus>("clean");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const screenIdRef = useRef(screenId);
|
||||||
|
const sourceTableRef = useRef(sourceTable);
|
||||||
|
const cartTypeRef = useRef(cartType || "pop");
|
||||||
|
screenIdRef.current = screenId;
|
||||||
|
sourceTableRef.current = sourceTable;
|
||||||
|
cartTypeRef.current = cartType || "pop";
|
||||||
|
|
||||||
|
// ----- DB에서 장바구니 로드 -----
|
||||||
|
const loadFromDb = useCallback(async () => {
|
||||||
|
if (!screenId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await dataApi.getTableData("cart_items", {
|
||||||
|
size: 500,
|
||||||
|
filters: {
|
||||||
|
screen_id: screenId,
|
||||||
|
cart_type: cartTypeRef.current,
|
||||||
|
status: "in_cart",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = (result.data || []).map(dbRowToCartItem);
|
||||||
|
setSavedItems(items);
|
||||||
|
setCartItems(items);
|
||||||
|
setSyncStatus("clean");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[useCartSync] DB 로드 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [screenId]);
|
||||||
|
|
||||||
|
// 마운트 시 자동 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadFromDb();
|
||||||
|
}, [loadFromDb]);
|
||||||
|
|
||||||
|
// ----- dirty 상태 계산 -----
|
||||||
|
const isDirty = !areItemsEqual(cartItems, savedItems);
|
||||||
|
|
||||||
|
// isDirty 변경 시 syncStatus 자동 갱신
|
||||||
|
useEffect(() => {
|
||||||
|
if (syncStatus !== "saving") {
|
||||||
|
setSyncStatus(isDirty ? "dirty" : "clean");
|
||||||
|
}
|
||||||
|
}, [isDirty, syncStatus]);
|
||||||
|
|
||||||
|
// ----- 로컬 조작 (DB 미반영) -----
|
||||||
|
|
||||||
|
const addItem = useCallback(
|
||||||
|
(item: CartItem, rowKey: string) => {
|
||||||
|
setCartItems((prev) => {
|
||||||
|
const exists = prev.find((i) => i.rowKey === rowKey);
|
||||||
|
if (exists) {
|
||||||
|
return prev.map((i) =>
|
||||||
|
i.rowKey === rowKey
|
||||||
|
? { ...i, quantity: item.quantity, packageUnit: item.packageUnit, packageEntries: item.packageEntries, row: item.row }
|
||||||
|
: i,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const newItem: CartItemWithId = {
|
||||||
|
...item,
|
||||||
|
cartId: undefined,
|
||||||
|
sourceTable: sourceTableRef.current,
|
||||||
|
rowKey,
|
||||||
|
status: "in_cart",
|
||||||
|
_origin: "local",
|
||||||
|
};
|
||||||
|
return [...prev, newItem];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeItem = useCallback((rowKey: string) => {
|
||||||
|
setCartItems((prev) => prev.filter((i) => i.rowKey !== rowKey));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateItemQuantity = useCallback(
|
||||||
|
(rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => {
|
||||||
|
setCartItems((prev) =>
|
||||||
|
prev.map((i) =>
|
||||||
|
i.rowKey === rowKey
|
||||||
|
? {
|
||||||
|
...i,
|
||||||
|
quantity,
|
||||||
|
...(packageUnit !== undefined && { packageUnit }),
|
||||||
|
...(packageEntries !== undefined && { packageEntries }),
|
||||||
|
}
|
||||||
|
: i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isItemInCart = useCallback(
|
||||||
|
(rowKey: string) => cartItems.some((i) => i.rowKey === rowKey),
|
||||||
|
[cartItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCartItem = useCallback(
|
||||||
|
(rowKey: string) => cartItems.find((i) => i.rowKey === rowKey),
|
||||||
|
[cartItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ----- DB 저장 (일괄) -----
|
||||||
|
const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => {
|
||||||
|
setSyncStatus("saving");
|
||||||
|
try {
|
||||||
|
const currentScreenId = screenIdRef.current;
|
||||||
|
|
||||||
|
// 삭제 대상: savedItems에 있지만 cartItems에 없는 것
|
||||||
|
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
|
||||||
|
const toDelete = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
|
||||||
|
|
||||||
|
// 추가 대상: cartItems에 있지만 cartId가 없는 것 (로컬에서 추가됨)
|
||||||
|
const toCreate = cartItems.filter((c) => !c.cartId);
|
||||||
|
|
||||||
|
// 수정 대상: 양쪽 다 존재하고 cartId 있으면서 내용이 다른 것
|
||||||
|
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
|
||||||
|
const toUpdate = cartItems.filter((c) => {
|
||||||
|
if (!c.cartId) return false;
|
||||||
|
const saved = savedMap.get(c.rowKey);
|
||||||
|
if (!saved) return false;
|
||||||
|
return (
|
||||||
|
c.quantity !== saved.quantity ||
|
||||||
|
c.packageUnit !== saved.packageUnit ||
|
||||||
|
c.status !== saved.status
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const promises: Promise<unknown>[] = [];
|
||||||
|
|
||||||
|
for (const item of toDelete) {
|
||||||
|
promises.push(dataApi.deleteRecord("cart_items", item.cartId!));
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCartType = cartTypeRef.current;
|
||||||
|
|
||||||
|
for (const item of toCreate) {
|
||||||
|
const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns);
|
||||||
|
promises.push(dataApi.createRecord("cart_items", record));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of toUpdate) {
|
||||||
|
const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns);
|
||||||
|
promises.push(dataApi.updateRecord("cart_items", item.cartId!, record));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
// 저장 후 DB에서 다시 로드하여 cartId 등을 최신화
|
||||||
|
await loadFromDb();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[useCartSync] DB 저장 실패:", err);
|
||||||
|
setSyncStatus("dirty");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [cartItems, savedItems, loadFromDb]);
|
||||||
|
|
||||||
|
// ----- 로컬 변경 취소 -----
|
||||||
|
const resetToSaved = useCallback(() => {
|
||||||
|
setCartItems(savedItems);
|
||||||
|
setSyncStatus("clean");
|
||||||
|
}, [savedItems]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cartItems,
|
||||||
|
savedItems,
|
||||||
|
syncStatus,
|
||||||
|
cartCount: cartItems.length,
|
||||||
|
isDirty,
|
||||||
|
loading,
|
||||||
|
addItem,
|
||||||
|
removeItem,
|
||||||
|
updateItemQuantity,
|
||||||
|
isItemInCart,
|
||||||
|
getCartItem,
|
||||||
|
saveToDb,
|
||||||
|
loadFromDb,
|
||||||
|
resetToSaved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
/**
|
||||||
|
* useConnectionResolver - 런타임 컴포넌트 연결 해석기
|
||||||
|
*
|
||||||
|
* PopViewerWithModals에서 사용.
|
||||||
|
* layout.dataFlow.connections를 읽고, 소스 컴포넌트의 __comp_output__ 이벤트를
|
||||||
|
* 타겟 컴포넌트의 __comp_input__ 이벤트로 자동 변환/중계한다.
|
||||||
|
*
|
||||||
|
* 이벤트 규칙:
|
||||||
|
* 소스: __comp_output__${sourceComponentId}__${outputKey}
|
||||||
|
* 타겟: __comp_input__${targetComponentId}__${inputKey}
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { usePopEvent } from "./usePopEvent";
|
||||||
|
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
|
||||||
|
|
||||||
|
interface UseConnectionResolverOptions {
|
||||||
|
screenId: string;
|
||||||
|
connections: PopDataConnection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConnectionResolver({
|
||||||
|
screenId,
|
||||||
|
connections,
|
||||||
|
}: UseConnectionResolverOptions): void {
|
||||||
|
const { publish, subscribe } = usePopEvent(screenId);
|
||||||
|
|
||||||
|
// 연결 목록을 ref로 저장하여 콜백 안정성 확보
|
||||||
|
const connectionsRef = useRef(connections);
|
||||||
|
connectionsRef.current = connections;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connections || connections.length === 0) return;
|
||||||
|
|
||||||
|
const unsubscribers: (() => void)[] = [];
|
||||||
|
|
||||||
|
// 소스별로 그룹핑하여 구독 생성
|
||||||
|
const sourceGroups = new Map<string, PopDataConnection[]>();
|
||||||
|
for (const conn of connections) {
|
||||||
|
const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`;
|
||||||
|
const existing = sourceGroups.get(sourceEvent) || [];
|
||||||
|
existing.push(conn);
|
||||||
|
sourceGroups.set(sourceEvent, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sourceEvent, conns] of sourceGroups) {
|
||||||
|
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||||
|
for (const conn of conns) {
|
||||||
|
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
|
||||||
|
|
||||||
|
// 항상 통일된 구조로 감싸서 전달: { value, filterConfig?, _connectionId }
|
||||||
|
const enrichedPayload = {
|
||||||
|
value: payload,
|
||||||
|
filterConfig: conn.filterConfig,
|
||||||
|
_connectionId: conn.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
publish(targetEvent, enrichedPayload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unsubscribers.push(unsub);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const unsub of unsubscribers) {
|
||||||
|
unsub();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [screenId, connections, subscribe, publish]);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,383 @@
|
||||||
|
/**
|
||||||
|
* useDataSource - POP 컴포넌트용 데이터 CRUD 통합 훅
|
||||||
|
*
|
||||||
|
* DataSourceConfig를 받아서 자동으로 적절한 API를 선택하여 데이터를 조회/생성/수정/삭제한다.
|
||||||
|
*
|
||||||
|
* 조회 분기:
|
||||||
|
* - aggregation 또는 joins가 있으면 → SQL 빌더 + executeQuery (대시보드와 동일)
|
||||||
|
* - 그 외 → dataApi.getTableData (단순 테이블 조회)
|
||||||
|
*
|
||||||
|
* CRUD:
|
||||||
|
* - save: dataApi.createRecord
|
||||||
|
* - update: dataApi.updateRecord
|
||||||
|
* - remove: dataApi.deleteRecord
|
||||||
|
*
|
||||||
|
* 사용 패턴:
|
||||||
|
* ```typescript
|
||||||
|
* // 집계 조회 (대시보드용)
|
||||||
|
* const { data, loading } = useDataSource({
|
||||||
|
* tableName: "sales_order",
|
||||||
|
* aggregation: { type: "sum", column: "amount", groupBy: ["category"] },
|
||||||
|
* refreshInterval: 30,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 단순 목록 조회 (테이블용)
|
||||||
|
* const { data, refetch } = useDataSource({
|
||||||
|
* tableName: "purchase_order",
|
||||||
|
* sort: [{ column: "created_at", direction: "desc" }],
|
||||||
|
* limit: 20,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 저장/삭제 (버튼용)
|
||||||
|
* const { save, remove } = useDataSource({ tableName: "inbound_record" });
|
||||||
|
* await save({ supplier_id: "SUP-001", quantity: 50 });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
|
import { dataApi } from "@/lib/api/data";
|
||||||
|
import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types";
|
||||||
|
import { validateDataSourceConfig, buildAggregationSQL } from "./popSqlBuilder";
|
||||||
|
|
||||||
|
// ===== 타입 정의 =====
|
||||||
|
|
||||||
|
/** 조회 결과 */
|
||||||
|
export interface DataSourceResult {
|
||||||
|
/** 데이터 행 배열 */
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
/** 단일 집계 값 (aggregation 시) 또는 전체 행 수 */
|
||||||
|
value: number;
|
||||||
|
/** 전체 행 수 (페이징용) */
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CRUD 작업 결과 */
|
||||||
|
export interface MutationResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** refetch 시 전달할 오버라이드 필터 */
|
||||||
|
interface OverrideOptions {
|
||||||
|
filters?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 내부: 집계/조인 조회 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 집계 또는 조인이 포함된 DataSourceConfig를 SQL로 변환하여 실행
|
||||||
|
* dataFetcher.ts의 fetchAggregatedData와 동일한 로직
|
||||||
|
*/
|
||||||
|
async function fetchWithSqlBuilder(
|
||||||
|
config: DataSourceConfig
|
||||||
|
): Promise<DataSourceResult> {
|
||||||
|
const sql = buildAggregationSQL(config);
|
||||||
|
|
||||||
|
// API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백
|
||||||
|
let queryResult: { columns: string[]; rows: Record<string, unknown>[] };
|
||||||
|
try {
|
||||||
|
// 1차: apiClient (axios 기반, 인증/세션 안정적)
|
||||||
|
const response = await apiClient.post("/dashboards/execute-query", { query: sql });
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
queryResult = response.data.data;
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data?.message || "쿼리 실행 실패");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 2차: dashboardApi (fetch 기반, 폴백)
|
||||||
|
queryResult = await dashboardApi.executeQuery(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryResult.rows.length === 0) {
|
||||||
|
return { rows: [], value: 0, total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨 → 숫자 변환
|
||||||
|
const processedRows = queryResult.rows.map((row) => {
|
||||||
|
const converted: Record<string, unknown> = { ...row };
|
||||||
|
for (const key of Object.keys(converted)) {
|
||||||
|
const val = converted[key];
|
||||||
|
if (typeof val === "string" && val !== "" && !isNaN(Number(val))) {
|
||||||
|
converted[key] = Number(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return converted;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 첫 번째 행의 value 컬럼 추출
|
||||||
|
const firstRow = processedRows[0];
|
||||||
|
const numericValue = parseFloat(
|
||||||
|
String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: processedRows,
|
||||||
|
value: Number.isFinite(numericValue) ? numericValue : 0,
|
||||||
|
total: processedRows.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 내부: 단순 테이블 조회 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* aggregation/joins 없는 단순 테이블 조회
|
||||||
|
* dataApi.getTableData 래핑
|
||||||
|
*/
|
||||||
|
async function fetchSimpleTable(
|
||||||
|
config: DataSourceConfig,
|
||||||
|
overrideFilters?: Record<string, unknown>
|
||||||
|
): Promise<DataSourceResult> {
|
||||||
|
// config.filters를 Record<string, unknown> 형태로 변환
|
||||||
|
const baseFilters: Record<string, unknown> = {};
|
||||||
|
if (config.filters?.length) {
|
||||||
|
for (const f of config.filters) {
|
||||||
|
if (f.column?.trim()) {
|
||||||
|
baseFilters[f.column] = f.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// overrideFilters가 있으면 병합 (같은 키는 override가 덮어씀)
|
||||||
|
const mergedFilters = overrideFilters
|
||||||
|
? { ...baseFilters, ...overrideFilters }
|
||||||
|
: baseFilters;
|
||||||
|
|
||||||
|
const tableResult = await dataApi.getTableData(config.tableName, {
|
||||||
|
page: 1,
|
||||||
|
size: config.limit ?? 100,
|
||||||
|
sortBy: config.sort?.[0]?.column,
|
||||||
|
sortOrder: config.sort?.[0]?.direction,
|
||||||
|
filters: Object.keys(mergedFilters).length > 0 ? mergedFilters : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: tableResult.data,
|
||||||
|
value: tableResult.total ?? tableResult.data.length,
|
||||||
|
total: tableResult.total ?? tableResult.data.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 내부: overrideFilters를 DataSourceFilter 배열에 병합 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 config에 overrideFilters를 병합한 새 config 생성
|
||||||
|
* 같은 column이 있으면 override 값으로 대체
|
||||||
|
*/
|
||||||
|
function mergeFilters(
|
||||||
|
config: DataSourceConfig,
|
||||||
|
overrideFilters?: Record<string, unknown>
|
||||||
|
): DataSourceConfig {
|
||||||
|
if (!overrideFilters || Object.keys(overrideFilters).length === 0) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 filters에서 override 대상이 아닌 것만 유지
|
||||||
|
const overrideColumns = new Set(Object.keys(overrideFilters));
|
||||||
|
const existingFilters: DataSourceFilter[] = (config.filters ?? []).filter(
|
||||||
|
(f) => !overrideColumns.has(f.column)
|
||||||
|
);
|
||||||
|
|
||||||
|
// override를 DataSourceFilter로 변환하여 추가
|
||||||
|
const newFilters: DataSourceFilter[] = Object.entries(overrideFilters).map(
|
||||||
|
([column, value]) => ({
|
||||||
|
column,
|
||||||
|
operator: "=" as const,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
filters: [...existingFilters, ...newFilters],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 메인 훅 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 컴포넌트용 데이터 CRUD 통합 훅
|
||||||
|
*
|
||||||
|
* @param config - DataSourceConfig (tableName 필수)
|
||||||
|
* @returns data, loading, error, refetch, save, update, remove
|
||||||
|
*/
|
||||||
|
export function useDataSource(config: DataSourceConfig) {
|
||||||
|
const [data, setData] = useState<DataSourceResult>({
|
||||||
|
rows: [],
|
||||||
|
value: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// config를 ref로 저장 (콜백 안정성)
|
||||||
|
const configRef = useRef(config);
|
||||||
|
configRef.current = config;
|
||||||
|
|
||||||
|
// 자동 새로고침 타이머
|
||||||
|
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// ===== 조회 (READ) =====
|
||||||
|
|
||||||
|
const refetch = useCallback(
|
||||||
|
async (options?: OverrideOptions): Promise<void> => {
|
||||||
|
const currentConfig = configRef.current;
|
||||||
|
|
||||||
|
// 테이블명 없으면 조회하지 않음
|
||||||
|
if (!currentConfig.tableName?.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasAggregation = !!currentConfig.aggregation;
|
||||||
|
const hasJoins = !!(currentConfig.joins && currentConfig.joins.length > 0);
|
||||||
|
|
||||||
|
let result: DataSourceResult;
|
||||||
|
|
||||||
|
if (hasAggregation || hasJoins) {
|
||||||
|
// 집계/조인 → SQL 빌더 경로
|
||||||
|
// 설정 완료 여부 검증
|
||||||
|
const merged = mergeFilters(currentConfig, options?.filters);
|
||||||
|
const validationError = validateDataSourceConfig(merged);
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = await fetchWithSqlBuilder(merged);
|
||||||
|
} else {
|
||||||
|
// 단순 조회 → dataApi 경로
|
||||||
|
result = await fetchSimpleTable(currentConfig, options?.filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "데이터 조회 실패";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[] // configRef 사용으로 의존성 불필요
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== 생성 (CREATE) =====
|
||||||
|
|
||||||
|
const save = useCallback(
|
||||||
|
async (record: Record<string, unknown>): Promise<MutationResult> => {
|
||||||
|
const tableName = configRef.current.tableName;
|
||||||
|
if (!tableName?.trim()) {
|
||||||
|
return { success: false, error: "테이블이 설정되지 않았습니다" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await dataApi.createRecord(tableName, record);
|
||||||
|
return {
|
||||||
|
success: result.success ?? true,
|
||||||
|
data: result.data,
|
||||||
|
error: result.message,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "레코드 생성 실패";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== 수정 (UPDATE) =====
|
||||||
|
|
||||||
|
const update = useCallback(
|
||||||
|
async (
|
||||||
|
id: string | number,
|
||||||
|
record: Record<string, unknown>
|
||||||
|
): Promise<MutationResult> => {
|
||||||
|
const tableName = configRef.current.tableName;
|
||||||
|
if (!tableName?.trim()) {
|
||||||
|
return { success: false, error: "테이블이 설정되지 않았습니다" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await dataApi.updateRecord(tableName, id, record);
|
||||||
|
return {
|
||||||
|
success: result.success ?? true,
|
||||||
|
data: result.data,
|
||||||
|
error: result.message,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "레코드 수정 실패";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== 삭제 (DELETE) =====
|
||||||
|
|
||||||
|
const remove = useCallback(
|
||||||
|
async (
|
||||||
|
id: string | number | Record<string, unknown>
|
||||||
|
): Promise<MutationResult> => {
|
||||||
|
const tableName = configRef.current.tableName;
|
||||||
|
if (!tableName?.trim()) {
|
||||||
|
return { success: false, error: "테이블이 설정되지 않았습니다" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await dataApi.deleteRecord(tableName, id);
|
||||||
|
return {
|
||||||
|
success: result.success ?? true,
|
||||||
|
data: result.data,
|
||||||
|
error: result.message,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "레코드 삭제 실패";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== 자동 조회 + 새로고침 =====
|
||||||
|
|
||||||
|
// config.tableName 또는 refreshInterval이 변경되면 재조회
|
||||||
|
const tableName = config.tableName;
|
||||||
|
const refreshInterval = config.refreshInterval;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 테이블명 있으면 초기 조회
|
||||||
|
if (tableName?.trim()) {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshInterval 설정 시 자동 새로고침
|
||||||
|
if (refreshInterval && refreshInterval > 0) {
|
||||||
|
const sec = Math.max(5, refreshInterval); // 최소 5초
|
||||||
|
refreshTimerRef.current = setInterval(() => {
|
||||||
|
refetch();
|
||||||
|
}, sec * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (refreshTimerRef.current) {
|
||||||
|
clearInterval(refreshTimerRef.current);
|
||||||
|
refreshTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [tableName, refreshInterval, refetch]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
save,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
/**
|
||||||
|
* usePopAction - POP 액션 실행 React 훅
|
||||||
|
*
|
||||||
|
* executePopAction (순수 함수)를 래핑하여 React UI 관심사를 처리:
|
||||||
|
* - 로딩 상태 (isLoading)
|
||||||
|
* - 확인 다이얼로그 (pendingConfirm)
|
||||||
|
* - 토스트 알림
|
||||||
|
* - 후속 액션 체이닝 (followUpActions)
|
||||||
|
*
|
||||||
|
* 사용처:
|
||||||
|
* - PopButtonComponent (메인 버튼)
|
||||||
|
*
|
||||||
|
* pop-string-list 등 리스트 컴포넌트는 executePopAction을 직접 호출하여
|
||||||
|
* 훅 인스턴스 폭발 문제를 회피함.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from "react";
|
||||||
|
import type {
|
||||||
|
ButtonMainAction,
|
||||||
|
FollowUpAction,
|
||||||
|
ConfirmConfig,
|
||||||
|
} from "@/lib/registry/pop-components/pop-button";
|
||||||
|
import { usePopEvent } from "./usePopEvent";
|
||||||
|
import { executePopAction } from "./executePopAction";
|
||||||
|
import type { ActionResult } from "./executePopAction";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 타입 정의
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/** 확인 대기 중인 액션 상태 */
|
||||||
|
export interface PendingConfirmState {
|
||||||
|
action: ButtonMainAction;
|
||||||
|
rowData?: Record<string, unknown>;
|
||||||
|
fieldMapping?: Record<string, string>;
|
||||||
|
confirm: ConfirmConfig;
|
||||||
|
followUpActions?: FollowUpAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** execute 호출 시 옵션 */
|
||||||
|
interface ExecuteActionOptions {
|
||||||
|
/** 대상 행 데이터 */
|
||||||
|
rowData?: Record<string, unknown>;
|
||||||
|
/** 필드 매핑 */
|
||||||
|
fieldMapping?: Record<string, string>;
|
||||||
|
/** 확인 다이얼로그 설정 */
|
||||||
|
confirm?: ConfirmConfig;
|
||||||
|
/** 후속 액션 */
|
||||||
|
followUpActions?: FollowUpAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 상수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/** 액션 성공 시 토스트 메시지 */
|
||||||
|
const ACTION_SUCCESS_MESSAGES: Record<string, string> = {
|
||||||
|
save: "저장되었습니다.",
|
||||||
|
delete: "삭제되었습니다.",
|
||||||
|
api: "요청이 완료되었습니다.",
|
||||||
|
modal: "",
|
||||||
|
event: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메인 훅
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 액션 실행 훅
|
||||||
|
*
|
||||||
|
* @param screenId - 화면 ID (이벤트 버스 연결용)
|
||||||
|
* @returns execute, isLoading, pendingConfirm, confirmExecute, cancelConfirm
|
||||||
|
*/
|
||||||
|
export function usePopAction(screenId: string) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [pendingConfirm, setPendingConfirm] = useState<PendingConfirmState | null>(null);
|
||||||
|
|
||||||
|
const { publish } = usePopEvent(screenId);
|
||||||
|
|
||||||
|
// publish 안정성 보장 (콜백 내에서 최신 참조 사용)
|
||||||
|
const publishRef = useRef(publish);
|
||||||
|
publishRef.current = publish;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제 실행 (확인 다이얼로그 이후 or 확인 불필요 시)
|
||||||
|
*/
|
||||||
|
const runAction = useCallback(
|
||||||
|
async (
|
||||||
|
action: ButtonMainAction,
|
||||||
|
rowData?: Record<string, unknown>,
|
||||||
|
fieldMapping?: Record<string, string>,
|
||||||
|
followUpActions?: FollowUpAction[]
|
||||||
|
): Promise<ActionResult> => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await executePopAction(action, rowData, {
|
||||||
|
fieldMapping,
|
||||||
|
screenId,
|
||||||
|
publish: publishRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 결과에 따른 토스트
|
||||||
|
if (result.success) {
|
||||||
|
const msg = ACTION_SUCCESS_MESSAGES[action.type];
|
||||||
|
if (msg) toast.success(msg);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "작업에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 시 후속 액션 실행
|
||||||
|
if (result.success && followUpActions?.length) {
|
||||||
|
await executeFollowUpActions(followUpActions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[screenId]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 후속 액션 실행
|
||||||
|
*/
|
||||||
|
const executeFollowUpActions = useCallback(
|
||||||
|
async (actions: FollowUpAction[]) => {
|
||||||
|
for (const followUp of actions) {
|
||||||
|
switch (followUp.type) {
|
||||||
|
case "event":
|
||||||
|
if (followUp.eventName) {
|
||||||
|
publishRef.current(followUp.eventName, followUp.eventPayload);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "refresh":
|
||||||
|
// 새로고침 이벤트 발행 (구독하는 컴포넌트가 refetch)
|
||||||
|
publishRef.current("__pop_refresh__");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "navigate":
|
||||||
|
if (followUp.targetScreenId) {
|
||||||
|
publishRef.current("__pop_navigate__", {
|
||||||
|
screenId: followUp.targetScreenId,
|
||||||
|
params: followUp.params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "close-modal":
|
||||||
|
publishRef.current("__pop_modal_close__");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부에서 호출하는 실행 함수
|
||||||
|
* confirm이 활성화되어 있으면 pendingConfirm에 저장하고 대기.
|
||||||
|
* 비활성화이면 즉시 실행.
|
||||||
|
*/
|
||||||
|
const execute = useCallback(
|
||||||
|
async (
|
||||||
|
action: ButtonMainAction,
|
||||||
|
options?: ExecuteActionOptions
|
||||||
|
): Promise<ActionResult> => {
|
||||||
|
const { rowData, fieldMapping, confirm, followUpActions } = options || {};
|
||||||
|
|
||||||
|
// 확인 다이얼로그 필요 시 대기
|
||||||
|
if (confirm?.enabled) {
|
||||||
|
setPendingConfirm({
|
||||||
|
action,
|
||||||
|
rowData,
|
||||||
|
fieldMapping,
|
||||||
|
confirm,
|
||||||
|
followUpActions,
|
||||||
|
});
|
||||||
|
return { success: true }; // 대기 상태이므로 일단 success
|
||||||
|
}
|
||||||
|
|
||||||
|
// 즉시 실행
|
||||||
|
return runAction(action, rowData, fieldMapping, followUpActions);
|
||||||
|
},
|
||||||
|
[runAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 확인 다이얼로그에서 "확인" 클릭 시
|
||||||
|
*/
|
||||||
|
const confirmExecute = useCallback(async () => {
|
||||||
|
if (!pendingConfirm) return;
|
||||||
|
|
||||||
|
const { action, rowData, fieldMapping, followUpActions } = pendingConfirm;
|
||||||
|
setPendingConfirm(null);
|
||||||
|
|
||||||
|
await runAction(action, rowData, fieldMapping, followUpActions);
|
||||||
|
}, [pendingConfirm, runAction]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 확인 다이얼로그에서 "취소" 클릭 시
|
||||||
|
*/
|
||||||
|
const cancelConfirm = useCallback(() => {
|
||||||
|
setPendingConfirm(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
execute,
|
||||||
|
isLoading,
|
||||||
|
pendingConfirm,
|
||||||
|
confirmExecute,
|
||||||
|
cancelConfirm,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
/**
|
||||||
|
* usePopEvent - POP 컴포넌트 간 이벤트 통신 훅
|
||||||
|
*
|
||||||
|
* 같은 화면(screenId) 안에서만 동작하는 이벤트 버스.
|
||||||
|
* 다른 screenId 간에는 완전히 격리됨.
|
||||||
|
*
|
||||||
|
* 주요 기능:
|
||||||
|
* - publish/subscribe: 일회성 이벤트 (거래처 선택됨, 저장 완료 등)
|
||||||
|
* - getSharedData/setSharedData: 지속성 상태 (버튼 클릭 시 다른 컴포넌트 값 수집용)
|
||||||
|
*
|
||||||
|
* 사용 패턴:
|
||||||
|
* ```typescript
|
||||||
|
* const { publish, subscribe, getSharedData, setSharedData } = usePopEvent("S001");
|
||||||
|
*
|
||||||
|
* // 이벤트 구독 (반드시 useEffect 안에서, cleanup 필수)
|
||||||
|
* useEffect(() => {
|
||||||
|
* const unsub = subscribe("supplier-selected", (payload) => {
|
||||||
|
* console.log(payload.supplierId);
|
||||||
|
* });
|
||||||
|
* return unsub;
|
||||||
|
* }, []);
|
||||||
|
*
|
||||||
|
* // 이벤트 발행
|
||||||
|
* publish("supplier-selected", { supplierId: "SUP-001" });
|
||||||
|
*
|
||||||
|
* // 공유 데이터 저장/조회
|
||||||
|
* setSharedData("selectedSupplier", { id: "SUP-001" });
|
||||||
|
* const supplier = getSharedData("selectedSupplier");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
// ===== 타입 정의 =====
|
||||||
|
|
||||||
|
/** 이벤트 콜백 함수 타입 */
|
||||||
|
type EventCallback = (payload: unknown) => void;
|
||||||
|
|
||||||
|
/** 화면별 이벤트 리스너 맵: eventName -> Set<callback> */
|
||||||
|
type ListenerMap = Map<string, Set<EventCallback>>;
|
||||||
|
|
||||||
|
/** 화면별 공유 데이터 맵: key -> value */
|
||||||
|
type SharedDataMap = Map<string, unknown>;
|
||||||
|
|
||||||
|
// ===== 전역 저장소 (React 외부, 모듈 스코프) =====
|
||||||
|
// SSR 환경에서 서버/클라이언트 간 공유 방지
|
||||||
|
|
||||||
|
/** screenId별 이벤트 리스너 저장소 */
|
||||||
|
const screenBuses: Map<string, ListenerMap> =
|
||||||
|
typeof window !== "undefined" ? new Map() : new Map();
|
||||||
|
|
||||||
|
/** screenId별 공유 데이터 저장소 */
|
||||||
|
const sharedDataStore: Map<string, SharedDataMap> =
|
||||||
|
typeof window !== "undefined" ? new Map() : new Map();
|
||||||
|
|
||||||
|
// ===== 내부 헬퍼 =====
|
||||||
|
|
||||||
|
/** 해당 screenId의 리스너 맵 가져오기 (없으면 생성) */
|
||||||
|
function getListenerMap(screenId: string): ListenerMap {
|
||||||
|
let map = screenBuses.get(screenId);
|
||||||
|
if (!map) {
|
||||||
|
map = new Map();
|
||||||
|
screenBuses.set(screenId, map);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 해당 screenId의 공유 데이터 맵 가져오기 (없으면 생성) */
|
||||||
|
function getSharedMap(screenId: string): SharedDataMap {
|
||||||
|
let map = sharedDataStore.get(screenId);
|
||||||
|
if (!map) {
|
||||||
|
map = new Map();
|
||||||
|
sharedDataStore.set(screenId, map);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 외부 API: 화면 정리 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 언마운트 시 해당 screenId의 모든 리스너 + 공유 데이터 정리
|
||||||
|
* 메모리 누수 방지용. 뷰어 또는 PopRenderer에서 화면 전환 시 호출.
|
||||||
|
*/
|
||||||
|
export function cleanupScreen(screenId: string): void {
|
||||||
|
screenBuses.delete(screenId);
|
||||||
|
sharedDataStore.delete(screenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 메인 훅 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 컴포넌트 간 이벤트 통신 훅
|
||||||
|
*
|
||||||
|
* @param screenId - 화면 ID (같은 screenId 안에서만 통신)
|
||||||
|
* @returns publish, subscribe, getSharedData, setSharedData
|
||||||
|
*/
|
||||||
|
export function usePopEvent(screenId: string) {
|
||||||
|
// screenId를 ref로 저장 (콜백 안정성)
|
||||||
|
const screenIdRef = useRef(screenId);
|
||||||
|
screenIdRef.current = screenId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 발행
|
||||||
|
* 해당 screenId + eventName에 등록된 모든 콜백에 payload 전달
|
||||||
|
*/
|
||||||
|
const publish = useCallback(
|
||||||
|
(eventName: string, payload?: unknown): void => {
|
||||||
|
const listeners = getListenerMap(screenIdRef.current);
|
||||||
|
const callbacks = listeners.get(eventName);
|
||||||
|
if (!callbacks || callbacks.size === 0) return;
|
||||||
|
|
||||||
|
// Set을 배열로 복사 후 순회 (순회 중 unsubscribe 안전)
|
||||||
|
const callbackArray = Array.from(callbacks);
|
||||||
|
for (const cb of callbackArray) {
|
||||||
|
try {
|
||||||
|
cb(payload);
|
||||||
|
} catch (err) {
|
||||||
|
// 개별 콜백 에러가 다른 콜백 실행을 막지 않음
|
||||||
|
console.error(
|
||||||
|
`[usePopEvent] 콜백 에러 (screen: ${screenIdRef.current}, event: ${eventName}):`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 구독
|
||||||
|
*
|
||||||
|
* 주의: 반드시 useEffect 안에서 호출하고, 반환값(unsubscribe)을 cleanup에서 호출할 것.
|
||||||
|
*
|
||||||
|
* @returns unsubscribe 함수
|
||||||
|
*/
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(eventName: string, callback: EventCallback): (() => void) => {
|
||||||
|
const listeners = getListenerMap(screenIdRef.current);
|
||||||
|
|
||||||
|
let callbacks = listeners.get(eventName);
|
||||||
|
if (!callbacks) {
|
||||||
|
callbacks = new Set();
|
||||||
|
listeners.set(eventName, callbacks);
|
||||||
|
}
|
||||||
|
callbacks.add(callback);
|
||||||
|
|
||||||
|
// unsubscribe 함수 반환
|
||||||
|
const capturedScreenId = screenIdRef.current;
|
||||||
|
return () => {
|
||||||
|
const map = screenBuses.get(capturedScreenId);
|
||||||
|
if (!map) return;
|
||||||
|
const cbs = map.get(eventName);
|
||||||
|
if (!cbs) return;
|
||||||
|
cbs.delete(callback);
|
||||||
|
// 빈 Set 정리
|
||||||
|
if (cbs.size === 0) {
|
||||||
|
map.delete(eventName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 데이터 조회
|
||||||
|
* 다른 컴포넌트가 setSharedData로 저장한 값을 가져옴
|
||||||
|
*/
|
||||||
|
const getSharedData = useCallback(
|
||||||
|
<T = unknown>(key: string): T | undefined => {
|
||||||
|
const shared = sharedDataStore.get(screenIdRef.current);
|
||||||
|
if (!shared) return undefined;
|
||||||
|
return shared.get(key) as T | undefined;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 데이터 저장
|
||||||
|
* 같은 screenId의 다른 컴포넌트가 getSharedData로 읽을 수 있음
|
||||||
|
*/
|
||||||
|
const setSharedData = useCallback(
|
||||||
|
(key: string, value: unknown): void => {
|
||||||
|
const shared = getSharedMap(screenIdRef.current);
|
||||||
|
shared.set(key, value);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { publish, subscribe, getSharedData, setSharedData } as const;
|
||||||
|
}
|
||||||
|
|
@ -545,7 +545,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
let currentValue;
|
let currentValue;
|
||||||
if (componentType === "modal-repeater-table" ||
|
if (componentType === "modal-repeater-table" ||
|
||||||
componentType === "repeat-screen-modal" ||
|
componentType === "repeat-screen-modal" ||
|
||||||
componentType === "selected-items-detail-input") {
|
componentType === "selected-items-detail-input" ||
|
||||||
|
componentType === "v2-repeater") {
|
||||||
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
|
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
|
||||||
currentValue = props.groupedData || formData?.[fieldName] || [];
|
currentValue = props.groupedData || formData?.[fieldName] || [];
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,24 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 메타 항목: 컴포넌트가 보내거나 받을 수 있는 데이터 슬롯
|
||||||
|
*/
|
||||||
|
export interface ConnectionMetaItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: "filter_value" | "selected_row" | "action_trigger" | "data_refresh" | string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트 연결 메타데이터: 디자이너가 연결 가능한 입출력 정의
|
||||||
|
*/
|
||||||
|
export interface ComponentConnectionMeta {
|
||||||
|
sendable: ConnectionMetaItem[];
|
||||||
|
receivable: ConnectionMetaItem[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POP 컴포넌트 정의 인터페이스
|
* POP 컴포넌트 정의 인터페이스
|
||||||
*/
|
*/
|
||||||
|
|
@ -15,6 +33,7 @@ export interface PopComponentDefinition {
|
||||||
configPanel?: React.ComponentType<any>;
|
configPanel?: React.ComponentType<any>;
|
||||||
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
|
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
|
||||||
defaultProps?: Record<string, any>;
|
defaultProps?: Record<string, any>;
|
||||||
|
connectionMeta?: ComponentConnectionMeta;
|
||||||
// POP 전용 속성
|
// POP 전용 속성
|
||||||
touchOptimized?: boolean;
|
touchOptimized?: boolean;
|
||||||
minTouchArea?: number;
|
minTouchArea?: number;
|
||||||
|
|
|
||||||
|
|
@ -1328,9 +1328,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
{/* 확인 다이얼로그 */}
|
||||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
<AlertDialogContent className="z-[99999]">
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
||||||
|
|
|
||||||
|
|
@ -24,29 +24,33 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// 컴포넌트 설정
|
|
||||||
const componentConfig = {
|
const componentConfig = {
|
||||||
...config,
|
...config,
|
||||||
...component.config,
|
...component.config,
|
||||||
} as ImageDisplayConfig;
|
} as ImageDisplayConfig;
|
||||||
|
|
||||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
const objectFit = componentConfig.objectFit || "contain";
|
||||||
|
const altText = componentConfig.altText || "이미지";
|
||||||
|
const borderRadius = componentConfig.borderRadius ?? 8;
|
||||||
|
const showBorder = componentConfig.showBorder ?? true;
|
||||||
|
const backgroundColor = componentConfig.backgroundColor || "#f9fafb";
|
||||||
|
const placeholder = componentConfig.placeholder || "이미지 없음";
|
||||||
|
|
||||||
|
const imageSrc = component.value || componentConfig.imageUrl || "";
|
||||||
|
|
||||||
const componentStyle: React.CSSProperties = {
|
const componentStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
componentStyle.border = "1px dashed #cbd5e1";
|
componentStyle.border = "1px dashed #cbd5e1";
|
||||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이벤트 핸들러
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick?.();
|
onClick?.();
|
||||||
|
|
@ -88,7 +92,9 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{component.label}
|
{component.label}
|
||||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
{(component.required || componentConfig.required) && (
|
||||||
|
<span style={{ color: "#ef4444" }}>*</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -96,43 +102,53 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
border: "1px solid #d1d5db",
|
border: showBorder ? "1px solid #d1d5db" : "none",
|
||||||
borderRadius: "8px",
|
borderRadius: `${borderRadius}px`,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "#f9fafb",
|
backgroundColor,
|
||||||
transition: "all 0.2s ease-in-out",
|
transition: "all 0.2s ease-in-out",
|
||||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
boxShadow: showBorder ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : "none",
|
||||||
|
opacity: componentConfig.disabled ? 0.5 : 1,
|
||||||
|
cursor: componentConfig.disabled ? "not-allowed" : "default",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
|
if (!componentConfig.disabled) {
|
||||||
|
if (showBorder) {
|
||||||
e.currentTarget.style.borderColor = "#f97316";
|
e.currentTarget.style.borderColor = "#f97316";
|
||||||
|
}
|
||||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
|
if (showBorder) {
|
||||||
e.currentTarget.style.borderColor = "#d1d5db";
|
e.currentTarget.style.borderColor = "#d1d5db";
|
||||||
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
}
|
||||||
|
e.currentTarget.style.boxShadow = showBorder
|
||||||
|
? "0 1px 2px 0 rgba(0, 0, 0, 0.05)"
|
||||||
|
: "none";
|
||||||
}}
|
}}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
{component.value || componentConfig.imageUrl ? (
|
{imageSrc ? (
|
||||||
<img
|
<img
|
||||||
src={component.value || componentConfig.imageUrl}
|
src={imageSrc}
|
||||||
alt={componentConfig.altText || "이미지"}
|
alt={altText}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
objectFit: componentConfig.objectFit || "contain",
|
objectFit,
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
if (e.target?.parentElement) {
|
if (e.target?.parentElement) {
|
||||||
e.target.parentElement.innerHTML = `
|
e.target.parentElement.innerHTML = `
|
||||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; color: #6b7280; font-size: 14px;">
|
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; color: #6b7280; font-size: 14px;">
|
||||||
<div style="font-size: 24px;">🖼️</div>
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
|
||||||
<div>이미지 로드 실패</div>
|
<div>이미지 로드 실패</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -150,8 +166,22 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||||
fontSize: "14px",
|
fontSize: "14px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: "32px" }}>🖼️</div>
|
<svg
|
||||||
<div>이미지 없음</div>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<polyline points="21 15 16 10 5 21" />
|
||||||
|
</svg>
|
||||||
|
<div>{placeholder}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -161,7 +191,6 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ImageDisplay 래퍼 컴포넌트
|
* ImageDisplay 래퍼 컴포넌트
|
||||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
|
||||||
*/
|
*/
|
||||||
export const ImageDisplayWrapper: React.FC<ImageDisplayComponentProps> = (props) => {
|
export const ImageDisplayWrapper: React.FC<ImageDisplayComponentProps> = (props) => {
|
||||||
return <ImageDisplayComponent {...props} />;
|
return <ImageDisplayComponent {...props} />;
|
||||||
|
|
|
||||||
|
|
@ -9,63 +9,166 @@ import { ImageDisplayConfig } from "./types";
|
||||||
|
|
||||||
export interface ImageDisplayConfigPanelProps {
|
export interface ImageDisplayConfigPanelProps {
|
||||||
config: ImageDisplayConfig;
|
config: ImageDisplayConfig;
|
||||||
onChange: (config: Partial<ImageDisplayConfig>) => void;
|
onChange?: (config: Partial<ImageDisplayConfig>) => void;
|
||||||
|
onConfigChange?: (config: Partial<ImageDisplayConfig>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ImageDisplay 설정 패널
|
* ImageDisplay 설정 패널
|
||||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
|
||||||
*/
|
*/
|
||||||
export const ImageDisplayConfigPanel: React.FC<ImageDisplayConfigPanelProps> = ({
|
export const ImageDisplayConfigPanel: React.FC<ImageDisplayConfigPanelProps> = ({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
|
onConfigChange,
|
||||||
}) => {
|
}) => {
|
||||||
const handleChange = (key: keyof ImageDisplayConfig, value: any) => {
|
const handleChange = (key: keyof ImageDisplayConfig, value: any) => {
|
||||||
onChange({ [key]: value });
|
const update = { ...config, [key]: value };
|
||||||
|
onChange?.(update);
|
||||||
|
onConfigChange?.(update);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">이미지 표시 설정</div>
|
||||||
image-display 설정
|
|
||||||
|
{/* 이미지 URL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="imageUrl" className="text-xs">
|
||||||
|
기본 이미지 URL
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="imageUrl"
|
||||||
|
value={config.imageUrl || ""}
|
||||||
|
onChange={(e) => handleChange("imageUrl", e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
데이터 바인딩 값이 없을 때 표시할 기본 이미지
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* file 관련 설정 */}
|
{/* 대체 텍스트 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
<Label htmlFor="altText" className="text-xs">
|
||||||
|
대체 텍스트 (alt)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="altText"
|
||||||
|
value={config.altText || ""}
|
||||||
|
onChange={(e) => handleChange("altText", e.target.value)}
|
||||||
|
placeholder="이미지 설명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이미지 맞춤 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="objectFit" className="text-xs">
|
||||||
|
이미지 맞춤 (Object Fit)
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={config.objectFit || "contain"}
|
||||||
|
onValueChange={(value) => handleChange("objectFit", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="contain">Contain (비율 유지, 전체 표시)</SelectItem>
|
||||||
|
<SelectItem value="cover">Cover (비율 유지, 영역 채움)</SelectItem>
|
||||||
|
<SelectItem value="fill">Fill (영역에 맞춤)</SelectItem>
|
||||||
|
<SelectItem value="none">None (원본 크기)</SelectItem>
|
||||||
|
<SelectItem value="scale-down">Scale Down (축소만)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테두리 둥글기 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="borderRadius" className="text-xs">
|
||||||
|
테두리 둥글기 (px)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="borderRadius"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="50"
|
||||||
|
value={config.borderRadius ?? 8}
|
||||||
|
onChange={(e) => handleChange("borderRadius", parseInt(e.target.value) || 0)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 배경 색상 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="backgroundColor" className="text-xs">
|
||||||
|
배경 색상
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={config.backgroundColor || "#f9fafb"}
|
||||||
|
onChange={(e) => handleChange("backgroundColor", e.target.value)}
|
||||||
|
className="h-8 w-8 cursor-pointer rounded border"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="backgroundColor"
|
||||||
|
value={config.backgroundColor || "#f9fafb"}
|
||||||
|
onChange={(e) => handleChange("backgroundColor", e.target.value)}
|
||||||
|
className="h-8 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 플레이스홀더 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="placeholder" className="text-xs">
|
||||||
|
이미지 없을 때 텍스트
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="placeholder"
|
id="placeholder"
|
||||||
value={config.placeholder || ""}
|
value={config.placeholder || ""}
|
||||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||||
|
placeholder="이미지 없음"
|
||||||
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 공통 설정 */}
|
{/* 테두리 표시 */}
|
||||||
<div className="space-y-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label htmlFor="disabled">비활성화</Label>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="disabled"
|
id="showBorder"
|
||||||
checked={config.disabled || false}
|
checked={config.showBorder ?? true}
|
||||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
onCheckedChange={(checked) => handleChange("showBorder", checked)}
|
||||||
/>
|
/>
|
||||||
|
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
|
||||||
|
테두리 표시
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* 읽기 전용 */}
|
||||||
<Label htmlFor="required">필수 입력</Label>
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
|
||||||
id="required"
|
|
||||||
checked={config.required || false}
|
|
||||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="readonly">읽기 전용</Label>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="readonly"
|
id="readonly"
|
||||||
checked={config.readonly || false}
|
checked={config.readonly || false}
|
||||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||||
/>
|
/>
|
||||||
|
<Label htmlFor="readonly" className="text-xs cursor-pointer">
|
||||||
|
읽기 전용
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필수 입력 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="required"
|
||||||
|
checked={config.required || false}
|
||||||
|
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="required" className="text-xs cursor-pointer">
|
||||||
|
필수 입력
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,14 @@ import { ImageDisplayConfig } from "./types";
|
||||||
* ImageDisplay 컴포넌트 기본 설정
|
* ImageDisplay 컴포넌트 기본 설정
|
||||||
*/
|
*/
|
||||||
export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
|
export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
|
||||||
placeholder: "입력하세요",
|
imageUrl: "",
|
||||||
|
altText: "이미지",
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: 8,
|
||||||
|
showBorder: true,
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
placeholder: "이미지 없음",
|
||||||
|
|
||||||
// 공통 기본값
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
required: false,
|
required: false,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
|
|
@ -18,23 +23,31 @@ export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ImageDisplay 컴포넌트 설정 스키마
|
* ImageDisplay 컴포넌트 설정 스키마
|
||||||
* 유효성 검사 및 타입 체크에 사용
|
|
||||||
*/
|
*/
|
||||||
export const ImageDisplayConfigSchema = {
|
export const ImageDisplayConfigSchema = {
|
||||||
placeholder: { type: "string", default: "" },
|
imageUrl: { type: "string", default: "" },
|
||||||
|
altText: { type: "string", default: "이미지" },
|
||||||
|
objectFit: {
|
||||||
|
type: "enum",
|
||||||
|
values: ["contain", "cover", "fill", "none", "scale-down"],
|
||||||
|
default: "contain",
|
||||||
|
},
|
||||||
|
borderRadius: { type: "number", default: 8 },
|
||||||
|
showBorder: { type: "boolean", default: true },
|
||||||
|
backgroundColor: { type: "string", default: "#f9fafb" },
|
||||||
|
placeholder: { type: "string", default: "이미지 없음" },
|
||||||
|
|
||||||
// 공통 스키마
|
|
||||||
disabled: { type: "boolean", default: false },
|
disabled: { type: "boolean", default: false },
|
||||||
required: { type: "boolean", default: false },
|
required: { type: "boolean", default: false },
|
||||||
readonly: { type: "boolean", default: false },
|
readonly: { type: "boolean", default: false },
|
||||||
variant: {
|
variant: {
|
||||||
type: "enum",
|
type: "enum",
|
||||||
values: ["default", "outlined", "filled"],
|
values: ["default", "outlined", "filled"],
|
||||||
default: "default"
|
default: "default",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
type: "enum",
|
type: "enum",
|
||||||
values: ["sm", "md", "lg"],
|
values: ["sm", "md", "lg"],
|
||||||
default: "md"
|
default: "md",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,13 @@ export const ImageDisplayDefinition = createComponentDefinition({
|
||||||
webType: "file",
|
webType: "file",
|
||||||
component: ImageDisplayWrapper,
|
component: ImageDisplayWrapper,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: "입력하세요",
|
imageUrl: "",
|
||||||
|
altText: "이미지",
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: 8,
|
||||||
|
showBorder: true,
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
placeholder: "이미지 없음",
|
||||||
},
|
},
|
||||||
defaultSize: { width: 200, height: 200 },
|
defaultSize: { width: 200, height: 200 },
|
||||||
configPanel: ImageDisplayConfigPanel,
|
configPanel: ImageDisplayConfigPanel,
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,19 @@ import { ComponentConfig } from "@/types/component";
|
||||||
* ImageDisplay 컴포넌트 설정 타입
|
* ImageDisplay 컴포넌트 설정 타입
|
||||||
*/
|
*/
|
||||||
export interface ImageDisplayConfig extends ComponentConfig {
|
export interface ImageDisplayConfig extends ComponentConfig {
|
||||||
// file 관련 설정
|
// 이미지 관련 설정
|
||||||
|
imageUrl?: string;
|
||||||
|
altText?: string;
|
||||||
|
objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down";
|
||||||
|
borderRadius?: number;
|
||||||
|
showBorder?: boolean;
|
||||||
|
backgroundColor?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
|
||||||
// 공통 설정
|
// 공통 설정
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
placeholder?: string;
|
|
||||||
helperText?: string;
|
|
||||||
|
|
||||||
// 스타일 관련
|
// 스타일 관련
|
||||||
variant?: "default" | "outlined" | "filled";
|
variant?: "default" | "outlined" | "filled";
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,8 @@ import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
|
||||||
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
||||||
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
||||||
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
||||||
|
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
|
||||||
|
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
|
||||||
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
|
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
|
||||||
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
|
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
|
||||||
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
|
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,79 @@ export function RepeaterTable({
|
||||||
// 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행)
|
// 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행)
|
||||||
const initializedRef = useRef(false);
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
// 편집 가능한 컬럼 인덱스 목록 (방향키 네비게이션용)
|
||||||
|
const editableColIndices = useMemo(
|
||||||
|
() => visibleColumns.reduce<number[]>((acc, col, idx) => {
|
||||||
|
if (col.editable && !col.calculated) acc.push(idx);
|
||||||
|
return acc;
|
||||||
|
}, []),
|
||||||
|
[visibleColumns],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 방향키로 리피터 셀 간 이동
|
||||||
|
const handleArrowNavigation = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const key = e.key;
|
||||||
|
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return;
|
||||||
|
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const cell = target.closest("[data-repeater-row]") as HTMLElement | null;
|
||||||
|
if (!cell) return;
|
||||||
|
|
||||||
|
const row = Number(cell.dataset.repeaterRow);
|
||||||
|
const col = Number(cell.dataset.repeaterCol);
|
||||||
|
if (isNaN(row) || isNaN(col)) return;
|
||||||
|
|
||||||
|
// 텍스트 입력 중 좌/우 방향키는 커서 이동에 사용하므로 무시
|
||||||
|
if ((key === "ArrowLeft" || key === "ArrowRight") && target.tagName === "INPUT") {
|
||||||
|
const input = target as HTMLInputElement;
|
||||||
|
const len = input.value?.length ?? 0;
|
||||||
|
const pos = input.selectionStart ?? 0;
|
||||||
|
// 커서가 끝에 있을 때만 오른쪽 이동, 처음에 있을 때만 왼쪽 이동
|
||||||
|
if (key === "ArrowRight" && pos < len) return;
|
||||||
|
if (key === "ArrowLeft" && pos > 0) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextRow = row;
|
||||||
|
let nextColPos = editableColIndices.indexOf(col);
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "ArrowUp":
|
||||||
|
nextRow = Math.max(0, row - 1);
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
nextRow = Math.min(data.length - 1, row + 1);
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
nextColPos = Math.max(0, nextColPos - 1);
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
nextColPos = Math.min(editableColIndices.length - 1, nextColPos + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCol = editableColIndices[nextColPos];
|
||||||
|
if (nextRow === row && nextCol === col) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const selector = `[data-repeater-row="${nextRow}"][data-repeater-col="${nextCol}"]`;
|
||||||
|
const nextCell = containerRef.current?.querySelector(selector) as HTMLElement | null;
|
||||||
|
if (!nextCell) return;
|
||||||
|
|
||||||
|
const focusable = nextCell.querySelector<HTMLElement>(
|
||||||
|
'input:not([disabled]), select:not([disabled]), [role="combobox"]:not([disabled]), button:not([disabled])',
|
||||||
|
);
|
||||||
|
if (focusable) {
|
||||||
|
focusable.focus();
|
||||||
|
if (focusable.tagName === "INPUT") {
|
||||||
|
(focusable as HTMLInputElement).select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editableColIndices, data.length],
|
||||||
|
);
|
||||||
|
|
||||||
// DnD 센서 설정
|
// DnD 센서 설정
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
|
|
@ -480,15 +553,20 @@ export function RepeaterTable({
|
||||||
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
||||||
const value = row[column.field];
|
const value = row[column.field];
|
||||||
|
|
||||||
// 🆕 카테고리 라벨 변환 함수
|
// 카테고리 라벨 변환 함수
|
||||||
const getCategoryDisplayValue = (val: any): string => {
|
const getCategoryDisplayValue = (val: any): string => {
|
||||||
if (!val || typeof val !== "string") return val || "-";
|
if (!val || typeof val !== "string") return val || "-";
|
||||||
|
|
||||||
// 카테고리 컬럼이 아니면 그대로 반환
|
const fieldName = column.field.replace(/^_display_/, "");
|
||||||
const fieldName = column.field.replace(/^_display_/, ""); // _display_ 접두사 제거
|
const isCategoryColumn = categoryColumns.includes(fieldName);
|
||||||
if (!categoryColumns.includes(fieldName)) return val;
|
|
||||||
|
|
||||||
// 쉼표로 구분된 다중 값 처리
|
// categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관)
|
||||||
|
if (categoryLabelMap[val]) return categoryLabelMap[val];
|
||||||
|
|
||||||
|
// 카테고리 컬럼이 아니면 원래 값 반환
|
||||||
|
if (!isCategoryColumn) return val;
|
||||||
|
|
||||||
|
// 콤마 구분된 다중 값 처리
|
||||||
const codes = val
|
const codes = val
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((c: string) => c.trim())
|
.map((c: string) => c.trim())
|
||||||
|
|
@ -643,7 +721,7 @@ export function RepeaterTable({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<div ref={containerRef} className="flex h-full flex-col border border-gray-200 bg-white">
|
<div ref={containerRef} className="flex h-full flex-col border border-gray-200 bg-white" onKeyDown={handleArrowNavigation}>
|
||||||
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto">
|
||||||
<table
|
<table
|
||||||
className="border-collapse text-xs"
|
className="border-collapse text-xs"
|
||||||
|
|
@ -835,6 +913,8 @@ export function RepeaterTable({
|
||||||
width: `${columnWidths[col.field]}px`,
|
width: `${columnWidths[col.field]}px`,
|
||||||
maxWidth: `${columnWidths[col.field]}px`,
|
maxWidth: `${columnWidths[col.field]}px`,
|
||||||
}}
|
}}
|
||||||
|
data-repeater-row={rowIndex}
|
||||||
|
data-repeater-col={colIndex}
|
||||||
>
|
>
|
||||||
{renderCell(row, col, rowIndex)}
|
{renderCell(row, col, rowIndex)}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
|
|
||||||
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
||||||
const groupByColumn = rawConfig.groupByColumn;
|
const groupByColumn = rawConfig.groupByColumn;
|
||||||
|
const groupBySourceColumn = rawConfig.groupBySourceColumn || rawConfig.groupByColumn;
|
||||||
const targetTable = rawConfig.targetTable;
|
const targetTable = rawConfig.targetTable;
|
||||||
|
|
||||||
// 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑)
|
// 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑)
|
||||||
|
|
@ -86,8 +87,8 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
||||||
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
||||||
|
|
||||||
// 🆕 그룹 키 값 (예: formData.inbound_number)
|
// 🆕 그룹 키 값: groupBySourceColumn(formData 키)과 groupByColumn(DB 컬럼)을 분리
|
||||||
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
|
const groupKeyValue = groupBySourceColumn ? formData?.[groupBySourceColumn] : null;
|
||||||
|
|
||||||
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
|
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
|
||||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,34 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { X } from "lucide-react";
|
import {
|
||||||
import * as LucideIcons from "lucide-react";
|
X,
|
||||||
|
Check,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
Save,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Info,
|
||||||
|
Settings,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
ExternalLink,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
X, Check, Plus, Minus, Edit, Trash2, Search, Save, RefreshCw,
|
||||||
|
AlertCircle, Info, Settings, ChevronDown, ChevronUp, ChevronRight,
|
||||||
|
Copy, Download, Upload, ExternalLink,
|
||||||
|
};
|
||||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -1555,7 +1581,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
switch (displayItem.type) {
|
switch (displayItem.type) {
|
||||||
case "icon": {
|
case "icon": {
|
||||||
if (!displayItem.icon) return null;
|
if (!displayItem.icon) return null;
|
||||||
const IconComponent = (LucideIcons as any)[displayItem.icon];
|
const IconComponent = LUCIDE_ICON_MAP[displayItem.icon];
|
||||||
if (!IconComponent) return null;
|
if (!IconComponent) return null;
|
||||||
return <IconComponent key={displayItem.id} className="mr-1 inline-block h-3 w-3" style={inlineStyle} />;
|
return <IconComponent key={displayItem.id} className="mr-1 inline-block h-3 w-3" style={inlineStyle} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ import { dataApi } from "@/lib/api/data";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient, getFullImageUrl } from "@/lib/api/client";
|
||||||
|
import { getFilePreviewUrl } from "@/lib/api/file";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -39,6 +40,80 @@ import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-opt
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useSplitPanel } from "./SplitPanelContext";
|
import { useSplitPanel } from "./SplitPanelContext";
|
||||||
|
|
||||||
|
// 테이블 셀 이미지 썸네일 컴포넌트
|
||||||
|
const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
||||||
|
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
|
||||||
|
const [error, setError] = React.useState(false);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const rawValue = String(value);
|
||||||
|
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
|
||||||
|
const isObjid = /^\d+$/.test(strValue);
|
||||||
|
|
||||||
|
if (isObjid) {
|
||||||
|
const loadImage = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/files/preview/${strValue}`, { responseType: "blob" });
|
||||||
|
if (mounted) {
|
||||||
|
const blob = new Blob([response.data]);
|
||||||
|
setImgSrc(window.URL.createObjectURL(blob));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (mounted) { setError(true); setLoading(false); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadImage();
|
||||||
|
} else {
|
||||||
|
setImgSrc(getFullImageUrl(strValue));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => { mounted = false; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||||
|
<div className="h-8 w-8 animate-pulse rounded bg-muted sm:h-10 sm:w-10" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !imgSrc) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||||
|
<div className="bg-muted text-muted-foreground flex h-8 w-8 items-center justify-center rounded sm:h-10 sm:w-10" title="이미지를 불러올 수 없습니다">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||||
|
<img
|
||||||
|
src={imgSrc}
|
||||||
|
alt="이미지"
|
||||||
|
className="h-8 w-8 cursor-pointer rounded object-cover transition-opacity hover:opacity-80 sm:h-10 sm:w-10"
|
||||||
|
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const rawValue = String(value);
|
||||||
|
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
|
||||||
|
const isObjid = /^\d+$/.test(strValue);
|
||||||
|
window.open(isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue), "_blank");
|
||||||
|
}}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SplitPanelCellImage.displayName = "SplitPanelCellImage";
|
||||||
|
|
||||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||||
// 추가 props
|
// 추가 props
|
||||||
}
|
}
|
||||||
|
|
@ -182,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
||||||
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
|
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
|
||||||
|
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({}); // 테이블별 컬럼 inputType
|
||||||
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
|
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
|
||||||
Record<string, Record<string, { label: string; color?: string }>>
|
Record<string, Record<string, { label: string; color?: string }>>
|
||||||
>({}); // 좌측 카테고리 매핑
|
>({}); // 좌측 카테고리 매핑
|
||||||
|
|
@ -619,7 +695,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
return result;
|
return result;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
|
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷 + 이미지)
|
||||||
const formatCellValue = useCallback(
|
const formatCellValue = useCallback(
|
||||||
(
|
(
|
||||||
columnName: string,
|
columnName: string,
|
||||||
|
|
@ -636,6 +712,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
) => {
|
) => {
|
||||||
if (value === null || value === undefined) return "-";
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
// 이미지 타입: 썸네일 표시
|
||||||
|
const colInputType = columnInputTypes[columnName];
|
||||||
|
if (colInputType === "image" && value) {
|
||||||
|
return <SplitPanelCellImage value={String(value)} />;
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 날짜 포맷 적용
|
// 🆕 날짜 포맷 적용
|
||||||
if (format?.type === "date" || format?.dateFormat) {
|
if (format?.type === "date" || format?.dateFormat) {
|
||||||
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||||
|
|
@ -702,7 +784,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 일반 값
|
// 일반 값
|
||||||
return String(value);
|
return String(value);
|
||||||
},
|
},
|
||||||
[formatDateValue, formatNumberValue],
|
[formatDateValue, formatNumberValue, columnInputTypes],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 좌측 데이터 로드
|
// 좌측 데이터 로드
|
||||||
|
|
@ -1453,14 +1535,36 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setRightColumnLabels(labels);
|
setRightColumnLabels(labels);
|
||||||
console.log("✅ 우측 컬럼 라벨 로드:", labels);
|
|
||||||
|
// 우측 테이블 + 추가 탭 테이블의 inputType 로드
|
||||||
|
const tablesToLoad = new Set<string>([rightTableName]);
|
||||||
|
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
|
||||||
|
additionalTabs.forEach((tab: any) => {
|
||||||
|
if (tab.tableName) tablesToLoad.add(tab.tableName);
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputTypes: Record<string, string> = {};
|
||||||
|
for (const tbl of tablesToLoad) {
|
||||||
|
try {
|
||||||
|
const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl);
|
||||||
|
inputTypesResponse.forEach((col: any) => {
|
||||||
|
const colName = col.columnName || col.column_name;
|
||||||
|
if (colName) {
|
||||||
|
inputTypes[colName] = col.inputType || "text";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// inputType 로드 실패 시 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setColumnInputTypes(inputTypes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadRightTableColumns();
|
loadRightTableColumns();
|
||||||
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
|
||||||
|
|
||||||
// 좌측 테이블 카테고리 매핑 로드
|
// 좌측 테이블 카테고리 매핑 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1603,6 +1707,57 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const handleAddClick = useCallback(
|
const handleAddClick = useCallback(
|
||||||
(panel: "left" | "right") => {
|
(panel: "left" | "right") => {
|
||||||
console.log("🆕 [추가모달] handleAddClick 호출:", { panel, activeTabIndex });
|
console.log("🆕 [추가모달] handleAddClick 호출:", { panel, activeTabIndex });
|
||||||
|
|
||||||
|
// screenId 기반 모달 확인
|
||||||
|
const panelConfig = panel === "left" ? componentConfig.leftPanel : componentConfig.rightPanel;
|
||||||
|
const addModalConfig = panelConfig?.addModal;
|
||||||
|
|
||||||
|
if (addModalConfig?.screenId) {
|
||||||
|
if (panel === "right" && !selectedLeftItem) {
|
||||||
|
toast({
|
||||||
|
title: "항목을 선택해주세요",
|
||||||
|
description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableName = panelConfig?.tableName || "";
|
||||||
|
const urlParams: Record<string, any> = {
|
||||||
|
mode: "add",
|
||||||
|
tableName,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parentData: Record<string, any> = {};
|
||||||
|
if (panel === "right" && selectedLeftItem) {
|
||||||
|
const relation = componentConfig.rightPanel?.relation;
|
||||||
|
console.log("🟢 [추가모달] selectedLeftItem:", JSON.stringify(selectedLeftItem));
|
||||||
|
console.log("🟢 [추가모달] relation:", JSON.stringify(relation));
|
||||||
|
if (relation?.keys && Array.isArray(relation.keys)) {
|
||||||
|
for (const key of relation.keys) {
|
||||||
|
console.log("🟢 [추가모달] key:", key, "leftValue:", selectedLeftItem[key.leftColumn]);
|
||||||
|
if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) {
|
||||||
|
parentData[key.rightColumn] = selectedLeftItem[key.leftColumn];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🆕 [추가모달] screenId 기반 모달 열기:", { screenId: addModalConfig.screenId, tableName, parentData, parentDataStr: JSON.stringify(parentData) });
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("openScreenModal", {
|
||||||
|
detail: {
|
||||||
|
screenId: addModalConfig.screenId,
|
||||||
|
urlParams,
|
||||||
|
splitPanelParentData: parentData,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 인라인 모달 방식
|
||||||
setAddModalPanel(panel);
|
setAddModalPanel(panel);
|
||||||
|
|
||||||
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움
|
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움
|
||||||
|
|
@ -2483,14 +2638,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<tbody className="divide-y divide-gray-200 bg-white">
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
{group.items.map((item, idx) => {
|
{group.items.map((item, idx) => {
|
||||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
||||||
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
const itemId = item[sourceColumn] || item.id || item.ID;
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selectedLeftItem &&
|
selectedLeftItem &&
|
||||||
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={itemId}
|
key={itemId != null ? `${itemId}-${idx}` : idx}
|
||||||
onClick={() => handleLeftItemSelect(item)}
|
onClick={() => handleLeftItemSelect(item)}
|
||||||
className={`hover:bg-accent cursor-pointer transition-colors ${
|
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||||
isSelected ? "bg-primary/10" : ""
|
isSelected ? "bg-primary/10" : ""
|
||||||
|
|
@ -2545,14 +2700,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<tbody className="divide-y divide-gray-200 bg-white">
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
{filteredData.map((item, idx) => {
|
{filteredData.map((item, idx) => {
|
||||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
||||||
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
const itemId = item[sourceColumn] || item.id || item.ID;
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selectedLeftItem &&
|
selectedLeftItem &&
|
||||||
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={itemId}
|
key={itemId != null ? `${itemId}-${idx}` : idx}
|
||||||
onClick={() => handleLeftItemSelect(item)}
|
onClick={() => handleLeftItemSelect(item)}
|
||||||
className={`hover:bg-accent cursor-pointer transition-colors ${
|
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||||
isSelected ? "bg-primary/10" : ""
|
isSelected ? "bg-primary/10" : ""
|
||||||
|
|
@ -2647,7 +2802,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 재귀 렌더링 함수
|
// 재귀 렌더링 함수
|
||||||
const renderTreeItem = (item: any, index: number): React.ReactNode => {
|
const renderTreeItem = (item: any, index: number): React.ReactNode => {
|
||||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
||||||
const itemId = item[sourceColumn] || item.id || item.ID || index;
|
const rawItemId = item[sourceColumn] || item.id || item.ID;
|
||||||
|
const itemId = rawItemId != null ? rawItemId : index;
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||||
const hasChildren = item.children && item.children.length > 0;
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
|
|
@ -2698,7 +2854,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const displaySubtitle = displayFields[1]?.value || null;
|
const displaySubtitle = displayFields[1]?.value || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={itemId}>
|
<React.Fragment key={`${itemId}-${index}`}>
|
||||||
{/* 현재 항목 */}
|
{/* 현재 항목 */}
|
||||||
<div
|
<div
|
||||||
className={`group hover:bg-muted relative cursor-pointer rounded-md p-3 transition-colors ${
|
className={`group hover:bg-muted relative cursor-pointer rounded-md p-3 transition-colors ${
|
||||||
|
|
@ -3030,7 +3186,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{currentTabData.map((item: any, idx: number) => {
|
{currentTabData.map((item: any, idx: number) => {
|
||||||
const itemId = item.id || idx;
|
const itemId = item.id ?? idx;
|
||||||
const isExpanded = expandedRightItems.has(itemId);
|
const isExpanded = expandedRightItems.has(itemId);
|
||||||
|
|
||||||
// 표시할 컬럼 결정
|
// 표시할 컬럼 결정
|
||||||
|
|
@ -3046,7 +3202,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const detailColumns = columnsToShow.slice(summaryCount);
|
const detailColumns = columnsToShow.slice(summaryCount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={itemId} className="rounded-lg border bg-white p-3">
|
<div key={`${itemId}-${idx}`} className="rounded-lg border bg-white p-3">
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-start justify-between"
|
className="flex cursor-pointer items-start justify-between"
|
||||||
onClick={() => toggleRightItemExpansion(itemId)}
|
onClick={() => toggleRightItemExpansion(itemId)}
|
||||||
|
|
@ -3236,10 +3392,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 bg-white">
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
{filteredData.map((item, idx) => {
|
{filteredData.map((item, idx) => {
|
||||||
const itemId = item.id || item.ID || idx;
|
const itemId = item.id || item.ID;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={itemId} className="hover:bg-accent transition-colors">
|
<tr key={itemId != null ? `${itemId}-${idx}` : idx} className="hover:bg-accent transition-colors">
|
||||||
{columnsToShow.map((col, colIdx) => (
|
{columnsToShow.map((col, colIdx) => (
|
||||||
<td
|
<td
|
||||||
key={colIdx}
|
key={colIdx}
|
||||||
|
|
@ -3353,7 +3509,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={itemId}
|
key={`${itemId}-${index}`}
|
||||||
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
|
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
|
||||||
>
|
>
|
||||||
{/* 요약 정보 */}
|
{/* 요약 정보 */}
|
||||||
|
|
|
||||||
|
|
@ -781,6 +781,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const dataProvider: DataProvidable = {
|
const dataProvider: DataProvidable = {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
componentType: "table-list",
|
componentType: "table-list",
|
||||||
|
tableName: tableConfig.selectedTable,
|
||||||
|
|
||||||
getSelectedData: () => {
|
getSelectedData: () => {
|
||||||
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
|
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
|
||||||
|
|
@ -940,23 +941,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반
|
// 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
|
||||||
|
try {
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data && response.data.data.length > 0) {
|
||||||
|
return response.data.data.map((item: any) => ({
|
||||||
|
value: String(item.value),
|
||||||
|
label: String(item.label),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback: 현재 로드된 데이터에서 고유 값 추출
|
||||||
const isLabelType = ["category", "entity", "code"].includes(inputType);
|
const isLabelType = ["category", "entity", "code"].includes(inputType);
|
||||||
const labelField = isLabelType ? `${columnName}_name` : columnName;
|
const labelField = isLabelType ? `${columnName}_name` : columnName;
|
||||||
|
|
||||||
// 현재 로드된 데이터에서 고유 값 추출
|
const uniqueValuesMap = new Map<string, string>();
|
||||||
const uniqueValuesMap = new Map<string, string>(); // value -> label
|
|
||||||
|
|
||||||
data.forEach((row) => {
|
data.forEach((row) => {
|
||||||
const value = row[columnName];
|
const value = row[columnName];
|
||||||
if (value !== null && value !== undefined && value !== "") {
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
|
|
||||||
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
|
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
|
||||||
uniqueValuesMap.set(String(value), label);
|
uniqueValuesMap.set(String(value), label);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map을 배열로 변환하고 라벨 기준으로 정렬
|
|
||||||
const result = Array.from(uniqueValuesMap.entries())
|
const result = Array.from(uniqueValuesMap.entries())
|
||||||
.map(([value, label]) => ({
|
.map(([value, label]) => ({
|
||||||
value: value,
|
value: value,
|
||||||
|
|
@ -4192,9 +4205,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
||||||
const inputType = meta?.inputType || column.inputType;
|
const inputType = meta?.inputType || column.inputType;
|
||||||
|
|
||||||
// 🖼️ 이미지 타입: 작은 썸네일 표시
|
// 🖼️ 이미지 타입: 작은 썸네일 표시 (다중 이미지인 경우 대표 이미지 1개만)
|
||||||
if (inputType === "image" && value && typeof value === "string") {
|
if (inputType === "image" && value && typeof value === "string") {
|
||||||
const imageUrl = getFullImageUrl(value);
|
const firstImage = value.includes(",") ? value.split(",")[0].trim() : value;
|
||||||
|
const imageUrl = getFullImageUrl(firstImage);
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
|
|
@ -4307,7 +4321,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 다중 값인 경우: 여러 배지 렌더링
|
// 다중 값인 경우: 여러 배지 렌더링
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-nowrap gap-1 overflow-hidden">
|
||||||
{values.map((val, idx) => {
|
{values.map((val, idx) => {
|
||||||
const categoryData = mapping?.[val];
|
const categoryData = mapping?.[val];
|
||||||
const displayLabel = categoryData?.label || val;
|
const displayLabel = categoryData?.label || val;
|
||||||
|
|
@ -4316,7 +4330,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||||||
if (!displayColor || displayColor === "none" || !categoryData) {
|
if (!displayColor || displayColor === "none" || !categoryData) {
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="text-sm">
|
<span key={idx} className="shrink-0 whitespace-nowrap text-sm">
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
{idx < values.length - 1 && ", "}
|
{idx < values.length - 1 && ", "}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -4330,7 +4344,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
backgroundColor: displayColor,
|
backgroundColor: displayColor,
|
||||||
borderColor: displayColor,
|
borderColor: displayColor,
|
||||||
}}
|
}}
|
||||||
className="text-white"
|
className="shrink-0 whitespace-nowrap text-white"
|
||||||
>
|
>
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
|
|
@ -554,6 +554,69 @@ export function TableSectionRenderer({
|
||||||
loadCategoryOptions();
|
loadCategoryOptions();
|
||||||
}, [tableConfig.source.tableName, tableConfig.columns]);
|
}, [tableConfig.source.tableName, tableConfig.columns]);
|
||||||
|
|
||||||
|
// receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!formData || Object.keys(formData).length === 0) return;
|
||||||
|
if (!tableConfig.columns) return;
|
||||||
|
|
||||||
|
const codesToResolve: string[] = [];
|
||||||
|
for (const col of tableConfig.columns) {
|
||||||
|
// receiveFromParent 컬럼
|
||||||
|
if ((col as any).receiveFromParent) {
|
||||||
|
const parentField = (col as any).parentFieldName || col.field;
|
||||||
|
const val = formData[parentField];
|
||||||
|
if (typeof val === "string" && val) {
|
||||||
|
codesToResolve.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// internal 매핑 컬럼
|
||||||
|
const mapping = (col as any).valueMapping;
|
||||||
|
if (mapping?.type === "internal" && mapping.internalField) {
|
||||||
|
const val = formData[mapping.internalField];
|
||||||
|
if (typeof val === "string" && val) {
|
||||||
|
codesToResolve.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codesToResolve.length === 0) return;
|
||||||
|
|
||||||
|
const loadParentLabels = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: codesToResolve,
|
||||||
|
});
|
||||||
|
if (resp.data?.success && resp.data.data) {
|
||||||
|
const labelData = resp.data.data as Record<string, string>;
|
||||||
|
// categoryOptionsMap에 추가 (receiveFromParent 컬럼별로)
|
||||||
|
const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
|
||||||
|
for (const col of tableConfig.columns) {
|
||||||
|
let val: string | undefined;
|
||||||
|
if ((col as any).receiveFromParent) {
|
||||||
|
const parentField = (col as any).parentFieldName || col.field;
|
||||||
|
val = formData[parentField] as string;
|
||||||
|
}
|
||||||
|
const mapping = (col as any).valueMapping;
|
||||||
|
if (mapping?.type === "internal" && mapping.internalField) {
|
||||||
|
val = formData[mapping.internalField] as string;
|
||||||
|
}
|
||||||
|
if (val && typeof val === "string" && labelData[val]) {
|
||||||
|
newOptionsMap[col.field] = [{ value: val, label: labelData[val] }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(newOptionsMap).length > 0) {
|
||||||
|
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 라벨 조회 실패 시 무시
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadParentLabels();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [formData, tableConfig.columns]);
|
||||||
|
|
||||||
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
|
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isConditionalMode) return;
|
if (!isConditionalMode) return;
|
||||||
|
|
@ -1005,6 +1068,23 @@ export function TableSectionRenderer({
|
||||||
});
|
});
|
||||||
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
|
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
|
||||||
|
|
||||||
|
// categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생
|
||||||
|
const tableCategoryColumns = useMemo(() => {
|
||||||
|
return Object.keys(categoryOptionsMap);
|
||||||
|
}, [categoryOptionsMap]);
|
||||||
|
|
||||||
|
const tableCategoryLabelMap = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const options of Object.values(categoryOptionsMap)) {
|
||||||
|
for (const opt of options) {
|
||||||
|
if (opt.value && opt.label) {
|
||||||
|
map[opt.value] = opt.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [categoryOptionsMap]);
|
||||||
|
|
||||||
// 원본 계산 규칙 (조건부 계산 포함)
|
// 원본 계산 규칙 (조건부 계산 포함)
|
||||||
const originalCalculationRules: TableCalculationRule[] = useMemo(
|
const originalCalculationRules: TableCalculationRule[] = useMemo(
|
||||||
() => tableConfig.calculations || [],
|
() => tableConfig.calculations || [],
|
||||||
|
|
@ -1312,6 +1392,67 @@ export function TableSectionRenderer({
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용)
|
||||||
|
const categoryFields = (tableConfig.columns || [])
|
||||||
|
.filter((col) => col.type === "category" || col.type === "select")
|
||||||
|
.reduce<Record<string, Record<string, string>>>((acc, col) => {
|
||||||
|
const options = categoryOptionsMap[col.field];
|
||||||
|
if (options && options.length > 0) {
|
||||||
|
acc[col.field] = {};
|
||||||
|
for (const opt of options) {
|
||||||
|
acc[col.field][opt.value] = opt.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// receiveFromParent / internal 매핑으로 넘어온 값도 포함하여 변환
|
||||||
|
if (Object.keys(categoryFields).length > 0) {
|
||||||
|
for (const item of mappedItems) {
|
||||||
|
for (const [field, codeToLabel] of Object.entries(categoryFields)) {
|
||||||
|
const val = item[field];
|
||||||
|
if (typeof val === "string" && codeToLabel[val]) {
|
||||||
|
item[field] = codeToLabel[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryOptionsMap에 없는 경우 API fallback
|
||||||
|
const unresolvedCodes = new Set<string>();
|
||||||
|
const categoryColFields = new Set(
|
||||||
|
(tableConfig.columns || []).filter((col) => col.type === "category").map((col) => col.field),
|
||||||
|
);
|
||||||
|
for (const item of mappedItems) {
|
||||||
|
for (const field of categoryColFields) {
|
||||||
|
const val = item[field];
|
||||||
|
if (typeof val === "string" && val && !categoryFields[field]?.[val] && val !== item[field]) {
|
||||||
|
unresolvedCodes.add(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unresolvedCodes.size > 0) {
|
||||||
|
try {
|
||||||
|
const labelResp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||||
|
valueCodes: Array.from(unresolvedCodes),
|
||||||
|
});
|
||||||
|
if (labelResp.data?.success && labelResp.data.data) {
|
||||||
|
const labelData = labelResp.data.data as Record<string, string>;
|
||||||
|
for (const item of mappedItems) {
|
||||||
|
for (const field of categoryColFields) {
|
||||||
|
const val = item[field];
|
||||||
|
if (typeof val === "string" && labelData[val]) {
|
||||||
|
item[field] = labelData[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 변환 실패 시 코드 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 계산 필드 업데이트
|
// 계산 필드 업데이트
|
||||||
const calculatedItems = calculateAll(mappedItems);
|
const calculatedItems = calculateAll(mappedItems);
|
||||||
|
|
||||||
|
|
@ -1319,7 +1460,7 @@ export function TableSectionRenderer({
|
||||||
const newData = [...tableData, ...calculatedItems];
|
const newData = [...tableData, ...calculatedItems];
|
||||||
handleDataChange(newData);
|
handleDataChange(newData);
|
||||||
},
|
},
|
||||||
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources],
|
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources, categoryOptionsMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 컬럼 모드/조회 옵션 변경 핸들러
|
// 컬럼 모드/조회 옵션 변경 핸들러
|
||||||
|
|
@ -1667,6 +1808,31 @@ export function TableSectionRenderer({
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용)
|
||||||
|
const categoryFields = (tableConfig.columns || [])
|
||||||
|
.filter((col) => col.type === "category" || col.type === "select")
|
||||||
|
.reduce<Record<string, Record<string, string>>>((acc, col) => {
|
||||||
|
const options = categoryOptionsMap[col.field];
|
||||||
|
if (options && options.length > 0) {
|
||||||
|
acc[col.field] = {};
|
||||||
|
for (const opt of options) {
|
||||||
|
acc[col.field][opt.value] = opt.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
if (Object.keys(categoryFields).length > 0) {
|
||||||
|
for (const item of mappedItems) {
|
||||||
|
for (const [field, codeToLabel] of Object.entries(categoryFields)) {
|
||||||
|
const val = item[field];
|
||||||
|
if (typeof val === "string" && codeToLabel[val]) {
|
||||||
|
item[field] = codeToLabel[val];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 현재 조건의 데이터에 추가
|
// 현재 조건의 데이터에 추가
|
||||||
const currentData = conditionalTableData[modalCondition] || [];
|
const currentData = conditionalTableData[modalCondition] || [];
|
||||||
const newData = [...currentData, ...mappedItems];
|
const newData = [...currentData, ...mappedItems];
|
||||||
|
|
@ -1964,6 +2130,8 @@ export function TableSectionRenderer({
|
||||||
[conditionValue]: newSelected,
|
[conditionValue]: newSelected,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
|
categoryColumns={tableCategoryColumns}
|
||||||
|
categoryLabelMap={tableCategoryLabelMap}
|
||||||
equalizeWidthsTrigger={widthTrigger}
|
equalizeWidthsTrigger={widthTrigger}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -2055,6 +2223,8 @@ export function TableSectionRenderer({
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
equalizeWidthsTrigger={widthTrigger}
|
equalizeWidthsTrigger={widthTrigger}
|
||||||
|
categoryColumns={tableCategoryColumns}
|
||||||
|
categoryLabelMap={tableCategoryLabelMap}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
|
|
@ -2185,6 +2355,8 @@ export function TableSectionRenderer({
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
onSelectionChange={setSelectedRows}
|
onSelectionChange={setSelectedRows}
|
||||||
equalizeWidthsTrigger={widthTrigger}
|
equalizeWidthsTrigger={widthTrigger}
|
||||||
|
categoryColumns={tableCategoryColumns}
|
||||||
|
categoryLabelMap={tableCategoryLabelMap}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 항목 선택 모달 */}
|
{/* 항목 선택 모달 */}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { apiClient } from "@/lib/api/client";
|
||||||
import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
|
import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
|
||||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
|
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
UniversalFormModalComponentProps,
|
UniversalFormModalComponentProps,
|
||||||
|
|
@ -247,6 +248,10 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
// 폼 데이터 상태
|
// 폼 데이터 상태
|
||||||
const [formData, setFormData] = useState<FormDataState>({});
|
const [formData, setFormData] = useState<FormDataState>({});
|
||||||
|
// formDataRef: 항상 최신 formData를 유지하는 ref
|
||||||
|
// React 상태 업데이트는 비동기적이므로, handleBeforeFormSave 등에서
|
||||||
|
// 클로저의 formData가 오래된 값을 참조하는 문제를 방지
|
||||||
|
const formDataRef = useRef<FormDataState>({});
|
||||||
const [, setOriginalData] = useState<Record<string, any>>({});
|
const [, setOriginalData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// 반복 섹션 데이터
|
// 반복 섹션 데이터
|
||||||
|
|
@ -398,18 +403,19 @@ export function UniversalFormModalComponent({
|
||||||
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
|
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
|
||||||
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
|
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
|
||||||
|
|
||||||
|
// formDataRef.current 사용: blur+click 동시 처리 시 클로저의 formData가 오래된 문제 방지
|
||||||
|
const latestFormData = formDataRef.current;
|
||||||
|
|
||||||
// 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용)
|
// 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용)
|
||||||
// - 신규 등록: formData.id가 없으므로 영향 없음
|
if (latestFormData.id !== undefined && latestFormData.id !== null && latestFormData.id !== "") {
|
||||||
// - 편집 모드: formData.id가 있으면 메인 테이블 UPDATE에 사용
|
event.detail.formData.id = latestFormData.id;
|
||||||
if (formData.id !== undefined && formData.id !== null && formData.id !== "") {
|
console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, latestFormData.id);
|
||||||
event.detail.formData.id = formData.id;
|
|
||||||
console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, formData.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
|
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
|
||||||
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
||||||
// (UniversalFormModal이 해당 필드의 주인이므로)
|
// (UniversalFormModal이 해당 필드의 주인이므로)
|
||||||
for (const [key, value] of Object.entries(formData)) {
|
for (const [key, value] of Object.entries(latestFormData)) {
|
||||||
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
|
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
|
||||||
const isConfiguredField = configuredFields.has(key);
|
const isConfiguredField = configuredFields.has(key);
|
||||||
const isNumberingRuleId = key.endsWith("_numberingRuleId");
|
const isNumberingRuleId = key.endsWith("_numberingRuleId");
|
||||||
|
|
@ -432,17 +438,13 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
|
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
|
||||||
// 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블),
|
// formDataRef.current(= latestFormData) 사용: React 상태 커밋 전에도 최신 데이터 보장
|
||||||
// handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용
|
for (const [key, value] of Object.entries(latestFormData)) {
|
||||||
for (const [key, value] of Object.entries(formData)) {
|
// _tableSection_ 과 __tableSection_ 모두 원본 키 그대로 전달
|
||||||
// 싱글/더블 언더스코어 모두 처리
|
// buttonActions.ts에서 DB데이터(__tableSection_)와 수정데이터(_tableSection_)를 구분하여 병합
|
||||||
if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) {
|
if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) {
|
||||||
// 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대)
|
event.detail.formData[key] = value;
|
||||||
const normalizedKey = key.startsWith("__tableSection_")
|
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`);
|
||||||
? key.replace("__tableSection_", "_tableSection_")
|
|
||||||
: key;
|
|
||||||
event.detail.formData[normalizedKey] = value;
|
|
||||||
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key} → ${normalizedKey}, ${value.length}개 항목`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용)
|
// 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용)
|
||||||
|
|
@ -457,6 +459,22 @@ export function UniversalFormModalComponent({
|
||||||
event.detail.formData._originalGroupedData = originalGroupedData;
|
event.detail.formData._originalGroupedData = originalGroupedData;
|
||||||
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`);
|
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 부모 formData의 중첩 객체(modalKey)도 최신 데이터로 업데이트
|
||||||
|
// onChange(setTimeout)가 아직 부모에 전파되지 않았을 수 있으므로 직접 업데이트
|
||||||
|
for (const parentKey of Object.keys(event.detail.formData)) {
|
||||||
|
const parentValue = event.detail.formData[parentKey];
|
||||||
|
if (parentValue && typeof parentValue === "object" && !Array.isArray(parentValue)) {
|
||||||
|
const hasTableSection = Object.keys(parentValue).some(
|
||||||
|
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
|
||||||
|
);
|
||||||
|
if (hasTableSection) {
|
||||||
|
event.detail.formData[parentKey] = { ...latestFormData };
|
||||||
|
console.log(`[UniversalFormModal] 부모 중첩 객체 업데이트: ${parentKey}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||||
|
|
@ -482,10 +500,11 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
// 테이블 섹션 데이터 설정
|
// 테이블 섹션 데이터 설정
|
||||||
const tableSectionKey = `_tableSection_${tableSection.id}`;
|
const tableSectionKey = `_tableSection_${tableSection.id}`;
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => {
|
||||||
...prev,
|
const newData = { ...prev, [tableSectionKey]: _groupedData };
|
||||||
[tableSectionKey]: _groupedData,
|
formDataRef.current = newData;
|
||||||
}));
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
groupedDataInitializedRef.current = true;
|
groupedDataInitializedRef.current = true;
|
||||||
}, [_groupedData, config.sections]);
|
}, [_groupedData, config.sections]);
|
||||||
|
|
@ -965,6 +984,7 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData(newFormData);
|
setFormData(newFormData);
|
||||||
|
formDataRef.current = newFormData;
|
||||||
setRepeatSections(newRepeatSections);
|
setRepeatSections(newRepeatSections);
|
||||||
setCollapsedSections(newCollapsed);
|
setCollapsedSections(newCollapsed);
|
||||||
setActivatedOptionalFieldGroups(newActivatedGroups);
|
setActivatedOptionalFieldGroups(newActivatedGroups);
|
||||||
|
|
@ -1132,6 +1152,9 @@ export function UniversalFormModalComponent({
|
||||||
console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`);
|
console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ref 즉시 업데이트 (React 상태 커밋 전에도 최신 데이터 접근 가능)
|
||||||
|
formDataRef.current = newData;
|
||||||
|
|
||||||
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
|
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
setTimeout(() => onChange(newData), 0);
|
setTimeout(() => onChange(newData), 0);
|
||||||
|
|
@ -1813,11 +1836,11 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
case "date":
|
case "date":
|
||||||
return (
|
return (
|
||||||
<Input
|
<FormDatePicker
|
||||||
id={fieldKey}
|
id={fieldKey}
|
||||||
type="date"
|
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onChange={(e) => onChangeHandler(e.target.value)}
|
onChange={onChangeHandler}
|
||||||
|
placeholder={field.placeholder || "날짜를 선택하세요"}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
readOnly={field.readOnly}
|
readOnly={field.readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1825,13 +1848,14 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
case "datetime":
|
case "datetime":
|
||||||
return (
|
return (
|
||||||
<Input
|
<FormDatePicker
|
||||||
id={fieldKey}
|
id={fieldKey}
|
||||||
type="datetime-local"
|
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onChange={(e) => onChangeHandler(e.target.value)}
|
onChange={onChangeHandler}
|
||||||
|
placeholder={field.placeholder || "날짜/시간을 선택하세요"}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
readOnly={field.readOnly}
|
readOnly={field.readOnly}
|
||||||
|
includeTime
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -393,7 +393,7 @@ export interface TableModalFilter {
|
||||||
export interface TableColumnConfig {
|
export interface TableColumnConfig {
|
||||||
field: string; // 필드명 (저장할 컬럼명)
|
field: string; // 필드명 (저장할 컬럼명)
|
||||||
label: string; // 컬럼 헤더 라벨
|
label: string; // 컬럼 헤더 라벨
|
||||||
type: "text" | "number" | "date" | "select"; // 입력 타입
|
type: "text" | "number" | "date" | "select" | "category"; // 입력 타입
|
||||||
|
|
||||||
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
|
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
|
||||||
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)
|
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,609 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useRef, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
FileSpreadsheet,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Download,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { importFromExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
interface BomExcelUploadModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
/** bomId가 있으면 "새 버전 등록" 모드, 없으면 "새 BOM 생성" 모드 */
|
||||||
|
bomId?: string;
|
||||||
|
bomName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedRow {
|
||||||
|
rowIndex: number;
|
||||||
|
level: number;
|
||||||
|
item_number: string;
|
||||||
|
item_name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
process_type: string;
|
||||||
|
remark: string;
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
isHeader?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadStep = "upload" | "preview" | "result";
|
||||||
|
|
||||||
|
const EXPECTED_HEADERS = ["레벨", "품번", "품명", "소요량", "단위", "공정구분", "비고"];
|
||||||
|
|
||||||
|
const HEADER_MAP: Record<string, string> = {
|
||||||
|
"레벨": "level",
|
||||||
|
"level": "level",
|
||||||
|
"품번": "item_number",
|
||||||
|
"품목코드": "item_number",
|
||||||
|
"item_number": "item_number",
|
||||||
|
"item_code": "item_number",
|
||||||
|
"품명": "item_name",
|
||||||
|
"품목명": "item_name",
|
||||||
|
"item_name": "item_name",
|
||||||
|
"소요량": "quantity",
|
||||||
|
"수량": "quantity",
|
||||||
|
"quantity": "quantity",
|
||||||
|
"qty": "quantity",
|
||||||
|
"단위": "unit",
|
||||||
|
"unit": "unit",
|
||||||
|
"공정구분": "process_type",
|
||||||
|
"공정": "process_type",
|
||||||
|
"process_type": "process_type",
|
||||||
|
"비고": "remark",
|
||||||
|
"remark": "remark",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BomExcelUploadModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
bomId,
|
||||||
|
bomName,
|
||||||
|
}: BomExcelUploadModalProps) {
|
||||||
|
const isVersionMode = !!bomId;
|
||||||
|
|
||||||
|
const [step, setStep] = useState<UploadStep>("upload");
|
||||||
|
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
|
||||||
|
const [fileName, setFileName] = useState<string>("");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadResult, setUploadResult] = useState<any>(null);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [versionName, setVersionName] = useState<string>("");
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setStep("upload");
|
||||||
|
setParsedRows([]);
|
||||||
|
setFileName("");
|
||||||
|
setUploadResult(null);
|
||||||
|
setUploading(false);
|
||||||
|
setVersionName("");
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
reset();
|
||||||
|
onOpenChange(false);
|
||||||
|
}, [reset, onOpenChange]);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setFileName(file.name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawData = await importFromExcel(file);
|
||||||
|
if (!rawData || rawData.length === 0) {
|
||||||
|
toast.error("엑셀 파일에 데이터가 없습니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstRow = rawData[0];
|
||||||
|
const excelHeaders = Object.keys(firstRow);
|
||||||
|
const fieldMap: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const header of excelHeaders) {
|
||||||
|
const normalized = header.trim().toLowerCase();
|
||||||
|
const mapped = HEADER_MAP[normalized] || HEADER_MAP[header.trim()];
|
||||||
|
if (mapped) {
|
||||||
|
fieldMap[header] = mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasItemNumber = excelHeaders.some(h => {
|
||||||
|
const n = h.trim().toLowerCase();
|
||||||
|
return HEADER_MAP[n] === "item_number" || HEADER_MAP[h.trim()] === "item_number";
|
||||||
|
});
|
||||||
|
if (!hasItemNumber) {
|
||||||
|
toast.error("품번 컬럼을 찾을 수 없습니다. 컬럼명을 확인해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: ParsedRow[] = [];
|
||||||
|
for (let index = 0; index < rawData.length; index++) {
|
||||||
|
const row = rawData[index];
|
||||||
|
const getField = (fieldName: string): any => {
|
||||||
|
for (const [excelKey, mappedField] of Object.entries(fieldMap)) {
|
||||||
|
if (mappedField === fieldName) return row[excelKey];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const levelRaw = getField("level");
|
||||||
|
const level = typeof levelRaw === "number" ? levelRaw : parseInt(String(levelRaw || "0"), 10);
|
||||||
|
const itemNumber = String(getField("item_number") || "").trim();
|
||||||
|
const itemName = String(getField("item_name") || "").trim();
|
||||||
|
const quantityRaw = getField("quantity");
|
||||||
|
const quantity = typeof quantityRaw === "number" ? quantityRaw : parseFloat(String(quantityRaw || "1"));
|
||||||
|
const unit = String(getField("unit") || "").trim();
|
||||||
|
const processType = String(getField("process_type") || "").trim();
|
||||||
|
const remark = String(getField("remark") || "").trim();
|
||||||
|
|
||||||
|
let valid = true;
|
||||||
|
let error = "";
|
||||||
|
const isHeader = level === 0;
|
||||||
|
|
||||||
|
if (!itemNumber) {
|
||||||
|
valid = false;
|
||||||
|
error = "품번 필수";
|
||||||
|
} else if (isNaN(level) || level < 0) {
|
||||||
|
valid = false;
|
||||||
|
error = "레벨 오류";
|
||||||
|
} else if (index > 0) {
|
||||||
|
const prevLevel = parsed[index - 1]?.level ?? 0;
|
||||||
|
if (level > prevLevel + 1) {
|
||||||
|
valid = false;
|
||||||
|
error = `레벨 점프 (이전: ${prevLevel})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.push({
|
||||||
|
rowIndex: index + 1,
|
||||||
|
isHeader,
|
||||||
|
level,
|
||||||
|
item_number: itemNumber,
|
||||||
|
item_name: itemName,
|
||||||
|
quantity: isNaN(quantity) ? 1 : quantity,
|
||||||
|
unit,
|
||||||
|
process_type: processType,
|
||||||
|
remark,
|
||||||
|
valid,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = parsed.filter(r => r.item_number !== "");
|
||||||
|
|
||||||
|
// 새 BOM 생성 모드: 레벨 0 필수
|
||||||
|
if (!isVersionMode) {
|
||||||
|
const hasHeader = filtered.some(r => r.level === 0);
|
||||||
|
if (!hasHeader) {
|
||||||
|
toast.error("레벨 0(BOM 마스터) 행이 필요합니다. 첫 행에 최상위 품목을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setParsedRows(filtered);
|
||||||
|
setStep("preview");
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(`파일 파싱 실패: ${err.message}`);
|
||||||
|
}
|
||||||
|
}, [isVersionMode]);
|
||||||
|
|
||||||
|
const handleUpload = useCallback(async () => {
|
||||||
|
const invalidRows = parsedRows.filter(r => !r.valid);
|
||||||
|
if (invalidRows.length > 0) {
|
||||||
|
toast.error(`유효하지 않은 행이 ${invalidRows.length}건 있습니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const rowPayload = parsedRows.map(r => ({
|
||||||
|
level: r.level,
|
||||||
|
item_number: r.item_number,
|
||||||
|
item_name: r.item_name,
|
||||||
|
quantity: r.quantity,
|
||||||
|
unit: r.unit,
|
||||||
|
process_type: r.process_type,
|
||||||
|
remark: r.remark,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let res;
|
||||||
|
if (isVersionMode) {
|
||||||
|
res = await apiClient.post(`/bom/${bomId}/excel-upload-version`, {
|
||||||
|
rows: rowPayload,
|
||||||
|
versionName: versionName.trim() || undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await apiClient.post("/bom/excel-upload", { rows: rowPayload });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data?.success) {
|
||||||
|
setUploadResult(res.data.data);
|
||||||
|
setStep("result");
|
||||||
|
const msg = isVersionMode
|
||||||
|
? `새 버전 생성 완료: 하위품목 ${res.data.data.insertedCount}건`
|
||||||
|
: `BOM 생성 완료: 하위품목 ${res.data.data.insertedCount}건`;
|
||||||
|
toast.success(msg);
|
||||||
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
const errData = res.data?.data;
|
||||||
|
if (errData?.unmatchedItems?.length > 0) {
|
||||||
|
toast.error(`매칭 안 되는 품번: ${errData.unmatchedItems.join(", ")}`);
|
||||||
|
setParsedRows(prev => prev.map(r => {
|
||||||
|
if (errData.unmatchedItems.includes(r.item_number)) {
|
||||||
|
return { ...r, valid: false, error: "품번 미등록" };
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
toast.error(res.data?.message || "업로드 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(`업로드 오류: ${err.response?.data?.message || err.message}`);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}, [parsedRows, isVersionMode, bomId, versionName, onSuccess]);
|
||||||
|
|
||||||
|
const handleDownloadTemplate = useCallback(async () => {
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
const XLSX = await import("xlsx");
|
||||||
|
let data: Record<string, any>[] = [];
|
||||||
|
|
||||||
|
if (isVersionMode && bomId) {
|
||||||
|
// 기존 BOM 데이터를 템플릿으로 다운로드
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/bom/${bomId}/excel-download`);
|
||||||
|
if (res.data?.success && res.data.data?.length > 0) {
|
||||||
|
data = res.data.data.map((row: any) => ({
|
||||||
|
"레벨": row.level,
|
||||||
|
"품번": row.item_number,
|
||||||
|
"품명": row.item_name,
|
||||||
|
"소요량": row.quantity,
|
||||||
|
"단위": row.unit,
|
||||||
|
"공정구분": row.process_type,
|
||||||
|
"비고": row.remark,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch { /* 데이터 없으면 빈 템플릿 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
if (isVersionMode) {
|
||||||
|
data = [
|
||||||
|
{ "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" },
|
||||||
|
{ "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
data = [
|
||||||
|
{ "레벨": 0, "품번": "(최상위 품번)", "품명": "(최상위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "BOM 마스터" },
|
||||||
|
{ "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" },
|
||||||
|
{ "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = XLSX.utils.json_to_sheet(data);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "BOM");
|
||||||
|
ws["!cols"] = [
|
||||||
|
{ wch: 6 }, { wch: 18 }, { wch: 20 }, { wch: 10 },
|
||||||
|
{ wch: 8 }, { wch: 12 }, { wch: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filename = bomName ? `BOM_${bomName}.xlsx` : "BOM_template.xlsx";
|
||||||
|
XLSX.writeFile(wb, filename);
|
||||||
|
toast.success("템플릿 다운로드 완료");
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(`다운로드 실패: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
}, [isVersionMode, bomId, bomName]);
|
||||||
|
|
||||||
|
const headerRow = parsedRows.find(r => r.isHeader);
|
||||||
|
const detailRows = parsedRows.filter(r => !r.isHeader);
|
||||||
|
const validCount = parsedRows.filter(r => r.valid).length;
|
||||||
|
const invalidCount = parsedRows.filter(r => !r.valid).length;
|
||||||
|
|
||||||
|
const title = isVersionMode ? "BOM 새 버전 엑셀 업로드" : "BOM 엑셀 업로드";
|
||||||
|
const description = isVersionMode
|
||||||
|
? `${bomName || "선택된 BOM"}의 새 버전을 엑셀 파일로 생성합니다. 레벨 0 행은 건너뜁니다.`
|
||||||
|
: "엑셀 파일로 새 BOM을 생성합니다. 레벨 0 = BOM 마스터, 레벨 1 이상 = 하위품목.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">{title}</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Step 1: 파일 업로드 */}
|
||||||
|
{step === "upload" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 새 버전 모드: 버전명 입력 */}
|
||||||
|
{isVersionMode && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">버전명 (미입력 시 자동 채번)</Label>
|
||||||
|
<Input
|
||||||
|
value={versionName}
|
||||||
|
onChange={(e) => setVersionName(e.target.value)}
|
||||||
|
placeholder="예: 2.0"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer",
|
||||||
|
"hover:border-primary/50 hover:bg-muted/50 transition-colors",
|
||||||
|
"border-muted-foreground/25",
|
||||||
|
)}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="mx-auto h-10 w-10 text-muted-foreground mb-3" />
|
||||||
|
<p className="text-sm font-medium">엑셀 파일을 선택하세요</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">.xlsx, .xls, .csv 형식 지원</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls,.csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-muted/50 p-3">
|
||||||
|
<p className="text-xs font-medium mb-2">엑셀 컬럼 형식</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{EXPECTED_HEADERS.map((h, i) => (
|
||||||
|
<span
|
||||||
|
key={h}
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] px-2 py-0.5 rounded-full",
|
||||||
|
i < 2 ? "bg-primary/10 text-primary font-medium" : "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{h}{i < 2 ? " *" : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1.5">
|
||||||
|
{isVersionMode
|
||||||
|
? "* 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목. 레벨 0 행이 있으면 건너뜁니다."
|
||||||
|
: "* 레벨 0 = BOM 마스터(최상위 품목, 1행), 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
disabled={downloading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{downloading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isVersionMode && bomName ? `현재 BOM 데이터로 템플릿 다운로드` : "빈 템플릿 다운로드"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: 미리보기 */}
|
||||||
|
{step === "preview" && (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-muted-foreground">{fileName}</span>
|
||||||
|
{!isVersionMode && headerRow && (
|
||||||
|
<span className="text-xs font-medium">마스터: {headerRow.item_number}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs">
|
||||||
|
하위품목 <span className="font-medium">{detailRows.length}</span>건
|
||||||
|
</span>
|
||||||
|
{invalidCount > 0 && (
|
||||||
|
<span className="text-xs text-destructive flex items-center gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" /> {invalidCount}건 오류
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={reset} className="h-7 text-xs">
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
다시 선택
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto border rounded-md">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-muted/50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1.5 text-left font-medium w-8">#</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-medium w-12">구분</th>
|
||||||
|
<th className="px-2 py-1.5 text-center font-medium w-12">레벨</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-medium">품번</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-medium">품명</th>
|
||||||
|
<th className="px-2 py-1.5 text-right font-medium w-16">소요량</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-medium w-14">단위</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-medium w-20">공정</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-medium">비고</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{parsedRows.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.rowIndex}
|
||||||
|
className={cn(
|
||||||
|
"border-t hover:bg-muted/30",
|
||||||
|
row.isHeader && "bg-blue-50/50",
|
||||||
|
!row.valid && "bg-destructive/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-1 text-muted-foreground">{row.rowIndex}</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
{row.isHeader ? (
|
||||||
|
<span className="text-[10px] text-blue-600 font-medium bg-blue-50 px-1.5 py-0.5 rounded">
|
||||||
|
{isVersionMode ? "건너뜀" : "마스터"}
|
||||||
|
</span>
|
||||||
|
) : row.valid ? (
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1" title={row.error}>
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1 text-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block rounded px-1.5 py-0.5 text-[10px] font-mono",
|
||||||
|
row.isHeader ? "bg-blue-100 text-blue-700 font-medium" : "bg-muted",
|
||||||
|
)}
|
||||||
|
style={{ marginLeft: `${row.level * 8}px` }}
|
||||||
|
>
|
||||||
|
{row.level}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-2 py-1 font-mono", row.isHeader && "font-semibold")}>{row.item_number}</td>
|
||||||
|
<td className={cn("px-2 py-1", row.isHeader && "font-semibold")}>{row.item_name}</td>
|
||||||
|
<td className="px-2 py-1 text-right font-mono">{row.quantity}</td>
|
||||||
|
<td className="px-2 py-1">{row.unit}</td>
|
||||||
|
<td className="px-2 py-1">{row.process_type}</td>
|
||||||
|
<td className="px-2 py-1 text-muted-foreground truncate max-w-[100px]">{row.remark}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invalidCount > 0 && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-2.5 text-xs text-destructive">
|
||||||
|
<div className="font-medium mb-1">유효하지 않은 행 ({invalidCount}건)</div>
|
||||||
|
<ul className="space-y-0.5 ml-3 list-disc">
|
||||||
|
{parsedRows.filter(r => !r.valid).slice(0, 5).map(r => (
|
||||||
|
<li key={r.rowIndex}>{r.rowIndex}행: {r.error}</li>
|
||||||
|
))}
|
||||||
|
{invalidCount > 5 && <li>...외 {invalidCount - 5}건</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{isVersionMode
|
||||||
|
? "레벨 1 이상의 하위품목으로 새 버전을 생성합니다."
|
||||||
|
: "레벨 0 품목으로 새 BOM 마스터를 생성하고, 레벨 1 이상은 하위품목으로 등록합니다."
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: 결과 */}
|
||||||
|
{step === "result" && uploadResult && (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center mb-3">
|
||||||
|
<CheckCircle2 className="h-7 w-7 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{isVersionMode ? "새 버전 생성 완료" : "BOM 생성 완료"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
하위품목 {uploadResult.insertedCount}건이 등록되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn("grid gap-3 max-w-xs mx-auto", isVersionMode ? "grid-cols-1" : "grid-cols-2")}>
|
||||||
|
{!isVersionMode && (
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">1</div>
|
||||||
|
<div className="text-xs text-muted-foreground">BOM 마스터</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{uploadResult.insertedCount}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">하위품목</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
{step === "upload" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === "preview" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={reset}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={uploading || invalidCount > 0}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> 업로드 중...</>
|
||||||
|
) : (
|
||||||
|
<><Upload className="mr-2 h-4 w-4" />
|
||||||
|
{isVersionMode ? `새 버전 생성 (${detailRows.length}건)` : `BOM 생성 (${detailRows.length}건)`}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === "result" && (
|
||||||
|
<Button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
History,
|
History,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Check,
|
Check,
|
||||||
|
FileSpreadsheet,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { BomDetailEditModal } from "./BomDetailEditModal";
|
import { BomDetailEditModal } from "./BomDetailEditModal";
|
||||||
import { BomHistoryModal } from "./BomHistoryModal";
|
import { BomHistoryModal } from "./BomHistoryModal";
|
||||||
import { BomVersionModal } from "./BomVersionModal";
|
import { BomVersionModal } from "./BomVersionModal";
|
||||||
|
import { BomExcelUploadModal } from "./BomExcelUploadModal";
|
||||||
|
|
||||||
interface BomTreeNode {
|
interface BomTreeNode {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -77,6 +79,7 @@ export function BomTreeComponent({
|
||||||
const [editTargetNode, setEditTargetNode] = useState<BomTreeNode | null>(null);
|
const [editTargetNode, setEditTargetNode] = useState<BomTreeNode | null>(null);
|
||||||
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||||
const [versionModalOpen, setVersionModalOpen] = useState(false);
|
const [versionModalOpen, setVersionModalOpen] = useState(false);
|
||||||
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||||
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => {
|
const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => {
|
||||||
|
|
@ -837,6 +840,15 @@ export function BomTreeComponent({
|
||||||
버전
|
버전
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setExcelUploadOpen(true)}
|
||||||
|
className="h-6 gap-1 px-2 text-[10px]"
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="h-3 w-3" />
|
||||||
|
엑셀
|
||||||
|
</Button>
|
||||||
<div className="mx-1 h-4 w-px bg-gray-200" />
|
<div className="mx-1 h-4 w-px bg-gray-200" />
|
||||||
<div className="flex overflow-hidden rounded-md border">
|
<div className="flex overflow-hidden rounded-md border">
|
||||||
<button
|
<button
|
||||||
|
|
@ -1149,6 +1161,18 @@ export function BomTreeComponent({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedBomId && (
|
||||||
|
<BomExcelUploadModal
|
||||||
|
open={excelUploadOpen}
|
||||||
|
onOpenChange={setExcelUploadOpen}
|
||||||
|
bomId={selectedBomId}
|
||||||
|
bomName={headerInfo?.item_name || ""}
|
||||||
|
onSuccess={() => {
|
||||||
|
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -724,17 +724,28 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 소스 컴포넌트에서 데이터 가져오기
|
// 1. 소스 컴포넌트에서 데이터 가져오기
|
||||||
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
let sourceProvider: import("@/types/data-transfer").DataProvidable | undefined;
|
||||||
|
|
||||||
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
|
const isAutoSource =
|
||||||
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
|
!dataTransferConfig.sourceComponentId || dataTransferConfig.sourceComponentId === "__auto__";
|
||||||
|
|
||||||
|
if (!isAutoSource) {
|
||||||
|
sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 탐색 모드이거나, 지정된 소스를 찾지 못한 경우
|
||||||
|
// 현재 마운트된 DataProvider 중에서 table-list를 자동 탐색
|
||||||
if (!sourceProvider) {
|
if (!sourceProvider) {
|
||||||
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
|
if (!isAutoSource) {
|
||||||
console.log("🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...");
|
console.log(
|
||||||
|
`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log("🔍 [ButtonPrimary] 현재 활성 DataProvider 자동 탐색...");
|
||||||
|
|
||||||
const allProviders = screenContext.getAllDataProviders();
|
const allProviders = screenContext.getAllDataProviders();
|
||||||
|
|
||||||
// 테이블 리스트 우선 탐색
|
// table-list 우선 탐색
|
||||||
for (const [id, provider] of allProviders) {
|
for (const [id, provider] of allProviders) {
|
||||||
if (provider.componentType === "table-list") {
|
if (provider.componentType === "table-list") {
|
||||||
sourceProvider = provider;
|
sourceProvider = provider;
|
||||||
|
|
@ -743,7 +754,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
|
// table-list가 없으면 첫 번째 DataProvider 사용
|
||||||
if (!sourceProvider && allProviders.size > 0) {
|
if (!sourceProvider && allProviders.size > 0) {
|
||||||
const firstEntry = allProviders.entries().next().value;
|
const firstEntry = allProviders.entries().next().value;
|
||||||
if (firstEntry) {
|
if (firstEntry) {
|
||||||
|
|
@ -784,15 +795,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
const additionalValues = additionalProvider.getSelectedData();
|
const additionalValues = additionalProvider.getSelectedData();
|
||||||
|
|
||||||
if (additionalValues && additionalValues.length > 0) {
|
if (additionalValues && additionalValues.length > 0) {
|
||||||
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
|
|
||||||
const firstValue = additionalValues[0];
|
const firstValue = additionalValues[0];
|
||||||
|
|
||||||
// fieldName이 지정되어 있으면 그 필드만 추출
|
|
||||||
if (additionalSource.fieldName) {
|
if (additionalSource.fieldName) {
|
||||||
additionalData[additionalSource.fieldName] =
|
additionalData[additionalSource.fieldName] =
|
||||||
firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
||||||
} else {
|
} else {
|
||||||
// fieldName이 없으면 전체 객체 병합
|
|
||||||
additionalData = { ...additionalData, ...firstValue };
|
additionalData = { ...additionalData, ...firstValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -802,6 +810,25 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
value: additionalData[additionalSource.fieldName || "all"],
|
value: additionalData[additionalSource.fieldName || "all"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (formData) {
|
||||||
|
// DataProvider로 등록되지 않은 컴포넌트(v2-select 등)는 formData에서 값을 가져옴
|
||||||
|
const comp = allComponents?.find((c: any) => c.id === additionalSource.componentId);
|
||||||
|
const columnName =
|
||||||
|
comp?.columnName ||
|
||||||
|
comp?.componentConfig?.columnName ||
|
||||||
|
comp?.overrides?.columnName;
|
||||||
|
|
||||||
|
if (columnName && formData[columnName] !== undefined && formData[columnName] !== "") {
|
||||||
|
const targetField = additionalSource.fieldName || columnName;
|
||||||
|
additionalData[targetField] = formData[columnName];
|
||||||
|
|
||||||
|
console.log("📦 추가 데이터 수집 (formData 폴백):", {
|
||||||
|
sourceId: additionalSource.componentId,
|
||||||
|
columnName,
|
||||||
|
targetField,
|
||||||
|
value: formData[columnName],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -870,44 +897,126 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 매핑 규칙 적용 + 추가 데이터 병합
|
// 4. 매핑 규칙 결정: 멀티 테이블 매핑 또는 레거시 단일 매핑
|
||||||
const mappedData = sourceData.map((row) => {
|
let effectiveMappingRules: any[] = dataTransferConfig.mappingRules || [];
|
||||||
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
|
|
||||||
|
const sourceTableName = sourceProvider?.tableName;
|
||||||
|
const multiTableMappings: Array<{ sourceTable: string; mappingRules: any[] }> =
|
||||||
|
dataTransferConfig.multiTableMappings || [];
|
||||||
|
|
||||||
|
if (multiTableMappings.length > 0 && sourceTableName) {
|
||||||
|
const matchedGroup = multiTableMappings.find((g) => g.sourceTable === sourceTableName);
|
||||||
|
if (matchedGroup) {
|
||||||
|
effectiveMappingRules = matchedGroup.mappingRules || [];
|
||||||
|
console.log(`✅ [ButtonPrimary] 멀티 테이블 매핑 적용: ${sourceTableName}`, effectiveMappingRules);
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ [ButtonPrimary] 소스 테이블 ${sourceTableName}에 대한 매핑 없음, 동일 필드명 자동 매핑`);
|
||||||
|
effectiveMappingRules = [];
|
||||||
|
}
|
||||||
|
} else if (multiTableMappings.length > 0 && !sourceTableName) {
|
||||||
|
console.log("⚠️ [ButtonPrimary] 소스 테이블 미감지, 첫 번째 매핑 그룹 사용");
|
||||||
|
effectiveMappingRules = multiTableMappings[0]?.mappingRules || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedData = sourceData.map((row) => {
|
||||||
|
const mappedRow = applyMappingRules(row, effectiveMappingRules);
|
||||||
|
|
||||||
// 추가 데이터를 모든 행에 포함
|
|
||||||
return {
|
return {
|
||||||
...mappedRow,
|
...mappedRow,
|
||||||
...additionalData,
|
...additionalData,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 5. targetType / targetComponentId 기본값 및 자동 탐색
|
||||||
|
const effectiveTargetType = dataTransferConfig.targetType || "component";
|
||||||
|
let effectiveTargetComponentId = dataTransferConfig.targetComponentId;
|
||||||
|
|
||||||
|
// targetComponentId가 없으면 현재 화면에서 DataReceiver 자동 탐색
|
||||||
|
if (effectiveTargetType === "component" && !effectiveTargetComponentId) {
|
||||||
|
console.log("🔍 [ButtonPrimary] 타겟 컴포넌트 자동 탐색...");
|
||||||
|
const allReceivers = screenContext.getAllDataReceivers();
|
||||||
|
|
||||||
|
// repeater 계열 우선 탐색
|
||||||
|
for (const [id, receiver] of allReceivers) {
|
||||||
|
if (
|
||||||
|
receiver.componentType === "repeater-field-group" ||
|
||||||
|
receiver.componentType === "v2-repeater" ||
|
||||||
|
receiver.componentType === "repeater"
|
||||||
|
) {
|
||||||
|
effectiveTargetComponentId = id;
|
||||||
|
console.log(`✅ [ButtonPrimary] 리피터 자동 발견: ${id} (${receiver.componentType})`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeater가 없으면 소스가 아닌 첫 번째 DataReceiver 사용
|
||||||
|
if (!effectiveTargetComponentId) {
|
||||||
|
for (const [id, receiver] of allReceivers) {
|
||||||
|
if (receiver.componentType === "table-list" || receiver.componentType === "data-table") {
|
||||||
|
effectiveTargetComponentId = id;
|
||||||
|
console.log(`✅ [ButtonPrimary] DataReceiver 자동 발견: ${id} (${receiver.componentType})`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!effectiveTargetComponentId) {
|
||||||
|
toast.error("데이터를 받을 수 있는 타겟 컴포넌트를 찾을 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log("📦 데이터 전달:", {
|
console.log("📦 데이터 전달:", {
|
||||||
sourceData,
|
sourceData,
|
||||||
mappedData,
|
mappedData,
|
||||||
targetType: dataTransferConfig.targetType,
|
targetType: effectiveTargetType,
|
||||||
targetComponentId: dataTransferConfig.targetComponentId,
|
targetComponentId: effectiveTargetComponentId,
|
||||||
targetScreenId: dataTransferConfig.targetScreenId,
|
targetScreenId: dataTransferConfig.targetScreenId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. 타겟으로 데이터 전달
|
// 6. 타겟으로 데이터 전달
|
||||||
if (dataTransferConfig.targetType === "component") {
|
if (effectiveTargetType === "component") {
|
||||||
// 같은 화면의 컴포넌트로 전달
|
const targetReceiver = screenContext.getDataReceiver(effectiveTargetComponentId);
|
||||||
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
|
|
||||||
|
const receiverConfig = {
|
||||||
|
targetComponentId: effectiveTargetComponentId,
|
||||||
|
targetComponentType: targetReceiver?.componentType || ("table" as const),
|
||||||
|
mode: dataTransferConfig.mode || ("append" as const),
|
||||||
|
mappingRules: dataTransferConfig.mappingRules || [],
|
||||||
|
};
|
||||||
|
|
||||||
if (!targetReceiver) {
|
if (!targetReceiver) {
|
||||||
toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`);
|
// 타겟이 아직 마운트되지 않은 경우 (조건부 레이어 등)
|
||||||
|
// 버퍼에 저장하고 레이어 활성화 요청
|
||||||
|
console.log(
|
||||||
|
`⏳ [ButtonPrimary] 타겟 컴포넌트 미마운트, 대기열에 추가: ${effectiveTargetComponentId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
screenContext.addPendingTransfer({
|
||||||
|
targetComponentId: effectiveTargetComponentId,
|
||||||
|
data: mappedData,
|
||||||
|
config: receiverConfig,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
targetLayerId: dataTransferConfig.targetLayerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 레이어 활성화 이벤트 발행 (page.tsx에서 수신)
|
||||||
|
const activateEvent = new CustomEvent("activateLayerForComponent", {
|
||||||
|
detail: {
|
||||||
|
componentId: effectiveTargetComponentId,
|
||||||
|
targetLayerId: dataTransferConfig.targetLayerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.dispatchEvent(activateEvent);
|
||||||
|
|
||||||
|
toast.info(`타겟 레이어를 활성화하고 데이터 전달을 준비합니다...`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await targetReceiver.receiveData(mappedData, {
|
await targetReceiver.receiveData(mappedData, receiverConfig);
|
||||||
targetComponentId: dataTransferConfig.targetComponentId,
|
|
||||||
targetComponentType: targetReceiver.componentType,
|
|
||||||
mode: dataTransferConfig.mode || "append",
|
|
||||||
mappingRules: dataTransferConfig.mappingRules || [],
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
||||||
} else if (dataTransferConfig.targetType === "splitPanel") {
|
} else if (effectiveTargetType === "splitPanel") {
|
||||||
// 🆕 분할 패널의 반대편 화면으로 전달
|
// 🆕 분할 패널의 반대편 화면으로 전달
|
||||||
if (!splitPanelContext) {
|
if (!splitPanelContext) {
|
||||||
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
|
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
|
||||||
|
|
@ -1107,6 +1216,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
effectiveFormData = { ...splitPanelParentData };
|
effectiveFormData = { ...splitPanelParentData };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("🔴 [ButtonPrimary] 저장 시 formData 디버그:", {
|
||||||
|
propsFormDataKeys: Object.keys(propsFormData),
|
||||||
|
screenContextFormDataKeys: Object.keys(screenContextFormData),
|
||||||
|
effectiveFormDataKeys: Object.keys(effectiveFormData),
|
||||||
|
process_code: effectiveFormData.process_code,
|
||||||
|
equipment_code: effectiveFormData.equipment_code,
|
||||||
|
fullData: JSON.stringify(effectiveFormData),
|
||||||
|
});
|
||||||
|
|
||||||
const context: ButtonActionContext = {
|
const context: ButtonActionContext = {
|
||||||
formData: effectiveFormData,
|
formData: effectiveFormData,
|
||||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||||
|
|
@ -1363,9 +1481,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
{/* 확인 다이얼로그 */}
|
||||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
<AlertDialogContent className="z-[99999]">
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
||||||
|
|
|
||||||
|
|
@ -247,14 +247,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex h-[75vh] flex-col space-y-3">
|
<div className="flex h-[75vh] flex-col space-y-3">
|
||||||
{/* 파일 업로드 영역 - 높이 축소 */}
|
{/* 파일 업로드 영역 - readonly/disabled이면 숨김 */}
|
||||||
{!isDesignMode && (
|
{!isDesignMode && !config.readonly && !config.disabled && (
|
||||||
<div
|
<div
|
||||||
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} ${config.disabled ? "cursor-not-allowed opacity-50" : "hover:border-gray-400"} ${uploading ? "opacity-75" : ""} `}
|
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} hover:border-gray-400 ${uploading ? "opacity-75" : ""} `}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!config.disabled && !isDesignMode) {
|
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
|
|
@ -267,7 +265,6 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
accept={config.accept}
|
accept={config.accept}
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={config.disabled}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
|
|
@ -286,8 +283,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
|
|
||||||
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
|
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
|
||||||
<div className="flex min-h-0 flex-1 gap-4">
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
|
{/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */}
|
||||||
<div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
|
{(config.showPreview !== false) && <div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
|
||||||
{/* 확대/축소 컨트롤 */}
|
{/* 확대/축소 컨트롤 */}
|
||||||
{selectedFile && previewImageUrl && (
|
{selectedFile && previewImageUrl && (
|
||||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1">
|
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1">
|
||||||
|
|
@ -369,10 +366,10 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
{selectedFile.realFileName}
|
{selectedFile.realFileName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* 우측: 파일 목록 (고정 너비) */}
|
{/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */}
|
||||||
<div className="flex w-[400px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200">
|
{(config.showFileList !== false) && <div className={`flex shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 ${config.showPreview !== false ? "w-[400px]" : "flex-1"}`}>
|
||||||
<div className="border-b border-gray-200 bg-gray-50 p-3">
|
<div className="border-b border-gray-200 bg-gray-50 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-gray-700">업로드된 파일</h3>
|
<h3 className="text-sm font-medium text-gray-700">업로드된 파일</h3>
|
||||||
|
|
@ -404,7 +401,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()}
|
{config.showFileSize !== false && <>{formatFileSize(file.fileSize)} • </>}{file.fileExt.toUpperCase()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
|
|
@ -434,6 +431,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
>
|
>
|
||||||
<Eye className="h-3 w-3" />
|
<Eye className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{config.allowDownload !== false && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -446,7 +444,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
>
|
>
|
||||||
<Download className="h-3 w-3" />
|
<Download className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
{!isDesignMode && (
|
)}
|
||||||
|
{!isDesignMode && config.allowDelete !== false && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -476,7 +475,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
@ -487,8 +486,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
file={viewerFile}
|
file={viewerFile}
|
||||||
isOpen={isViewerOpen}
|
isOpen={isViewerOpen}
|
||||||
onClose={handleViewerClose}
|
onClose={handleViewerClose}
|
||||||
onDownload={onFileDownload}
|
onDownload={config.allowDownload !== false ? onFileDownload : undefined}
|
||||||
onDelete={!isDesignMode ? onFileDelete : undefined}
|
onDelete={!isDesignMode && config.allowDelete !== false ? onFileDelete : undefined}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const [forceUpdate, setForceUpdate] = useState(0);
|
const [forceUpdate, setForceUpdate] = useState(0);
|
||||||
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지)
|
||||||
|
const filesLoadedFromObjidRef = useRef(false);
|
||||||
|
|
||||||
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
|
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
|
||||||
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
||||||
|
|
@ -150,6 +152,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
if (isRecordMode || !recordId) {
|
if (isRecordMode || !recordId) {
|
||||||
setUploadedFiles([]);
|
setUploadedFiles([]);
|
||||||
setRepresentativeImageUrl(null);
|
setRepresentativeImageUrl(null);
|
||||||
|
filesLoadedFromObjidRef.current = false;
|
||||||
}
|
}
|
||||||
} else if (prevIsRecordModeRef.current === null) {
|
} else if (prevIsRecordModeRef.current === null) {
|
||||||
// 초기 마운트 시 모드 저장
|
// 초기 마운트 시 모드 저장
|
||||||
|
|
@ -191,63 +194,68 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
}, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행
|
}, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행
|
||||||
|
|
||||||
// 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드
|
// 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드
|
||||||
// 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지)
|
// 콤마로 구분된 다중 objid도 처리 (예: "123,456")
|
||||||
const imageObjidFromFormData = formData?.[columnName];
|
const imageObjidFromFormData = formData?.[columnName];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 이미지 objid가 있고, 숫자 문자열인 경우에만 처리
|
if (!imageObjidFromFormData) return;
|
||||||
if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) {
|
|
||||||
const objidStr = String(imageObjidFromFormData);
|
|
||||||
|
|
||||||
// 이미 같은 objid의 파일이 로드되어 있으면 스킵
|
const rawValue = String(imageObjidFromFormData);
|
||||||
const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr);
|
// 콤마 구분 다중 objid 또는 단일 objid 모두 처리
|
||||||
if (alreadyLoaded) {
|
const objids = rawValue.split(',').map(s => s.trim()).filter(s => /^\d+$/.test(s));
|
||||||
return;
|
|
||||||
}
|
if (objids.length === 0) return;
|
||||||
|
|
||||||
|
// 모든 objid가 이미 로드되어 있으면 스킵
|
||||||
|
const allLoaded = objids.every(id => uploadedFiles.some(f => String(f.objid) === id));
|
||||||
|
if (allLoaded) return;
|
||||||
|
|
||||||
// 🔑 실제 파일 정보 조회 (previewUrl 제거 - apiClient blob 다운로드 방식으로 통일)
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const fileInfoResponse = await getFileInfoByObjid(objidStr);
|
const loadedFiles: FileInfo[] = [];
|
||||||
|
|
||||||
|
for (const objid of objids) {
|
||||||
|
// 이미 로드된 파일은 스킵
|
||||||
|
if (uploadedFiles.some(f => String(f.objid) === objid)) continue;
|
||||||
|
|
||||||
|
const fileInfoResponse = await getFileInfoByObjid(objid);
|
||||||
|
|
||||||
if (fileInfoResponse.success && fileInfoResponse.data) {
|
if (fileInfoResponse.success && fileInfoResponse.data) {
|
||||||
const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data;
|
const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data;
|
||||||
|
|
||||||
const fileInfo = {
|
loadedFiles.push({
|
||||||
objid: objidStr,
|
objid,
|
||||||
realFileName: realFileName,
|
realFileName,
|
||||||
fileExt: fileExt,
|
fileExt,
|
||||||
fileSize: fileSize,
|
fileSize,
|
||||||
filePath: getFilePreviewUrl(objidStr),
|
filePath: getFilePreviewUrl(objid),
|
||||||
regdate: regdate,
|
regdate,
|
||||||
isImage: true,
|
isImage: true,
|
||||||
isRepresentative: isRepresentative,
|
isRepresentative,
|
||||||
};
|
} as FileInfo);
|
||||||
|
|
||||||
setUploadedFiles([fileInfo]);
|
|
||||||
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
|
|
||||||
} else {
|
} else {
|
||||||
// 파일 정보 조회 실패 시 최소 정보로 추가
|
// 파일 정보 조회 실패 시 최소 정보로 추가
|
||||||
console.warn("🖼️ [FileUploadComponent] 파일 정보 조회 실패, 최소 정보 사용");
|
loadedFiles.push({
|
||||||
const minimalFileInfo = {
|
objid,
|
||||||
objid: objidStr,
|
realFileName: `file_${objid}`,
|
||||||
realFileName: `image_${objidStr}.jpg`,
|
|
||||||
fileExt: '.jpg',
|
fileExt: '.jpg',
|
||||||
fileSize: 0,
|
fileSize: 0,
|
||||||
filePath: getFilePreviewUrl(objidStr),
|
filePath: getFilePreviewUrl(objid),
|
||||||
regdate: new Date().toISOString(),
|
regdate: new Date().toISOString(),
|
||||||
isImage: true,
|
isImage: true,
|
||||||
};
|
} as FileInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setUploadedFiles([minimalFileInfo]);
|
if (loadedFiles.length > 0) {
|
||||||
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
|
setUploadedFiles(loadedFiles);
|
||||||
|
filesLoadedFromObjidRef.current = true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
|
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}, [imageObjidFromFormData, columnName, component.id]);
|
||||||
}, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존
|
|
||||||
|
|
||||||
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
||||||
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
||||||
|
|
@ -365,6 +373,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
...file,
|
...file,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 서버에서 0개 반환 + objid 기반 로딩이 이미 완료된 경우 덮어쓰지 않음
|
||||||
|
if (formattedFiles.length === 0 && filesLoadedFromObjidRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
|
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
|
||||||
let finalFiles = formattedFiles;
|
let finalFiles = formattedFiles;
|
||||||
|
|
@ -427,14 +439,19 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
return; // DB 로드 성공 시 localStorage 무시
|
return; // DB 로드 성공 시 localStorage 무시
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지
|
// objid 기반으로 이미 파일이 로드된 경우 빈 데이터로 덮어쓰지 않음
|
||||||
|
if (filesLoadedFromObjidRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지
|
||||||
if (!isRecordMode || !recordId) {
|
if (!isRecordMode || !recordId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
|
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
|
||||||
|
|
||||||
// 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용)
|
// 전역 상태에서 최신 파일 정보 가져오기 (고유 키 사용)
|
||||||
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
||||||
const uniqueKeyForFallback = getUniqueKey();
|
const uniqueKeyForFallback = getUniqueKey();
|
||||||
const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || [];
|
const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || [];
|
||||||
|
|
@ -442,6 +459,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
||||||
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
||||||
|
|
||||||
|
// 빈 데이터로 기존 파일을 덮어쓰지 않음
|
||||||
|
if (currentFiles.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 최신 파일과 현재 파일 비교
|
// 최신 파일과 현재 파일 비교
|
||||||
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
||||||
|
|
@ -1147,8 +1168,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
file={viewerFile}
|
file={viewerFile}
|
||||||
isOpen={isViewerOpen}
|
isOpen={isViewerOpen}
|
||||||
onClose={handleViewerClose}
|
onClose={handleViewerClose}
|
||||||
onDownload={handleFileDownload}
|
onDownload={safeComponentConfig.allowDownload !== false ? handleFileDownload : undefined}
|
||||||
onDelete={!isDesignMode ? handleFileDelete : undefined}
|
onDelete={!isDesignMode && safeComponentConfig.allowDelete !== false ? handleFileDelete : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 파일 관리 모달 */}
|
{/* 파일 관리 모달 */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,544 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { ItemRoutingConfig, ItemRoutingComponentProps } from "./types";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
import { useItemRouting } from "./hooks/useItemRouting";
|
||||||
|
|
||||||
|
export function ItemRoutingComponent({
|
||||||
|
config: configProp,
|
||||||
|
isPreview,
|
||||||
|
}: ItemRoutingComponentProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
items,
|
||||||
|
versions,
|
||||||
|
details,
|
||||||
|
loading,
|
||||||
|
selectedItemCode,
|
||||||
|
selectedItemName,
|
||||||
|
selectedVersionId,
|
||||||
|
fetchItems,
|
||||||
|
selectItem,
|
||||||
|
selectVersion,
|
||||||
|
refreshVersions,
|
||||||
|
refreshDetails,
|
||||||
|
deleteDetail,
|
||||||
|
deleteVersion,
|
||||||
|
setDefaultVersion,
|
||||||
|
unsetDefaultVersion,
|
||||||
|
} = useItemRouting(configProp || {});
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{
|
||||||
|
type: "version" | "detail";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// 초기 로딩 (마운트 시 1회만)
|
||||||
|
const mountedRef = React.useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
mountedRef.current = true;
|
||||||
|
fetchItems();
|
||||||
|
}
|
||||||
|
}, [fetchItems]);
|
||||||
|
|
||||||
|
// 모달 저장 성공 감지 -> 데이터 새로고침
|
||||||
|
const refreshVersionsRef = React.useRef(refreshVersions);
|
||||||
|
const refreshDetailsRef = React.useRef(refreshDetails);
|
||||||
|
refreshVersionsRef.current = refreshVersions;
|
||||||
|
refreshDetailsRef.current = refreshDetails;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSaveSuccess = () => {
|
||||||
|
refreshVersionsRef.current();
|
||||||
|
refreshDetailsRef.current();
|
||||||
|
};
|
||||||
|
window.addEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 품목 검색
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
fetchItems(searchText || undefined);
|
||||||
|
}, [fetchItems, searchText]);
|
||||||
|
|
||||||
|
const handleSearchKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") handleSearch();
|
||||||
|
},
|
||||||
|
[handleSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 버전 추가 모달
|
||||||
|
const handleAddVersion = useCallback(() => {
|
||||||
|
if (!selectedItemCode) {
|
||||||
|
toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const screenId = config.modals.versionAddScreenId;
|
||||||
|
if (!screenId) return;
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("openScreenModal", {
|
||||||
|
detail: {
|
||||||
|
screenId,
|
||||||
|
urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable },
|
||||||
|
splitPanelParentData: {
|
||||||
|
[config.dataSource.routingVersionFkColumn]: selectedItemCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [selectedItemCode, config, toast]);
|
||||||
|
|
||||||
|
// 공정 추가 모달
|
||||||
|
const handleAddProcess = useCallback(() => {
|
||||||
|
if (!selectedVersionId) {
|
||||||
|
toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const screenId = config.modals.processAddScreenId;
|
||||||
|
if (!screenId) return;
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("openScreenModal", {
|
||||||
|
detail: {
|
||||||
|
screenId,
|
||||||
|
urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable },
|
||||||
|
splitPanelParentData: {
|
||||||
|
[config.dataSource.routingDetailFkColumn]: selectedVersionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [selectedVersionId, config, toast]);
|
||||||
|
|
||||||
|
// 공정 수정 모달
|
||||||
|
const handleEditProcess = useCallback(
|
||||||
|
(detail: Record<string, any>) => {
|
||||||
|
const screenId = config.modals.processEditScreenId;
|
||||||
|
if (!screenId) return;
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("openScreenModal", {
|
||||||
|
detail: {
|
||||||
|
screenId,
|
||||||
|
urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable },
|
||||||
|
editData: detail,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[config]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기본 버전 토글
|
||||||
|
const handleToggleDefault = useCallback(
|
||||||
|
async (versionId: string, currentIsDefault: boolean) => {
|
||||||
|
let success: boolean;
|
||||||
|
if (currentIsDefault) {
|
||||||
|
success = await unsetDefaultVersion(versionId);
|
||||||
|
if (success) toast({ title: "기본 버전이 해제되었습니다" });
|
||||||
|
} else {
|
||||||
|
success = await setDefaultVersion(versionId);
|
||||||
|
if (success) toast({ title: "기본 버전으로 설정되었습니다" });
|
||||||
|
}
|
||||||
|
if (!success) {
|
||||||
|
toast({ title: "기본 버전 변경 실패", variant: "destructive" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setDefaultVersion, unsetDefaultVersion, toast]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
|
||||||
|
let success = false;
|
||||||
|
if (deleteTarget.type === "version") {
|
||||||
|
success = await deleteVersion(deleteTarget.id);
|
||||||
|
} else {
|
||||||
|
success = await deleteDetail(deleteTarget.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast({ title: `${deleteTarget.name} 삭제 완료` });
|
||||||
|
} else {
|
||||||
|
toast({ title: "삭제 실패", variant: "destructive" });
|
||||||
|
}
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}, [deleteTarget, deleteVersion, deleteDetail, toast]);
|
||||||
|
|
||||||
|
const splitRatio = config.splitRatio || 40;
|
||||||
|
|
||||||
|
if (isPreview) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/10 p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<ListOrdered className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
품목별 라우팅 관리
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||||
|
품목 선택 - 라우팅 버전 - 공정 순서
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-background">
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* 좌측 패널: 품목 목록 */}
|
||||||
|
<div
|
||||||
|
style={{ width: `${splitRatio}%` }}
|
||||||
|
className="flex shrink-0 flex-col overflow-hidden border-r"
|
||||||
|
>
|
||||||
|
<div className="border-b px-3 py-2">
|
||||||
|
<h3 className="text-sm font-semibold">
|
||||||
|
{config.leftPanelTitle || "품목 목록"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex gap-1.5 border-b px-3 py-2">
|
||||||
|
<Input
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
placeholder="품목명/품번 검색"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" onClick={handleSearch}>
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 리스트 */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{loading ? "로딩 중..." : "품목이 없습니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{items.map((item) => {
|
||||||
|
const itemCode =
|
||||||
|
item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number;
|
||||||
|
const itemName =
|
||||||
|
item[config.dataSource.itemNameColumn] || item.item_name;
|
||||||
|
const isSelected = selectedItemCode === itemCode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted/50",
|
||||||
|
isSelected && "bg-primary/10 font-medium"
|
||||||
|
)}
|
||||||
|
onClick={() => selectItem(itemCode, itemName)}
|
||||||
|
>
|
||||||
|
<Package className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{itemName}</p>
|
||||||
|
<p className="truncate text-muted-foreground">{itemCode}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 패널: 버전 + 공정 */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{selectedItemCode ? (
|
||||||
|
<>
|
||||||
|
{/* 헤더: 선택된 품목 + 버전 추가 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">{selectedItemName}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{selectedItemCode}</p>
|
||||||
|
</div>
|
||||||
|
{!config.readonly && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-xs"
|
||||||
|
onClick={handleAddVersion}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
{config.versionAddButtonText || "+ 라우팅 버전 추가"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버전 선택 버튼들 */}
|
||||||
|
{versions.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 border-b px-4 py-2">
|
||||||
|
<span className="mr-1 text-xs text-muted-foreground">버전:</span>
|
||||||
|
{versions.map((ver) => {
|
||||||
|
const isActive = selectedVersionId === ver.id;
|
||||||
|
const isDefault = ver.is_default === true;
|
||||||
|
return (
|
||||||
|
<div key={ver.id} className="flex items-center gap-0.5">
|
||||||
|
<Badge
|
||||||
|
variant={isActive ? "default" : "outline"}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
|
||||||
|
isActive && "bg-primary text-primary-foreground",
|
||||||
|
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700"
|
||||||
|
)}
|
||||||
|
onClick={() => selectVersion(ver.id)}
|
||||||
|
>
|
||||||
|
{isDefault && <Star className="mr-1 h-3 w-3 fill-current" />}
|
||||||
|
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
|
||||||
|
</Badge>
|
||||||
|
{!config.readonly && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-5",
|
||||||
|
isDefault
|
||||||
|
? "text-amber-500 hover:text-amber-600"
|
||||||
|
: "text-muted-foreground hover:text-amber-500"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleDefault(ver.id, isDefault);
|
||||||
|
}}
|
||||||
|
title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}
|
||||||
|
>
|
||||||
|
<Star className={cn("h-3 w-3", isDefault && "fill-current")} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteTarget({
|
||||||
|
type: "version",
|
||||||
|
id: ver.id,
|
||||||
|
name: ver.version_name || ver.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border-b px-4 py-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
라우팅 버전이 없습니다. 버전을 추가해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 공정 테이블 */}
|
||||||
|
{selectedVersionId ? (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* 공정 테이블 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground">
|
||||||
|
{config.rightPanelTitle || "공정 순서"} ({details.length}건)
|
||||||
|
</h4>
|
||||||
|
{!config.readonly && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-xs"
|
||||||
|
onClick={handleAddProcess}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
{config.processAddButtonText || "+ 공정 추가"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto px-4 pb-4">
|
||||||
|
{details.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{loading ? "로딩 중..." : "등록된 공정이 없습니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{config.processColumns.map((col) => (
|
||||||
|
<TableHead
|
||||||
|
key={col.name}
|
||||||
|
style={{ width: col.width ? `${col.width}px` : undefined }}
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
col.align === "center" && "text-center",
|
||||||
|
col.align === "right" && "text-right"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
{!config.readonly && (
|
||||||
|
<TableHead className="w-[80px] text-center text-xs">
|
||||||
|
관리
|
||||||
|
</TableHead>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{details.map((detail) => (
|
||||||
|
<TableRow key={detail.id}>
|
||||||
|
{config.processColumns.map((col) => {
|
||||||
|
let cellValue = detail[col.name];
|
||||||
|
if (cellValue == null) {
|
||||||
|
const aliasKey = Object.keys(detail).find(
|
||||||
|
(k) => k.endsWith(`_${col.name}`)
|
||||||
|
);
|
||||||
|
if (aliasKey) cellValue = detail[aliasKey];
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={col.name}
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
col.align === "center" && "text-center",
|
||||||
|
col.align === "right" && "text-right"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cellValue ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!config.readonly && (
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => handleEditProcess(detail)}
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setDeleteTarget({
|
||||||
|
type: "detail",
|
||||||
|
id: detail.id,
|
||||||
|
name: `공정 ${detail.seq_no || detail.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
versions.length > 0 && (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
라우팅 버전을 선택해주세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
||||||
|
<ListOrdered className="mb-3 h-12 w-12 text-muted-foreground/30" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
좌측에서 품목을 선택하세요
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||||
|
품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-base">삭제 확인</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-sm">
|
||||||
|
{deleteTarget?.name}을(를) 삭제하시겠습니까?
|
||||||
|
{deleteTarget?.type === "version" && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,780 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Plus, Trash2, Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ItemRoutingConfig, ProcessColumnDef } from "./types";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
displayName?: string;
|
||||||
|
dataType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScreenInfo {
|
||||||
|
screenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 셀렉터 Combobox
|
||||||
|
function TableSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tables,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
tables: TableInfo[];
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selected = tables.find((t) => t.tableName === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "로딩 중..."
|
||||||
|
: selected
|
||||||
|
? selected.displayName || selected.tableName
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[280px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{tables.map((t) => (
|
||||||
|
<CommandItem
|
||||||
|
key={t.tableName}
|
||||||
|
value={`${t.displayName || ""} ${t.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(t.tableName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
value === t.tableName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t.displayName || t.tableName}
|
||||||
|
</span>
|
||||||
|
{t.displayName && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{t.tableName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 셀렉터 Combobox
|
||||||
|
function ColumnSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tableName,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
tableName: string;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tableName) {
|
||||||
|
setColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import(
|
||||||
|
"@/lib/api/tableManagement"
|
||||||
|
);
|
||||||
|
const res = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (res.success && res.data?.columns) {
|
||||||
|
setColumns(res.data.columns);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [tableName]);
|
||||||
|
|
||||||
|
const selected = columns.find((c) => c.columnName === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading || !tableName}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "로딩..."
|
||||||
|
: !tableName
|
||||||
|
? "테이블 먼저 선택"
|
||||||
|
: selected
|
||||||
|
? selected.displayName || selected.columnName
|
||||||
|
: label || "컬럼 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[260px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs">
|
||||||
|
컬럼을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<CommandItem
|
||||||
|
key={c.columnName}
|
||||||
|
value={`${c.displayName || ""} ${c.columnName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(c.columnName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
value === c.columnName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</span>
|
||||||
|
{c.displayName && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{c.columnName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화면 셀렉터 Combobox
|
||||||
|
function ScreenSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value?: number;
|
||||||
|
onChange: (v?: number) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [screens, setScreens] = useState<ScreenInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { screenApi } = await import("@/lib/api/screen");
|
||||||
|
const res = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||||
|
setScreens(
|
||||||
|
res.data.map((s: any) => ({
|
||||||
|
screenId: s.screenId,
|
||||||
|
screenName: s.screenName,
|
||||||
|
screenCode: s.screenCode,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selected = screens.find((s) => s.screenId === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "로딩 중..."
|
||||||
|
: selected
|
||||||
|
? `${selected.screenName} (${selected.screenId})`
|
||||||
|
: "화면 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[350px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs">
|
||||||
|
화면을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[300px] overflow-auto">
|
||||||
|
{screens.map((s) => (
|
||||||
|
<CommandItem
|
||||||
|
key={s.screenId}
|
||||||
|
value={`${s.screenName.toLowerCase()} ${s.screenCode.toLowerCase()} ${s.screenId}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(s.screenId === value ? undefined : s.screenId);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
value === s.screenId ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{s.screenName}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{s.screenCode} (ID: {s.screenId})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공정 테이블 컬럼 셀렉터 (routingDetailTable의 컬럼 목록에서 선택)
|
||||||
|
function ProcessColumnSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tableName,
|
||||||
|
processTable,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
tableName: string;
|
||||||
|
processTable: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAll = async () => {
|
||||||
|
if (!tableName) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import(
|
||||||
|
"@/lib/api/tableManagement"
|
||||||
|
);
|
||||||
|
const res = await tableManagementApi.getColumnList(tableName);
|
||||||
|
const cols: ColumnInfo[] = [];
|
||||||
|
if (res.success && res.data?.columns) {
|
||||||
|
cols.push(...res.data.columns);
|
||||||
|
}
|
||||||
|
if (processTable && processTable !== tableName) {
|
||||||
|
const res2 = await tableManagementApi.getColumnList(processTable);
|
||||||
|
if (res2.success && res2.data?.columns) {
|
||||||
|
cols.push(
|
||||||
|
...res2.data.columns.map((c: any) => ({
|
||||||
|
...c,
|
||||||
|
columnName: c.columnName,
|
||||||
|
displayName: `[${processTable}] ${c.displayName || c.columnName}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setColumns(cols);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAll();
|
||||||
|
}, [tableName, processTable]);
|
||||||
|
|
||||||
|
const selected = columns.find((c) => c.columnName === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-7 w-24 justify-between text-[10px]"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{selected ? selected.displayName || selected.columnName : value || "선택"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-2.5 w-2.5 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[250px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-3 text-center text-xs">
|
||||||
|
없음
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<CommandItem
|
||||||
|
key={c.columnName}
|
||||||
|
value={`${c.displayName || ""} ${c.columnName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(c.columnName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-1 h-3 w-3",
|
||||||
|
value === c.columnName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigPanelProps {
|
||||||
|
config: Partial<ItemRoutingConfig>;
|
||||||
|
onChange: (config: Partial<ItemRoutingConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemRoutingConfigPanel({
|
||||||
|
config: configProp,
|
||||||
|
onChange,
|
||||||
|
}: ConfigPanelProps) {
|
||||||
|
const config: ItemRoutingConfig = {
|
||||||
|
...defaultConfig,
|
||||||
|
...configProp,
|
||||||
|
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
||||||
|
modals: { ...defaultConfig.modals, ...configProp?.modals },
|
||||||
|
processColumns: configProp?.processColumns?.length
|
||||||
|
? configProp.processColumns
|
||||||
|
: defaultConfig.processColumns,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [allTables, setAllTables] = useState<TableInfo[]>([]);
|
||||||
|
const [tablesLoading, setTablesLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setTablesLoading(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import(
|
||||||
|
"@/lib/api/tableManagement"
|
||||||
|
);
|
||||||
|
const res = await tableManagementApi.getTableList();
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setAllTables(res.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setTablesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const update = (partial: Partial<ItemRoutingConfig>) => {
|
||||||
|
onChange({ ...configProp, ...partial });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDataSource = (field: string, value: string) => {
|
||||||
|
update({ dataSource: { ...config.dataSource, [field]: value } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateModals = (field: string, value: number | undefined) => {
|
||||||
|
update({ modals: { ...config.modals, [field]: value } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 관리
|
||||||
|
const addColumn = () => {
|
||||||
|
update({
|
||||||
|
processColumns: [
|
||||||
|
...config.processColumns,
|
||||||
|
{ name: "", label: "새 컬럼", width: 100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeColumn = (idx: number) => {
|
||||||
|
update({
|
||||||
|
processColumns: config.processColumns.filter((_, i) => i !== idx),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateColumn = (
|
||||||
|
idx: number,
|
||||||
|
field: keyof ProcessColumnDef,
|
||||||
|
value: any
|
||||||
|
) => {
|
||||||
|
const next = [...config.processColumns];
|
||||||
|
next[idx] = { ...next[idx], [field]: value };
|
||||||
|
update({ processColumns: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 p-4">
|
||||||
|
<h3 className="text-sm font-semibold">품목별 라우팅 설정</h3>
|
||||||
|
|
||||||
|
{/* 데이터 소스 설정 */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
데이터 소스
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">품목 테이블</Label>
|
||||||
|
<TableSelector
|
||||||
|
value={config.dataSource.itemTable}
|
||||||
|
onChange={(v) => updateDataSource("itemTable", v)}
|
||||||
|
tables={allTables}
|
||||||
|
loading={tablesLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">품목명 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.itemNameColumn}
|
||||||
|
onChange={(v) => updateDataSource("itemNameColumn", v)}
|
||||||
|
tableName={config.dataSource.itemTable}
|
||||||
|
label="품목명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">품목코드 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.itemCodeColumn}
|
||||||
|
onChange={(v) => updateDataSource("itemCodeColumn", v)}
|
||||||
|
tableName={config.dataSource.itemTable}
|
||||||
|
label="품목코드"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">라우팅 버전 테이블</Label>
|
||||||
|
<TableSelector
|
||||||
|
value={config.dataSource.routingVersionTable}
|
||||||
|
onChange={(v) => updateDataSource("routingVersionTable", v)}
|
||||||
|
tables={allTables}
|
||||||
|
loading={tablesLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버전 FK 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.routingVersionFkColumn}
|
||||||
|
onChange={(v) => updateDataSource("routingVersionFkColumn", v)}
|
||||||
|
tableName={config.dataSource.routingVersionTable}
|
||||||
|
label="FK 컬럼"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버전명 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.routingVersionNameColumn}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateDataSource("routingVersionNameColumn", v)
|
||||||
|
}
|
||||||
|
tableName={config.dataSource.routingVersionTable}
|
||||||
|
label="버전명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 상세 테이블</Label>
|
||||||
|
<TableSelector
|
||||||
|
value={config.dataSource.routingDetailTable}
|
||||||
|
onChange={(v) => updateDataSource("routingDetailTable", v)}
|
||||||
|
tables={allTables}
|
||||||
|
loading={tablesLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 상세 FK 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.routingDetailFkColumn}
|
||||||
|
onChange={(v) => updateDataSource("routingDetailFkColumn", v)}
|
||||||
|
tableName={config.dataSource.routingDetailTable}
|
||||||
|
label="FK 컬럼"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 마스터 테이블</Label>
|
||||||
|
<TableSelector
|
||||||
|
value={config.dataSource.processTable}
|
||||||
|
onChange={(v) => updateDataSource("processTable", v)}
|
||||||
|
tables={allTables}
|
||||||
|
loading={tablesLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정명 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.processNameColumn}
|
||||||
|
onChange={(v) => updateDataSource("processNameColumn", v)}
|
||||||
|
tableName={config.dataSource.processTable}
|
||||||
|
label="공정명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정코드 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.processCodeColumn}
|
||||||
|
onChange={(v) => updateDataSource("processCodeColumn", v)}
|
||||||
|
tableName={config.dataSource.processTable}
|
||||||
|
label="공정코드"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 모달 설정 */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">모달 연동</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버전 추가 모달</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.modals.versionAddScreenId}
|
||||||
|
onChange={(v) => updateModals("versionAddScreenId", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 추가 모달</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.modals.processAddScreenId}
|
||||||
|
onChange={(v) => updateModals("processAddScreenId", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 수정 모달</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.modals.processEditScreenId}
|
||||||
|
onChange={(v) => updateModals("processEditScreenId", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 공정 테이블 컬럼 설정 */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
공정 테이블 컬럼
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 gap-1 text-[10px]"
|
||||||
|
onClick={addColumn}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
컬럼 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{config.processColumns.map((col, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center gap-1.5 rounded border bg-muted/30 p-1.5"
|
||||||
|
>
|
||||||
|
<ProcessColumnSelector
|
||||||
|
value={col.name}
|
||||||
|
onChange={(v) => updateColumn(idx, "name", v)}
|
||||||
|
tableName={config.dataSource.routingDetailTable}
|
||||||
|
processTable={config.dataSource.processTable}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={col.label}
|
||||||
|
onChange={(e) => updateColumn(idx, "label", e.target.value)}
|
||||||
|
className="h-7 flex-1 text-[10px]"
|
||||||
|
placeholder="표시명"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={col.width || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateColumn(
|
||||||
|
idx,
|
||||||
|
"width",
|
||||||
|
e.target.value ? Number(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-7 w-14 text-[10px]"
|
||||||
|
placeholder="너비"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removeColumn(idx)}
|
||||||
|
disabled={config.processColumns.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* UI 설정 */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">UI 설정</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">좌우 분할 비율 (%)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.splitRatio || 40}
|
||||||
|
onChange={(e) => update({ splitRatio: Number(e.target.value) })}
|
||||||
|
min={20}
|
||||||
|
max={60}
|
||||||
|
className="mt-1 h-8 w-20 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">좌측 패널 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={config.leftPanelTitle || ""}
|
||||||
|
onChange={(e) => update({ leftPanelTitle: e.target.value })}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">우측 패널 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={config.rightPanelTitle || ""}
|
||||||
|
onChange={(e) => update({ rightPanelTitle: e.target.value })}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버전 추가 버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
value={config.versionAddButtonText || ""}
|
||||||
|
onChange={(e) => update({ versionAddButtonText: e.target.value })}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 추가 버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
value={config.processAddButtonText || ""}
|
||||||
|
onChange={(e) => update({ processAddButtonText: e.target.value })}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.autoSelectFirstVersion ?? true}
|
||||||
|
onCheckedChange={(v) => update({ autoSelectFirstVersion: v })}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">첫 번째 버전 자동 선택</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.readonly || false}
|
||||||
|
onCheckedChange={(v) => update({ readonly: v })}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">읽기 전용 모드</Label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
|
import { V2ItemRoutingDefinition } from "./index";
|
||||||
|
import { ItemRoutingComponent } from "./ItemRoutingComponent";
|
||||||
|
|
||||||
|
export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = V2ItemRoutingDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
const { formData, isPreview, config, tableName } = this.props as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemRoutingComponent
|
||||||
|
config={(config as object) || {}}
|
||||||
|
formData={formData as Record<string, unknown>}
|
||||||
|
tableName={tableName as string}
|
||||||
|
isPreview={isPreview as boolean}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemRoutingRenderer.registerSelf();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
ItemRoutingRenderer.enableHotReload();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { ItemRoutingConfig } from "./types";
|
||||||
|
|
||||||
|
export const defaultConfig: ItemRoutingConfig = {
|
||||||
|
dataSource: {
|
||||||
|
itemTable: "item_info",
|
||||||
|
itemNameColumn: "item_name",
|
||||||
|
itemCodeColumn: "item_number",
|
||||||
|
routingVersionTable: "item_routing_version",
|
||||||
|
routingVersionFkColumn: "item_code",
|
||||||
|
routingVersionNameColumn: "version_name",
|
||||||
|
routingDetailTable: "item_routing_detail",
|
||||||
|
routingDetailFkColumn: "routing_version_id",
|
||||||
|
processTable: "process_mng",
|
||||||
|
processNameColumn: "process_name",
|
||||||
|
processCodeColumn: "process_code",
|
||||||
|
},
|
||||||
|
modals: {
|
||||||
|
versionAddScreenId: 1613,
|
||||||
|
processAddScreenId: 1614,
|
||||||
|
processEditScreenId: 1615,
|
||||||
|
},
|
||||||
|
processColumns: [
|
||||||
|
{ name: "seq_no", label: "순서", width: 60, align: "center" },
|
||||||
|
{ name: "process_code", label: "공정코드", width: 120 },
|
||||||
|
{ name: "work_type", label: "작업유형", width: 100 },
|
||||||
|
{ name: "standard_time", label: "표준시간(분)", width: 100, align: "right" },
|
||||||
|
{ name: "is_required", label: "필수여부", width: 80, align: "center" },
|
||||||
|
{ name: "is_fixed_order", label: "순서고정", width: 80, align: "center" },
|
||||||
|
{ name: "outsource_supplier", label: "외주업체", width: 120 },
|
||||||
|
],
|
||||||
|
splitRatio: 40,
|
||||||
|
leftPanelTitle: "품목 목록",
|
||||||
|
rightPanelTitle: "공정 순서",
|
||||||
|
readonly: false,
|
||||||
|
autoSelectFirstVersion: true,
|
||||||
|
versionAddButtonText: "+ 라우팅 버전 추가",
|
||||||
|
processAddButtonText: "+ 공정 추가",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData } from "../types";
|
||||||
|
import { defaultConfig } from "../config";
|
||||||
|
|
||||||
|
const API_BASE = "/process-work-standard";
|
||||||
|
|
||||||
|
export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
|
const configKey = useMemo(
|
||||||
|
() => JSON.stringify(configPartial),
|
||||||
|
[configPartial]
|
||||||
|
);
|
||||||
|
|
||||||
|
const config: ItemRoutingConfig = useMemo(() => ({
|
||||||
|
...defaultConfig,
|
||||||
|
...configPartial,
|
||||||
|
dataSource: { ...defaultConfig.dataSource, ...configPartial?.dataSource },
|
||||||
|
modals: { ...defaultConfig.modals, ...configPartial?.modals },
|
||||||
|
processColumns: configPartial?.processColumns?.length
|
||||||
|
? configPartial.processColumns
|
||||||
|
: defaultConfig.processColumns,
|
||||||
|
}), [configKey]);
|
||||||
|
|
||||||
|
const configRef = useRef(config);
|
||||||
|
configRef.current = config;
|
||||||
|
|
||||||
|
const [items, setItems] = useState<ItemData[]>([]);
|
||||||
|
const [versions, setVersions] = useState<RoutingVersionData[]>([]);
|
||||||
|
const [details, setDetails] = useState<RoutingDetailData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 선택 상태
|
||||||
|
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
|
||||||
|
const [selectedItemName, setSelectedItemName] = useState<string | null>(null);
|
||||||
|
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 품목 목록 조회
|
||||||
|
const fetchItems = useCallback(
|
||||||
|
async (search?: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
tableName: ds.itemTable,
|
||||||
|
nameColumn: ds.itemNameColumn,
|
||||||
|
codeColumn: ds.itemCodeColumn,
|
||||||
|
routingTable: ds.routingVersionTable,
|
||||||
|
routingFkColumn: ds.routingVersionFkColumn,
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
});
|
||||||
|
const res = await apiClient.get(`${API_BASE}/items?${params}`);
|
||||||
|
if (res.data?.success) {
|
||||||
|
setItems(res.data.data || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("품목 조회 실패", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[configKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 라우팅 버전 목록 조회
|
||||||
|
const fetchVersions = useCallback(
|
||||||
|
async (itemCode: string) => {
|
||||||
|
try {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
routingVersionTable: ds.routingVersionTable,
|
||||||
|
routingDetailTable: ds.routingDetailTable,
|
||||||
|
routingFkColumn: ds.routingVersionFkColumn,
|
||||||
|
processTable: ds.processTable,
|
||||||
|
processNameColumn: ds.processNameColumn,
|
||||||
|
processCodeColumn: ds.processCodeColumn,
|
||||||
|
});
|
||||||
|
const res = await apiClient.get(
|
||||||
|
`${API_BASE}/items/${encodeURIComponent(itemCode)}/routings?${params}`
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
const routingData = res.data.data || [];
|
||||||
|
setVersions(routingData);
|
||||||
|
return routingData;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("라우팅 버전 조회 실패", err);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[configKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함
|
||||||
|
const fetchDetails = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const searchConditions = {
|
||||||
|
[ds.routingDetailFkColumn]: { value: versionId, operator: "equals" },
|
||||||
|
};
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: "1",
|
||||||
|
size: "1000",
|
||||||
|
search: JSON.stringify(searchConditions),
|
||||||
|
sortBy: "seq_no",
|
||||||
|
sortOrder: "ASC",
|
||||||
|
enableEntityJoin: "true",
|
||||||
|
});
|
||||||
|
const res = await apiClient.get(
|
||||||
|
`/table-management/tables/${ds.routingDetailTable}/data-with-joins?${params}`
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
const result = res.data.data;
|
||||||
|
setDetails(Array.isArray(result) ? result : result?.data || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("공정 상세 조회 실패", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[configKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 품목 선택
|
||||||
|
const selectItem = useCallback(
|
||||||
|
async (itemCode: string, itemName: string) => {
|
||||||
|
setSelectedItemCode(itemCode);
|
||||||
|
setSelectedItemName(itemName);
|
||||||
|
setSelectedVersionId(null);
|
||||||
|
setDetails([]);
|
||||||
|
|
||||||
|
const versionList = await fetchVersions(itemCode);
|
||||||
|
|
||||||
|
if (versionList.length > 0) {
|
||||||
|
// 기본 버전 우선, 없으면 첫번째 버전 선택
|
||||||
|
const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default);
|
||||||
|
const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null);
|
||||||
|
if (targetVersion) {
|
||||||
|
setSelectedVersionId(targetVersion.id);
|
||||||
|
await fetchDetails(targetVersion.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchVersions, fetchDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 버전 선택
|
||||||
|
const selectVersion = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
setSelectedVersionId(versionId);
|
||||||
|
await fetchDetails(versionId);
|
||||||
|
},
|
||||||
|
[fetchDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달에서 데이터 변경 후 새로고침
|
||||||
|
const refreshVersions = useCallback(async () => {
|
||||||
|
if (selectedItemCode) {
|
||||||
|
const versionList = await fetchVersions(selectedItemCode);
|
||||||
|
if (selectedVersionId) {
|
||||||
|
await fetchDetails(selectedVersionId);
|
||||||
|
} else if (versionList.length > 0) {
|
||||||
|
const lastVersion = versionList[versionList.length - 1];
|
||||||
|
setSelectedVersionId(lastVersion.id);
|
||||||
|
await fetchDetails(lastVersion.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedItemCode, selectedVersionId, fetchVersions, fetchDetails]);
|
||||||
|
|
||||||
|
const refreshDetails = useCallback(async () => {
|
||||||
|
if (selectedVersionId) {
|
||||||
|
await fetchDetails(selectedVersionId);
|
||||||
|
}
|
||||||
|
}, [selectedVersionId, fetchDetails]);
|
||||||
|
|
||||||
|
// 공정 삭제
|
||||||
|
const deleteDetail = useCallback(
|
||||||
|
async (detailId: string) => {
|
||||||
|
try {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const res = await apiClient.delete(
|
||||||
|
`/table-management/tables/${ds.routingDetailTable}/delete`,
|
||||||
|
{ data: [{ id: detailId }] }
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
await refreshDetails();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("공정 삭제 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[refreshDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 버전 삭제
|
||||||
|
const deleteVersion = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const res = await apiClient.delete(
|
||||||
|
`/table-management/tables/${ds.routingVersionTable}/delete`,
|
||||||
|
{ data: [{ id: versionId }] }
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
if (selectedVersionId === versionId) {
|
||||||
|
setSelectedVersionId(null);
|
||||||
|
setDetails([]);
|
||||||
|
}
|
||||||
|
await refreshVersions();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("버전 삭제 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[selectedVersionId, refreshVersions]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기본 버전 설정
|
||||||
|
const setDefaultVersion = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const res = await apiClient.put(`${API_BASE}/versions/${versionId}/set-default`, {
|
||||||
|
routingVersionTable: ds.routingVersionTable,
|
||||||
|
routingFkColumn: ds.routingVersionFkColumn,
|
||||||
|
});
|
||||||
|
if (res.data?.success) {
|
||||||
|
if (selectedItemCode) {
|
||||||
|
await fetchVersions(selectedItemCode);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("기본 버전 설정 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[selectedItemCode, fetchVersions]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기본 버전 해제
|
||||||
|
const unsetDefaultVersion = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const res = await apiClient.put(`${API_BASE}/versions/${versionId}/unset-default`, {
|
||||||
|
routingVersionTable: ds.routingVersionTable,
|
||||||
|
});
|
||||||
|
if (res.data?.success) {
|
||||||
|
if (selectedItemCode) {
|
||||||
|
await fetchVersions(selectedItemCode);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("기본 버전 해제 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[selectedItemCode, fetchVersions]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
items,
|
||||||
|
versions,
|
||||||
|
details,
|
||||||
|
loading,
|
||||||
|
selectedItemCode,
|
||||||
|
selectedItemName,
|
||||||
|
selectedVersionId,
|
||||||
|
fetchItems,
|
||||||
|
selectItem,
|
||||||
|
selectVersion,
|
||||||
|
refreshVersions,
|
||||||
|
refreshDetails,
|
||||||
|
deleteDetail,
|
||||||
|
deleteVersion,
|
||||||
|
setDefaultVersion,
|
||||||
|
unsetDefaultVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { ItemRoutingComponent } from "./ItemRoutingComponent";
|
||||||
|
import { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
|
||||||
|
export const V2ItemRoutingDefinition = createComponentDefinition({
|
||||||
|
id: "v2-item-routing",
|
||||||
|
name: "품목별 라우팅",
|
||||||
|
nameEng: "Item Routing",
|
||||||
|
description: "품목별 라우팅 버전과 공정 순서를 관리하는 3단계 계층 컴포넌트",
|
||||||
|
category: ComponentCategory.INPUT,
|
||||||
|
webType: "component",
|
||||||
|
component: ItemRoutingComponent,
|
||||||
|
defaultConfig: defaultConfig,
|
||||||
|
defaultSize: {
|
||||||
|
width: 1400,
|
||||||
|
height: 800,
|
||||||
|
gridColumnSpan: "12",
|
||||||
|
},
|
||||||
|
configPanel: ItemRoutingConfigPanel,
|
||||||
|
icon: "ListOrdered",
|
||||||
|
tags: ["라우팅", "공정", "품목", "버전", "제조", "생산"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
documentation: `
|
||||||
|
품목별 라우팅 버전과 공정 순서를 관리하는 전용 컴포넌트입니다.
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
- 좌측: 품목 목록 검색 및 선택
|
||||||
|
- 우측 상단: 라우팅 버전 선택 (Badge 버튼) 및 추가/삭제
|
||||||
|
- 우측 하단: 선택된 버전의 공정 순서 테이블 (추가/수정/삭제)
|
||||||
|
- 기존 모달 화면 재활용 (1613, 1614, 1615)
|
||||||
|
|
||||||
|
## 커스터마이징
|
||||||
|
- 데이터 소스 테이블/컬럼 변경 가능
|
||||||
|
- 모달 화면 ID 변경 가능
|
||||||
|
- 공정 테이블 컬럼 추가/삭제 가능
|
||||||
|
- 좌우 분할 비율, 패널 제목, 버튼 텍스트 변경 가능
|
||||||
|
- 읽기 전용 모드 지원
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ItemRoutingConfig,
|
||||||
|
ItemRoutingComponentProps,
|
||||||
|
ItemRoutingDataSource,
|
||||||
|
ItemRoutingModals,
|
||||||
|
ProcessColumnDef,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export { ItemRoutingComponent } from "./ItemRoutingComponent";
|
||||||
|
export { ItemRoutingRenderer } from "./ItemRoutingRenderer";
|
||||||
|
export { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel";
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* 품목별 라우팅 관리 컴포넌트 타입 정의
|
||||||
|
*
|
||||||
|
* 3단계 계층: item_info → item_routing_version → item_routing_detail
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 데이터 소스 설정
|
||||||
|
export interface ItemRoutingDataSource {
|
||||||
|
itemTable: string;
|
||||||
|
itemNameColumn: string;
|
||||||
|
itemCodeColumn: string;
|
||||||
|
routingVersionTable: string;
|
||||||
|
routingVersionFkColumn: string; // item_routing_version에서 item_code를 가리키는 FK
|
||||||
|
routingVersionNameColumn: string;
|
||||||
|
routingDetailTable: string;
|
||||||
|
routingDetailFkColumn: string; // item_routing_detail에서 routing_version_id를 가리키는 FK
|
||||||
|
processTable: string;
|
||||||
|
processNameColumn: string;
|
||||||
|
processCodeColumn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 연동 설정
|
||||||
|
export interface ItemRoutingModals {
|
||||||
|
versionAddScreenId?: number;
|
||||||
|
processAddScreenId?: number;
|
||||||
|
processEditScreenId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공정 테이블 컬럼 정의
|
||||||
|
export interface ProcessColumnDef {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
width?: number;
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 Config
|
||||||
|
export interface ItemRoutingConfig {
|
||||||
|
dataSource: ItemRoutingDataSource;
|
||||||
|
modals: ItemRoutingModals;
|
||||||
|
processColumns: ProcessColumnDef[];
|
||||||
|
splitRatio?: number;
|
||||||
|
leftPanelTitle?: string;
|
||||||
|
rightPanelTitle?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
autoSelectFirstVersion?: boolean;
|
||||||
|
versionAddButtonText?: string;
|
||||||
|
processAddButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 Props
|
||||||
|
export interface ItemRoutingComponentProps {
|
||||||
|
config: Partial<ItemRoutingConfig>;
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
isPreview?: boolean;
|
||||||
|
tableName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 모델
|
||||||
|
export interface ItemData {
|
||||||
|
id: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingVersionData {
|
||||||
|
id: string;
|
||||||
|
version_name: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingDetailData {
|
||||||
|
id: string;
|
||||||
|
routing_version_id: string;
|
||||||
|
seq_no: string;
|
||||||
|
process_code: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useCallback } from "react";
|
||||||
|
import { Save, Loader2, ClipboardCheck } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ProcessWorkStandardConfig, WorkItem } from "./types";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
import { useProcessWorkStandard } from "./hooks/useProcessWorkStandard";
|
||||||
|
import { ItemProcessSelector } from "./components/ItemProcessSelector";
|
||||||
|
import { WorkPhaseSection } from "./components/WorkPhaseSection";
|
||||||
|
import { WorkItemAddModal } from "./components/WorkItemAddModal";
|
||||||
|
|
||||||
|
interface ProcessWorkStandardComponentProps {
|
||||||
|
config?: Partial<ProcessWorkStandardConfig>;
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
isPreview?: boolean;
|
||||||
|
tableName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProcessWorkStandardComponent({
|
||||||
|
config: configProp,
|
||||||
|
isPreview,
|
||||||
|
}: ProcessWorkStandardComponentProps) {
|
||||||
|
const config: ProcessWorkStandardConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
...defaultConfig,
|
||||||
|
...configProp,
|
||||||
|
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
||||||
|
phases: configProp?.phases?.length
|
||||||
|
? configProp.phases
|
||||||
|
: defaultConfig.phases,
|
||||||
|
detailTypes: configProp?.detailTypes?.length
|
||||||
|
? configProp.detailTypes
|
||||||
|
: defaultConfig.detailTypes,
|
||||||
|
}),
|
||||||
|
[configProp]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
routings,
|
||||||
|
workItems,
|
||||||
|
selectedWorkItemIdByPhase,
|
||||||
|
selectedDetailsByPhase,
|
||||||
|
selection,
|
||||||
|
loading,
|
||||||
|
fetchItems,
|
||||||
|
selectItem,
|
||||||
|
selectProcess,
|
||||||
|
fetchWorkItemDetails,
|
||||||
|
createWorkItem,
|
||||||
|
updateWorkItem,
|
||||||
|
deleteWorkItem,
|
||||||
|
createDetail,
|
||||||
|
updateDetail,
|
||||||
|
deleteDetail,
|
||||||
|
} = useProcessWorkStandard(config);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [modalPhaseKey, setModalPhaseKey] = useState("");
|
||||||
|
const [editingItem, setEditingItem] = useState<WorkItem | null>(null);
|
||||||
|
|
||||||
|
// phase별 작업 항목 그룹핑
|
||||||
|
const workItemsByPhase = useMemo(() => {
|
||||||
|
const map: Record<string, WorkItem[]> = {};
|
||||||
|
for (const phase of config.phases) {
|
||||||
|
map[phase.key] = workItems.filter((wi) => wi.work_phase === phase.key);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [workItems, config.phases]);
|
||||||
|
|
||||||
|
const sortedPhases = useMemo(
|
||||||
|
() => [...config.phases].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||||
|
[config.phases]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddWorkItem = useCallback((phaseKey: string) => {
|
||||||
|
setModalPhaseKey(phaseKey);
|
||||||
|
setEditingItem(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditWorkItem = useCallback((item: WorkItem) => {
|
||||||
|
setModalPhaseKey(item.work_phase);
|
||||||
|
setEditingItem(item);
|
||||||
|
setModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleModalSave = useCallback(
|
||||||
|
async (data: Parameters<typeof createWorkItem>[0]) => {
|
||||||
|
if (editingItem) {
|
||||||
|
await updateWorkItem(editingItem.id, {
|
||||||
|
title: data.title,
|
||||||
|
is_required: data.is_required,
|
||||||
|
description: data.description,
|
||||||
|
} as any);
|
||||||
|
} else {
|
||||||
|
await createWorkItem(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editingItem, createWorkItem, updateWorkItem]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectWorkItem = useCallback(
|
||||||
|
(workItemId: string, phaseKey: string) => {
|
||||||
|
fetchWorkItemDetails(workItemId, phaseKey);
|
||||||
|
},
|
||||||
|
[fetchWorkItemDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInit = useCallback(() => {
|
||||||
|
fetchItems();
|
||||||
|
}, [fetchItems]);
|
||||||
|
|
||||||
|
const splitRatio = config.splitRatio || 30;
|
||||||
|
|
||||||
|
if (isPreview) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/10 p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<ClipboardCheck className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
공정 작업기준
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||||
|
{sortedPhases.map((p) => p.label).join(" / ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-background">
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* 좌측 패널 */}
|
||||||
|
<div style={{ width: `${splitRatio}%` }} className="shrink-0 overflow-hidden">
|
||||||
|
<ItemProcessSelector
|
||||||
|
title={config.leftPanelTitle || "품목 및 공정 선택"}
|
||||||
|
items={items}
|
||||||
|
routings={routings}
|
||||||
|
selection={selection}
|
||||||
|
onSearch={(keyword) => fetchItems(keyword)}
|
||||||
|
onSelectItem={selectItem}
|
||||||
|
onSelectProcess={selectProcess}
|
||||||
|
onInit={handleInit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 패널 */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* 우측 헤더 */}
|
||||||
|
{selection.routingDetailId ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold">
|
||||||
|
{selection.itemName} - {selection.processName}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>품목: {selection.itemCode}</span>
|
||||||
|
<span>공정: {selection.processName}</span>
|
||||||
|
<span>버전: {selection.routingVersionName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!config.readonly && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
전체 저장
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업 단계별 섹션 */}
|
||||||
|
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
|
{sortedPhases.map((phase) => (
|
||||||
|
<WorkPhaseSection
|
||||||
|
key={phase.key}
|
||||||
|
phase={phase}
|
||||||
|
items={workItemsByPhase[phase.key] || []}
|
||||||
|
selectedWorkItemId={selectedWorkItemIdByPhase[phase.key] || null}
|
||||||
|
selectedWorkItemDetails={selectedDetailsByPhase[phase.key] || []}
|
||||||
|
detailTypes={config.detailTypes}
|
||||||
|
readonly={config.readonly}
|
||||||
|
onSelectWorkItem={handleSelectWorkItem}
|
||||||
|
onAddWorkItem={handleAddWorkItem}
|
||||||
|
onEditWorkItem={handleEditWorkItem}
|
||||||
|
onDeleteWorkItem={deleteWorkItem}
|
||||||
|
onCreateDetail={createDetail}
|
||||||
|
onUpdateDetail={updateDetail}
|
||||||
|
onDeleteDetail={deleteDetail}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
||||||
|
<ClipboardCheck className="mb-3 h-12 w-12 text-muted-foreground/30" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
좌측에서 품목과 공정을 선택하세요
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||||
|
품목을 펼쳐 라우팅별 공정을 선택하면 작업기준을 관리할 수
|
||||||
|
있습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업 항목 추가/수정 모달 */}
|
||||||
|
<WorkItemAddModal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
}}
|
||||||
|
onSave={handleModalSave}
|
||||||
|
phaseKey={modalPhaseKey}
|
||||||
|
phaseLabel={
|
||||||
|
config.phases.find((p) => p.key === modalPhaseKey)?.label || ""
|
||||||
|
}
|
||||||
|
detailTypes={config.detailTypes}
|
||||||
|
editItem={editingItem}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue