Compare commits

..

79 Commits

Author SHA1 Message Date
kjs d3701cfe1e 코드 할당 요청 시 폼 데이터 추가: numberingRuleController에서 코드 할당 요청 시 폼 데이터를 포함하도록 수정하였습니다. 이를 통해 날짜 컬럼 기준 생성 시 필요한 정보를 전달할 수 있도록 개선하였습니다. 2026-01-19 18:21:30 +09:00
kjs 95da69ec70 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal 2026-01-19 17:56:33 +09:00
kjs f0cacb9401 V2 컴포넌트 추가 및 기존 컴포넌트 업데이트: getComponentConfigPanel.tsx에서 다양한 V2 컴포넌트를 추가하고, 기존 컴포넌트의 경로를 업데이트하여 관리 효율성을 높였습니다. 이를 통해 새로운 컴포넌트 사용을 지원하고, 기존 컴포넌트와의 호환성을 유지하도록 개선하였습니다. 2026-01-19 17:28:52 +09:00
kjs 0ee36d9b35 테이블 헤더 최소 너비 설정 추가: SplitPanelLayoutComponent에서 각 테이블 헤더의 최소 너비를 80px로 설정하여 레이아웃의 일관성을 높였습니다. 이를 통해 다양한 화면 크기에서도 가독성을 유지할 수 있도록 개선하였습니다. 2026-01-19 17:04:10 +09:00
kjs b6ed76f243 V2 컴포넌트로의 전환 및 기존 컴포넌트 숨김 처리: ComponentsPanel에서 기존 컴포넌트를 V2 버전으로 대체하고, 관련 컴포넌트들을 패널에서 숨김 처리하여 관리 효율성을 높였습니다. 각 컴포넌트의 정의에 'hidden' 속성을 추가하여 V2 컴포넌트 사용을 명시하였습니다. 2026-01-19 16:51:08 +09:00
kjs 7f186c509f 집계 위젯 개발 진행상황 문서 추가: 집계 위젯의 기능 및 파일 구조에 대한 상세한 설명을 포함한 문서를 새로 작성하였습니다. 완료된 기능, 미완료 기능, 사용 예시 및 디버깅 로그를 정리하여 개발자들이 집계 위젯을 이해하고 활용하는 데 도움을 주도록 하였습니다. 또한, 소스 컴포넌트 연동 및 필터링 기능에 대한 진행 상황을 명시하였습니다. 2026-01-19 16:44:42 +09:00
kjs 279ec17623 기존 컴포넌트 호환성 유지 및 V2 컴포넌트 자동 등록: 기존 화면과의 호환성을 위해 기존 컴포넌트들을 주석 처리에서 제거하고, V2 버전도 별도로 존재함을 명시하였습니다. 이를 통해 관리 효율성을 높이고, 기존 컴포넌트의 사용을 지속적으로 지원하도록 개선하였습니다. 2026-01-19 16:26:34 +09:00
kjs d69d509893 리피터 컨테이너 드롭 처리 로직 개선: ScreenDesigner 컴포넌트에서 리피터 컨테이너의 드롭 이벤트 처리 시, 지원하는 컴포넌트 타입을 "repeat-container"와 "v2-repeat-container"로 확장하였습니다. 또한, 불필요한 로그 출력을 제거하여 코드의 가독성을 향상시켰습니다. 2026-01-19 15:52:59 +09:00
kjs e785dbbe6e 집계 위젯 선택 이벤트 처리 기능 추가: AggregationWidgetComponent에서 다양한 선택 이벤트를 수신하여 필터링된 데이터를 설정하는 기능을 구현하였습니다. 또한, AggregationWidgetConfigPanel에 카테고리 값 콤보박스를 추가하여 사용자에게 더 나은 선택 경험을 제공하도록 개선하였습니다. 이로 인해 데이터 소스 타입이 "selection"인 경우의 데이터 처리 로직이 강화되었습니다. 2026-01-19 15:31:01 +09:00
kjs d45443521d 집계 위젯 필터링 기능 추가: AggregationWidgetComponent와 AggregationWidgetConfigPanel에서 필터 조건을 적용하여 데이터를 필터링할 수 있는 기능을 구현하였습니다. 필터 조건 추가, 수정, 삭제 기능을 포함하여 다양한 데이터 소스에서 필터링을 지원하도록 개선하였습니다. 또한, 필터 연산자 및 값 소스 타입에 대한 라벨을 추가하여 사용자 경험을 향상시켰습니다. 2026-01-19 15:17:28 +09:00
kjs 338f3c27fd V2 컴포넌트 규칙 추가 및 기존 컴포넌트 자동 등록 개선: 화면 컴포넌트 개발 가이드에 V2 컴포넌트 사용 규칙을 명시하고, ComponentsPanel에서 수동으로 추가하던 table-list 컴포넌트를 자동 등록으로 변경하여 관리 효율성을 높였습니다. 또한, V2 컴포넌트 목록과 수정/개발 시 규칙을 추가하여 일관된 개발 환경을 조성하였습니다. 2026-01-19 14:52:11 +09:00
kjs 901cb04a88 집계 위젯 구성 패널 개선: AggregationWidgetConfigPanel에서 컬럼 상태를 업데이트하여 inputType 및 webType을 추가하였습니다. 숫자형 컬럼 필터링 로직을 입력 타입에 기반하여 개선하여, 다양한 숫자형 데이터 타입을 지원하도록 하였습니다. 2026-01-19 14:18:23 +09:00
kjs 0658ce41f9 리피터 컨테이너 제목 및 설명 설정 기능 추가: RepeatContainerComponent와 RepeatContainerConfigPanel에서 아이템 제목과 설명을 설정할 수 있는 기능을 추가하였습니다. 제목 및 설명 컬럼을 선택할 수 있는 콤보박스를 구현하고, 각 아이템의 제목과 설명을 동적으로 표시하도록 개선하였습니다. 2026-01-19 14:01:21 +09:00
kjs 6ea3aef396 슬롯 컴포넌트 상세 설정 패널 추가: RepeatContainerConfigPanel에 슬롯 컴포넌트의 필드 바인딩, 라벨 설정, 크기 및 스타일 변경 기능을 포함한 상세 설정 패널을 구현하였습니다. 또한, 동적 컴포넌트 설정 패널을 통해 각 컴포넌트의 전용 설정을 관리할 수 있도록 개선하였습니다. 2026-01-19 09:37:02 +09:00
kjs b4bfb9964f 자동 수직 정렬 기능 추가: ScreenViewPage에서 같은 X 영역 내 컴포넌트들이 겹치지 않도록 자동으로 Y 위치를 조정하는 로직을 구현하였습니다. 또한, RepeatContainerComponent에서 고정 높이 대신 자동 높이를 사용하도록 변경하여 내부 콘텐츠가 커질 때 유연하게 대응할 수 있도록 개선하였습니다. 2026-01-16 17:20:11 +09:00
kjs 9d74baf60a 리피터 컨테이너 기능 추가: ScreenDesigner 컴포넌트에 리피터 컨테이너 내부 드롭 처리 로직을 추가하여, 드롭 시 새로운 자식 컴포넌트를 생성하고 레이아웃을 업데이트합니다. 또한, TableListComponent에서 리피터 컨테이너와 집계 위젯 연동을 위한 커스텀 이벤트를 발생시켜 데이터 변경 사항을 처리할 수 있도록 개선하였습니다. 2026-01-16 15:12:22 +09:00
kjs 28f67cb0b6 집계 위젯(aggregation-widget) 항목 라벨 추가: MultilangSettingsModal 및 multilangLabelExtractor 파일에서 집계 위젯 관련 주석을 수정하여 항목 라벨을 명확히 하였습니다. 코드 가독성을 향상시키고, 다국어 지원을 위한 라벨 수집 및 매핑 로직을 개선하였습니다. 2026-01-16 14:05:47 +09:00
kjs 3ce7691b0c 코드 정리 및 변수 이름 변경: InteractiveScreenViewer 컴포넌트에서 변수 이름을 변경하여 가독성을 향상시키고, ButtonPrimaryComponent에서 선택된 데이터 변수를 업데이트했습니다. 또한, ScreenViewPage에서 불필요한 코드 충돌을 해결했습니다. 2026-01-16 12:36:43 +09:00
kjs 7ea49cfc9e 코드 정리 및 불필요한 공백 제거: 여러 파일에서 불필요한 공백을 제거하고, 코드 가독성을 향상시켰습니다. 또한, 다국어 지원 및 테이블 설정 현황 문서에 새로운 내용을 추가하여 현재 사용 가능한 컴포넌트의 기능을 반영했습니다. 2026-01-16 11:10:50 +09:00
kjs ee1ea4190d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal 2026-01-16 11:10:41 +09:00
kjs 6c920b21a4 다국어 지원 및 테이블 설정 현황 문서를 업데이트하여 현재 사용 가능한 17개 컴포넌트의 기능 현황을 반영했습니다. 또한, 집계 위젯(aggregation-widget) 관련 기능을 추가하고, UI에서 다국어 지원을 위한 라벨 수집 및 매핑 로직을 개선하여 사용자 경험을 향상시켰습니다. 2026-01-16 11:02:27 +09:00
kjs f160ba2a1b 다국어 지원 및 테이블 설정 현황 문서를 업데이트하고, 컴포넌트 패널에서 UI 개선을 통해 사용자 경험을 향상시켰습니다. 또한, 통합 패널의 너비를 조정하고, 패널 토글 기능을 추가하여 인터페이스의 유연성을 높였습니다. 2026-01-16 09:59:16 +09:00
kjs c71641c32c 코드 정리 및 스타일 개선: ComponentsPanel과 UnifiedRepeater 컴포넌트에서 불필요한 공백을 제거하고, 코드 가독성을 향상시켰습니다. 또한, UnifiedRepeaterConfigPanel에서 컬럼 선택 UI의 구조를 개선하여 사용자 경험을 개선했습니다. 2026-01-15 17:50:52 +09:00
kjs ce85528ddf 다국어 지원 및 테이블 설정 현황 문서를 업데이트하고, SplitPanelLayoutConfigPanel에서 좌측 패널 테이블 선택 기능을 추가했습니다. 또한, 조인 키를 연결 키로 변경하고, 조건 필터 모드에 대한 설명을 수정하여 사용자 경험을 개선했습니다. 2026-01-15 17:35:04 +09:00
kjs 7181822832 카드 디스플레이 설정 패널에 테이블 선택 기능을 추가하고, 커스텀 테이블 사용 여부에 따라 컬럼 목록을 동적으로 로드하도록 개선했습니다. 또한, 다국어 지원 및 테이블 설정 현황 문서의 내용을 업데이트하여 적용 상태를 명확히 하였습니다. 2026-01-15 17:07:18 +09:00
kjs 57d86c8ef1 새로운 문서 파일을 추가하여 현재 사용 가능한 16개 컴포넌트의 다국어 지원 및 테이블 설정 기능 현황을 정리했습니다. 각 컴포넌트별 상세 현황과 우선순위 작업 목록을 포함하여 기능 적용 상태를 명확히 하였습니다. 2026-01-15 17:00:21 +09:00
kjs e937ba9161 조회 테이블 설정 UI 표준을 추가하고, 테이블 선택 Combobox 및 읽기전용 설정 기능을 구현했습니다. 또한, 테이블 이름 계산 로직을 개선하여 커스텀 테이블 사용 시 올바른 테이블 이름을 표시하도록 수정했습니다. 2026-01-15 16:21:55 +09:00
kjs e168753d87 리피터 케이블 설정 구현 2026-01-15 15:17:52 +09:00
kjs bed7f5f5c4 UnifiedRepeater 및 관련 컴포넌트에서 마스터 레코드 ID와 커스텀 테이블 설정 기능을 추가했습니다. 데이터 저장 시 마스터 레코드 ID를 포함하여 FK 자동 연결을 지원하며, 커스텀 테이블 사용 여부에 따라 저장 대상을 설정할 수 있도록 개선했습니다. 2026-01-15 14:47:49 +09:00
kjs 321c52a1f8 UnifiedRepeaterConfigPanel: Update render mode handling to reset modal settings when switching from modal to inline mode 2026-01-15 13:51:32 +09:00
kjs 5c098a0395 리피터 수정 2026-01-15 13:38:01 +09:00
kjs c3adb4216f 엔티티 조인 설정에서 카테고리 드롭다운을 제외하고, RepeaterTable에서 카테고리 타입을 추가하여 관련 옵션을 로드하도록 수정했습니다. 또한, 코드 정리를 통해 가독성을 향상시켰습니다. 2026-01-15 12:22:45 +09:00
kjs 7920928706 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal 2026-01-15 11:04:58 +09:00
kjs 19dbe59e3a 엔티티조인 읽기전용 컬럼 추가 2026-01-15 10:39:23 +09:00
kjs 71af4dfc6b 폼 통합 2026-01-15 09:50:33 +09:00
kjs 08ea14eed7 충돌수정 2026-01-15 09:31:25 +09:00
kjs 45f0c667c2 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal 2026-01-15 09:22:31 +09:00
kjs 7f9885f84e Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal 2026-01-06 09:22:45 +09:00
kjs 5e688ca28c Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal 2026-01-05 18:23:14 +09:00
kjs 6295b52e87 모달열기 기능 통합 2026-01-05 18:22:46 +09:00
kjs a34230ae90 모달열기 액션 통합 2026-01-05 17:44:32 +09:00
kjs 3fdc9e36f4 탭 분할 2026-01-05 16:14:36 +09:00
kjs a3c29b28ad 컴포넌트 숨김처리 2026-01-05 16:02:33 +09:00
kjs 9dbb6b100a 카드디스플레이 설정 적용안된느 오류 해결 2026-01-05 15:42:24 +09:00
kjs b4a1fe6889 테이블 설정패널 간소화 2026-01-05 15:35:19 +09:00
kjs 722eebb00b 테이블 페이지 네이션 입력 2026-01-05 15:30:57 +09:00
kjs 83597a7cc2 설정패널 간소화 2026-01-05 15:21:29 +09:00
kjs 42583a75eb 기존 컴포넌트 숨김처리 2026-01-05 15:00:05 +09:00
kjs 30012d908a 숨김처리 2026-01-05 14:36:56 +09:00
kjs 56036d7fe4 분할패널 헤더설정 제거 2026-01-05 14:25:39 +09:00
kjs e5faff2853 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal 2026-01-05 14:22:17 +09:00
kjs 291a389486 헤더로 해상도 설정 이동 2026-01-05 13:28:11 +09:00
kjs d7eb91a043 분할패널 설정 간소화 2026-01-05 12:21:02 +09:00
kjs f7ab488b3c 중복 분할패널 숨김처리 2026-01-05 11:40:24 +09:00
kjs 2a8acd9a02 컴포넌트 정리 2026-01-05 11:33:57 +09:00
kjs 0dafd417ef Merge feature/unified-components-renewal: 통합 브랜치 생성 2026-01-05 10:22:12 +09:00
kjs 42d75e1aaf Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/unified-components-renewal 2025-12-31 15:11:25 +09:00
kjs 5daef415ad 검색필터 고장 2025-12-24 14:46:51 +09:00
kjs 5102eec46f 테이블 헤더 고정 2025-12-24 13:54:24 +09:00
kjs b68c0bd340 ui수정 2025-12-24 10:31:36 +09:00
kjs cc4d294906 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/unified-components-renewal 2025-12-24 09:58:39 +09:00
kjs 5948799a29 리피터 입력폼 수정 2025-12-24 09:58:22 +09:00
kjs 97675458d7 반복 데이터 입력컴포넌트 통합중 2025-12-23 16:44:53 +09:00
kjs 2513b89ca2 반복입력 컴포넌트 통합 2025-12-23 14:45:19 +09:00
kjs 9af7fe5b98 입력타입 컴포넌트 통합 2025-12-23 14:20:18 +09:00
kjs 9c26738604 카드 디스플레이 옵션 설정 2025-12-23 13:53:22 +09:00
kjs 01e47a1830 불필요한 컴포넌트 제거 2025-12-23 10:49:28 +09:00
kjs 7569394645 날자타입 컴포넌트 디자인 개선 2025-12-23 10:11:21 +09:00
kjs 584d6b183b Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/unified-components-renewal 2025-12-23 09:47:57 +09:00
kjs 6e9cbccf47 공통코드 원복 2025-12-22 16:50:25 +09:00
kjs b01efd293c 공통코드 수정중 2025-12-22 13:45:08 +09:00
kjs ac526c8578 조건부 설정 구현 2025-12-22 10:44:22 +09:00
kjs a717f97b34 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/unified-components-renewal 2025-12-22 10:10:26 +09:00
kjs d33daf0a3d 뉴 컴포넌트 2025-12-19 16:40:40 +09:00
kjs 034ef59ef9 수정 2025-12-19 16:20:59 +09:00
kjs 9597494685 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/unified-components-renewal 2025-12-19 16:10:32 +09:00
kjs 979a5ddd9a 복사기능 오류수정 2025-12-19 15:56:22 +09:00
kjs 91d00aa784 컴포넌트 리뉴얼 1.0 2025-12-19 15:44:38 +09:00
kjs 2487c79a61 fix: 메뉴 복사 로직 개선 - FK 에러 해결 및 성능 최적화
- numbering_rules FK 에러 해결 (menu_objid NULL 설정)
- category_column_mapping FK 에러 해결 (삭제 후 재복사)
- 채번규칙 매핑 보완 로직 추가 (화면에서 참조하는 채번규칙을 이름으로 찾아 매핑)
- 기존 채번규칙/카테고리 매핑의 menu_objid 갱신 로직 추가
- N+1 쿼리 최적화 (배치 조회/삽입으로 변경)
  - 메뉴 삭제: N개 쿼리 → 1개
  - 화면 할당/플로우 수집: N개 쿼리 → 1개
  - 화면 정의 조회: N개 쿼리 → 1개
  - 레이아웃 삽입: N개 쿼리 → 화면당 1개
  - 채번규칙/카테고리 매핑 업데이트: CASE WHEN 배치 처리
- 예상 성능 개선: ~10배
2025-12-19 13:45:14 +09:00
267 changed files with 74788 additions and 4628 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,559 +1,40 @@
# 다국어 지원 컴포넌트 개발 가이드
새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다.
이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다.
---
description: (Deprecated) 이 파일은 component-development-guide.mdc로 통합되었습니다.
alwaysApply: false
---
## 1. 타입 정의 시 다국어 필드 추가
# 다국어 지원 컴포넌트 개발 가이드 (Deprecated)
### 기본 원칙
> **이 문서는 더 이상 사용되지 않습니다.**
>
> 새로운 통합 가이드를 참조하세요: `component-development-guide.mdc`
텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 함께 정의해야 합니다.
다국어 지원을 포함한 모든 컴포넌트 개발 가이드가 다음 파일로 통합되었습니다:
### 단일 텍스트 속성
**[component-development-guide.mdc](.cursor/rules/component-development-guide.mdc)**
```typescript
interface MyComponentConfig {
// 기본 텍스트
title?: string;
// 다국어 키 (필수 추가)
titleLangKeyId?: number;
titleLangKey?: string;
통합된 가이드에는 다음 내용이 포함되어 있습니다:
// 라벨
label?: string;
labelLangKeyId?: number;
labelLangKey?: string;
1. **엔티티 조인 컬럼 활용 (필수)**
// 플레이스홀더
placeholder?: string;
placeholderLangKeyId?: number;
placeholderLangKey?: string;
}
```
- 화면을 새로 만들어 임베딩하는 방식 대신 엔티티 관계 활용
- `entityJoinApi.getEntityJoinColumns()` 사용법
- 설정 패널에서 조인 컬럼 표시 패턴
### 배열/목록 속성 (컬럼, 탭 등)
2. **폼 데이터 관리**
```typescript
interface ColumnConfig {
name: string;
label: string;
// 다국어 키 (필수 추가)
langKeyId?: number;
langKey?: string;
// 기타 속성
width?: number;
align?: "left" | "center" | "right";
}
- `useFormCompatibility` 훅 사용법
- 레거시 `beforeFormSave` 이벤트 호환성
interface TabConfig {
id: string;
label: string;
// 다국어 키 (필수 추가)
langKeyId?: number;
langKey?: string;
// 탭 제목도 별도로
title?: string;
titleLangKeyId?: number;
titleLangKey?: string;
}
3. **다국어 지원**
interface MyComponentConfig {
columns?: ColumnConfig[];
tabs?: TabConfig[];
}
```
- 타입 정의 시 `langKeyId`, `langKey` 필드 추가
- 라벨 추출/매핑 로직
- 번역 표시 로직
### 버튼 컴포넌트
4. **컬럼 설정 패널 구현**
```typescript
interface ButtonComponentConfig {
text?: string;
// 다국어 키 (필수 추가)
langKeyId?: number;
langKey?: string;
}
```
- 필수 구조 및 패턴
### 실제 예시: 분할 패널
```typescript
interface SplitPanelLayoutConfig {
leftPanel?: {
title?: string;
langKeyId?: number; // 좌측 패널 제목 다국어
langKey?: string;
columns?: Array<{
name: string;
label: string;
langKeyId?: number; // 각 컬럼 다국어
langKey?: string;
}>;
};
rightPanel?: {
title?: string;
langKeyId?: number; // 우측 패널 제목 다국어
langKey?: string;
columns?: Array<{
name: string;
label: string;
langKeyId?: number;
langKey?: string;
}>;
additionalTabs?: Array<{
label: string;
langKeyId?: number; // 탭 라벨 다국어
langKey?: string;
title?: string;
titleLangKeyId?: number; // 탭 제목 다국어
titleLangKey?: string;
columns?: Array<{
name: string;
label: string;
langKeyId?: number;
langKey?: string;
}>;
}>;
};
}
```
---
## 2. 라벨 추출 로직 등록
### 파일 위치
`frontend/lib/utils/multilangLabelExtractor.ts`
### `extractMultilangLabels` 함수에 추가
새 컴포넌트의 라벨을 추출하는 로직을 추가해야 합니다.
```typescript
// 새 컴포넌트 타입 체크
if (comp.componentType === "my-new-component") {
const config = comp.componentConfig as MyComponentConfig;
// 1. 제목 추출
if (config?.title) {
addLabel({
id: `${comp.id}_title`,
componentId: `${comp.id}_title`,-
label: config.title,
type: "title",
parentType: "my-new-component",
parentLabel: config.title,
langKeyId: config.titleLangKeyId,
langKey: config.titleLangKey,
});
}
// 2. 컬럼 추출
if (config?.columns && Array.isArray(config.columns)) {
config.columns.forEach((col, index) => {
const colLabel = col.label || col.name;
addLabel({
id: `${comp.id}_col_${index}`,
componentId: `${comp.id}_col_${index}`,
label: colLabel,
type: "column",
parentType: "my-new-component",
parentLabel: config.title || "새 컴포넌트",
langKeyId: col.langKeyId,
langKey: col.langKey,
});
});
}
// 3. 버튼 텍스트 추출 (버튼 컴포넌트인 경우)
if (config?.text) {
addLabel({
id: `${comp.id}_button`,
componentId: `${comp.id}_button`,
label: config.text,
type: "button",
parentType: "my-new-component",
parentLabel: config.text,
langKeyId: config.langKeyId,
langKey: config.langKey,
});
}
}
```
### 추출해야 할 라벨 타입
| 타입 | 설명 | 예시 |
| ------------- | ------------------ | ------------------------ |
| `title` | 컴포넌트/패널 제목 | 분할패널 제목, 카드 제목 |
| `label` | 입력 필드 라벨 | 텍스트 입력 라벨 |
| `button` | 버튼 텍스트 | 저장, 취소, 삭제 |
| `column` | 테이블 컬럼 헤더 | 품목명, 수량, 금액 |
| `tab` | 탭 라벨 | 기본정보, 상세정보 |
| `filter` | 검색 필터 라벨 | 검색어, 기간 |
| `placeholder` | 플레이스홀더 | "검색어를 입력하세요" |
| `action` | 액션 버튼/링크 | 수정, 삭제, 상세보기 |
---
## 3. 매핑 적용 로직 등록
### 파일 위치
`frontend/lib/utils/multilangLabelExtractor.ts`
### `applyMultilangMappings` 함수에 추가
다국어 키가 선택되면 컴포넌트에 `langKeyId`와 `langKey`를 저장하는 로직을 추가합니다.
```typescript
// 새 컴포넌트 매핑 적용
if (comp.componentType === "my-new-component") {
const config = comp.componentConfig as MyComponentConfig;
// 1. 제목 매핑
const titleMapping = mappingMap.get(`${comp.id}_title`);
if (titleMapping) {
updated.componentConfig = {
...updated.componentConfig,
titleLangKeyId: titleMapping.keyId,
titleLangKey: titleMapping.langKey,
};
}
// 2. 컬럼 매핑
if (config?.columns && Array.isArray(config.columns)) {
const updatedColumns = config.columns.map((col, index) => {
const colMapping = mappingMap.get(`${comp.id}_col_${index}`);
if (colMapping) {
return {
...col,
langKeyId: colMapping.keyId,
langKey: colMapping.langKey,
};
}
return col;
});
updated.componentConfig = {
...updated.componentConfig,
columns: updatedColumns,
};
}
// 3. 버튼 매핑 (버튼 컴포넌트인 경우)
const buttonMapping = mappingMap.get(`${comp.id}_button`);
if (buttonMapping) {
updated.componentConfig = {
...updated.componentConfig,
langKeyId: buttonMapping.keyId,
langKey: buttonMapping.langKey,
};
}
}
```
### 주의사항
- **객체 참조 유지**: 매핑 시 기존 `updated.componentConfig`를 기반으로 업데이트해야 합니다.
- **중첩 구조**: 중첩된 객체(예: `leftPanel.columns`)는 상위 객체부터 순서대로 업데이트합니다.
```typescript
// 잘못된 방법 - 이전 업데이트 덮어쓰기
updated.componentConfig = { ...config, langKeyId: mapping.keyId }; // ❌
updated.componentConfig = { ...config, columns: updatedColumns }; // langKeyId 사라짐!
// 올바른 방법 - 이전 업데이트 유지
updated.componentConfig = {
...updated.componentConfig,
langKeyId: mapping.keyId,
}; // ✅
updated.componentConfig = {
...updated.componentConfig,
columns: updatedColumns,
}; // ✅
```
---
## 4. 번역 표시 로직 구현
### 파일 위치
새 컴포넌트 파일 (예: `frontend/lib/registry/components/my-component/MyComponent.tsx`)
### Context 사용
```typescript
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
const MyComponent = ({ component }: Props) => {
const { getTranslatedText } = useScreenMultiLang();
const config = component.componentConfig;
// 제목 번역
const displayTitle = config?.titleLangKey
? getTranslatedText(config.titleLangKey, config.title || "")
: config?.title || "";
// 컬럼 헤더 번역
const translatedColumns = config?.columns?.map((col) => ({
...col,
displayLabel: col.langKey
? getTranslatedText(col.langKey, col.label)
: col.label,
}));
// 버튼 텍스트 번역
const buttonText = config?.langKey
? getTranslatedText(config.langKey, config.text || "")
: config?.text || "";
return (
<div>
<h2>{displayTitle}</h2>
<table>
<thead>
<tr>
{translatedColumns?.map((col, idx) => (
<th key={idx}>{col.displayLabel}</th>
))}
</tr>
</thead>
</table>
<button>{buttonText}</button>
</div>
);
};
```
### getTranslatedText 함수
```typescript
// 첫 번째 인자: langKey (다국어 키)
// 두 번째 인자: fallback (키가 없거나 번역이 없을 때 기본값)
const text = getTranslatedText(
"screen.company_1.Sales.OrderList.품목명",
"품목명"
);
```
### 주의사항
- `langKey`가 없으면 원본 텍스트를 표시합니다.
- `useScreenMultiLang`은 반드시 `ScreenMultiLangProvider` 내부에서 사용해야 합니다.
- 화면 페이지(`/screens/[screenId]/page.tsx`)에서 이미 Provider로 감싸져 있습니다.
---
## 5. ScreenMultiLangContext에 키 수집 로직 추가
### 파일 위치
`frontend/contexts/ScreenMultiLangContext.tsx`
### `collectLangKeys` 함수에 추가
번역을 미리 로드하기 위해 컴포넌트에서 사용하는 모든 `langKey`를 수집해야 합니다.
```typescript
const collectLangKeys = (comps: ComponentData[]): Set<string> => {
const keys = new Set<string>();
const processComponent = (comp: ComponentData) => {
const config = comp.componentConfig;
// 새 컴포넌트의 langKey 수집
if (comp.componentType === "my-new-component") {
// 제목
if (config?.titleLangKey) {
keys.add(config.titleLangKey);
}
// 컬럼
if (config?.columns && Array.isArray(config.columns)) {
config.columns.forEach((col: any) => {
if (col.langKey) {
keys.add(col.langKey);
}
});
}
// 버튼
if (config?.langKey) {
keys.add(config.langKey);
}
}
// 자식 컴포넌트 재귀 처리
if (comp.children && Array.isArray(comp.children)) {
comp.children.forEach(processComponent);
}
};
comps.forEach(processComponent);
return keys;
};
```
---
## 6. MultilangSettingsModal에 표시 로직 추가
### 파일 위치
`frontend/components/screen/modals/MultilangSettingsModal.tsx`
### `extractLabelsFromComponents` 함수에 추가
다국어 설정 모달에서 새 컴포넌트의 라벨이 표시되도록 합니다.
```typescript
// 새 컴포넌트 라벨 추출
if (comp.componentType === "my-new-component") {
const config = comp.componentConfig as MyComponentConfig;
// 제목
if (config?.title) {
addLabel({
id: `${comp.id}_title`,
componentId: `${comp.id}_title`,
label: config.title,
type: "title",
parentType: "my-new-component",
parentLabel: config.title,
langKeyId: config.titleLangKeyId,
langKey: config.titleLangKey,
});
}
// 컬럼
if (config?.columns) {
config.columns.forEach((col, index) => {
// columnLabelMap에서 라벨 가져오기 (테이블 컬럼인 경우)
const tableName = config.tableName;
const displayLabel =
tableName && columnLabelMap[tableName]?.[col.name]
? columnLabelMap[tableName][col.name]
: col.label || col.name;
addLabel({
id: `${comp.id}_col_${index}`,
componentId: `${comp.id}_col_${index}`,
label: displayLabel,
type: "column",
parentType: "my-new-component",
parentLabel: config.title || "새 컴포넌트",
langKeyId: col.langKeyId,
langKey: col.langKey,
});
});
}
}
```
---
## 7. 테이블명 추출 (테이블 사용 컴포넌트인 경우)
### 파일 위치
`frontend/lib/utils/multilangLabelExtractor.ts`
### `extractTableNames` 함수에 추가
컴포넌트가 테이블을 사용하는 경우, 테이블명을 추출해야 컬럼 라벨을 가져올 수 있습니다.
```typescript
const extractTableNames = (comps: ComponentData[]): Set<string> => {
const tableNames = new Set<string>();
const processComponent = (comp: ComponentData) => {
const config = comp.componentConfig;
// 새 컴포넌트의 테이블명 추출
if (comp.componentType === "my-new-component") {
if (config?.tableName) {
tableNames.add(config.tableName);
}
if (config?.selectedTable) {
tableNames.add(config.selectedTable);
}
}
// 자식 컴포넌트 재귀 처리
if (comp.children && Array.isArray(comp.children)) {
comp.children.forEach(processComponent);
}
};
comps.forEach(processComponent);
return tableNames;
};
```
---
## 8. 체크리스트
새 컴포넌트 개발 시 다음 항목을 확인하세요:
### 타입 정의
- [ ] 모든 텍스트 속성에 `langKeyId`, `langKey` 필드 추가
- [ ] 배열 속성(columns, tabs 등)의 각 항목에도 다국어 필드 추가
### 라벨 추출 (multilangLabelExtractor.ts)
- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가
- [ ] `extractTableNames` 함수에 테이블명 추출 로직 추가 (해당되는 경우)
### 매핑 적용 (multilangLabelExtractor.ts)
- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가
### 번역 표시 (컴포넌트 파일)
- [ ] `useScreenMultiLang` 훅 사용
- [ ] `getTranslatedText`로 텍스트 번역 적용
### 키 수집 (ScreenMultiLangContext.tsx)
- [ ] `collectLangKeys` 함수에 langKey 수집 로직 추가
### 설정 모달 (MultilangSettingsModal.tsx)
- [ ] `extractLabelsFromComponents`에 라벨 표시 로직 추가
---
## 9. 관련 파일 목록
| 파일 | 역할 |
| -------------------------------------------------------------- | ----------------------- |
| `frontend/lib/utils/multilangLabelExtractor.ts` | 라벨 추출 및 매핑 적용 |
| `frontend/contexts/ScreenMultiLangContext.tsx` | 번역 Context 및 키 수집 |
| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 설정 UI |
| `frontend/components/screen/ScreenDesigner.tsx` | 다국어 생성 버튼 처리 |
| `backend-node/src/services/multilangService.ts` | 다국어 키 생성 서비스 |
---
## 10. 주의사항
1. **componentId 형식 일관성**: 라벨 추출과 매핑 적용에서 동일한 ID 형식 사용
- 제목: `${comp.id}_title`
- 컬럼: `${comp.id}_col_${index}`
- 버튼: `${comp.id}_button`
2. **중첩 구조 주의**: 분할패널처럼 중첩된 구조는 경로를 명확히 지정
- `${comp.id}_left_title`, `${comp.id}_right_col_${index}`
3. **기존 값 보존**: 매핑 적용 시 `updated.componentConfig`를 기반으로 업데이트
4. **라벨 타입 구분**: 입력 폼의 `label`과 다른 컴포넌트의 `label`을 구분하여 처리
5. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트
5. **체크리스트**
- 새 컴포넌트 개발 시 확인 항목

View File

@ -71,7 +71,7 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
@ -253,6 +253,7 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/entity", entityOptionsRouter); // 엔티티 옵션 (UnifiedSelect용)
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리

View File

@ -412,7 +412,13 @@ export class EntityJoinController {
logger.info(`Entity 조인 컬럼 조회: ${tableName}`);
// 1. 현재 테이블의 Entity 조인 설정 조회
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName);
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
const joinConfigs = allJoinConfigs.filter(
(config) => config.referenceTable !== "table_column_category_values"
);
if (joinConfigs.length === 0) {
res.status(200).json({
@ -449,6 +455,7 @@ export class EntityJoinController {
columnName: col.columnName,
columnLabel: col.displayName || col.columnName,
dataType: col.dataType,
inputType: col.inputType || "text",
isNullable: true, // 기본값으로 설정
maxLength: undefined, // 정보가 없으므로 undefined
description: col.displayName,
@ -477,6 +484,7 @@ export class EntityJoinController {
columnName: string;
columnLabel: string;
dataType: string;
inputType: string;
joinAlias: string;
suggestedLabel: string;
}> = [];
@ -491,6 +499,7 @@ export class EntityJoinController {
columnName: col.columnName,
columnLabel: col.columnLabel,
dataType: col.dataType,
inputType: col.inputType || "text",
joinAlias,
suggestedLabel,
});

View File

@ -3,6 +3,101 @@ import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
/**
* API (UnifiedSelect용)
* GET /api/entity/:tableName/options
*
* Query Params:
* - value: (기본: id)
* - label: 표시 (기본: name)
*/
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
try {
const { tableName } = req.params;
const { value = "id", label = "name" } = req.query;
// tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
logger.warn("엔티티 옵션 조회 실패: 테이블명이 없음", { tableName });
return res.status(400).json({
success: false,
message: "테이블명이 지정되지 않았습니다.",
});
}
const companyCode = req.user!.companyCode;
const pool = getPool();
// 테이블의 실제 컬럼 목록 조회
const columnsResult = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1`,
[tableName]
);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
// 요청된 컬럼 검증
const valueColumn = existingColumns.has(value as string) ? value : "id";
const labelColumn = existingColumns.has(label as string) ? label : "name";
// 둘 다 없으면 에러
if (!existingColumns.has(valueColumn as string)) {
return res.status(400).json({
success: false,
message: `테이블 "${tableName}"에 값 컬럼 "${value}"이 존재하지 않습니다.`,
});
}
// label 컬럼이 없으면 value 컬럼을 label로도 사용
const effectiveLabelColumn = existingColumns.has(labelColumn as string) ? labelColumn : valueColumn;
// WHERE 조건 (멀티테넌시)
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (companyCode !== "*" && existingColumns.has("company_code")) {
whereConditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 쿼리 실행 (최대 500개)
const query = `
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label
FROM ${tableName}
${whereClause}
ORDER BY ${effectiveLabelColumn} ASC
LIMIT 500
`;
const result = await pool.query(query, params);
logger.info("엔티티 옵션 조회 성공", {
tableName,
valueColumn,
labelColumn: effectiveLabelColumn,
companyCode,
rowCount: result.rowCount,
});
res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("엔티티 옵션 조회 오류", {
error: error.message,
stack: error.stack,
});
res.status(500).json({ success: false, message: error.message });
}
}
/**
* API
* GET /api/entity-search/:tableName

View File

@ -216,11 +216,12 @@ router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequ
router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
logger.info("코드 할당 요청", { ruleId, companyCode });
logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData });
try {
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData);
logger.info("코드 할당 성공", { ruleId, allocatedCode });
return res.json({ success: true, data: { generatedCode: allocatedCode } });
} catch (error: any) {

View File

@ -97,11 +97,16 @@ export async function getColumnList(
}
const tableManagementService = new TableManagementService();
// 🔥 캐시 버스팅: _t 파라미터가 있으면 캐시 무시
const bustCache = !!req.query._t;
const result = await tableManagementService.getColumnList(
tableName,
parseInt(page as string),
parseInt(size as string),
companyCode // 🔥 회사 코드 전달
companyCode, // 🔥 회사 코드 전달
bustCache // 🔥 캐시 버스팅 옵션
);
logger.info(
@ -2280,3 +2285,90 @@ export async function getTableEntityRelations(
});
}
}
/**
* (FK로 )
* GET /api/table-management/columns/:tableName/referenced-by
*
* column_labels에서 reference_table이
* FK .
*/
export async function getReferencedByTables(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(
`=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 ===`
);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "tableName 파라미터가 필요합니다.",
error: {
code: "MISSING_PARAMETERS",
details: "tableName 경로 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
return;
}
// column_labels에서 reference_table이 현재 테이블인 레코드 조회
// input_type이 'entity'인 것만 조회 (실제 FK 관계)
const sqlQuery = `
SELECT DISTINCT
cl.table_name,
cl.column_name,
cl.column_label,
cl.reference_table,
cl.reference_column,
cl.display_column,
cl.table_name as table_label
FROM column_labels cl
WHERE cl.reference_table = $1
AND cl.input_type = 'entity'
ORDER BY cl.table_name, cl.column_name
`;
const result = await query(sqlQuery, [tableName]);
const referencedByTables = result.map((row: any) => ({
tableName: row.table_name,
tableLabel: row.table_label,
columnName: row.column_name,
columnLabel: row.column_label,
referenceTable: row.reference_table,
referenceColumn: row.reference_column || "id",
displayColumn: row.display_column,
}));
logger.info(
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견`
);
const response: ApiResponse<any> = {
success: true,
message: `${referencedByTables.length}개의 테이블이 ${tableName}을 참조합니다.`,
data: referencedByTables,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 참조 관계 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 참조 관계 조회 중 오류가 발생했습니다.",
error: {
code: "REFERENCED_BY_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}

View File

@ -57,3 +57,5 @@ export default router;

View File

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

View File

@ -69,3 +69,5 @@ export default router;

View File

@ -57,3 +57,5 @@ export default router;

View File

@ -73,4 +73,20 @@ router.get("/categories/:categoryCode/options", (req, res) =>
commonCodeController.getCodeOptions(req, res)
);
// 계층 구조 코드 조회 (트리 형태)
router.get("/categories/:categoryCode/hierarchy", (req, res) =>
commonCodeController.getCodesHierarchy(req, res)
);
// 자식 코드 조회 (연쇄 선택용)
router.get("/categories/:categoryCode/children", (req, res) =>
commonCodeController.getChildCodes(req, res)
);
// 카테고리 → 공통코드 호환 API (레거시 지원)
// 기존 카테고리 타입이 공통코드로 마이그레이션된 후에도 동작
router.get("/category-options/:tableName/:columnName", (req, res) =>
commonCodeController.getCategoryOptionsAsCode(req, res)
);
export default router;

View File

@ -1,6 +1,6 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { searchEntity } from "../controllers/entitySearchController";
import { searchEntity, getEntityOptions } from "../controllers/entitySearchController";
const router = Router();
@ -12,3 +12,12 @@ router.get("/:tableName", authenticateToken, searchEntity);
export default router;
// 엔티티 옵션 라우터 (UnifiedSelect용)
export const entityOptionsRouter = Router();
/**
* API
* GET /api/entity/:tableName/options
*/
entityOptionsRouter.get("/:tableName/options", authenticateToken, getEntityOptions);

View File

@ -26,6 +26,7 @@ import {
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
} from "../controllers/tableManagementController";
const router = express.Router();
@ -54,6 +55,14 @@ router.get("/tables/entity-relations", getTableEntityRelations);
*/
router.get("/tables/:tableName/columns", getColumnList);
/**
*
* GET /api/table-management/columns/:tableName/referenced-by
*
* FK
*/
router.get("/columns/:tableName/referenced-by", getReferencedByTables);
/**
*
* PUT /api/table-management/tables/:tableName/label

View File

@ -704,6 +704,7 @@ export class EntityJoinService {
columnName: string;
displayName: string;
dataType: string;
inputType?: string;
}>
> {
try {
@ -722,31 +723,39 @@ export class EntityJoinService {
[tableName]
);
// 2. column_labels 테이블에서 라벨 정보 조회
// 2. column_labels 테이블에서 라벨과 input_type 정보 조회
const columnLabels = await query<{
column_name: string;
column_label: string | null;
input_type: string | null;
}>(
`SELECT column_name, column_label
`SELECT column_name, column_label, input_type
FROM column_labels
WHERE table_name = $1`,
[tableName]
);
// 3. 라벨 정보를 맵으로 변환
const labelMap = new Map<string, string>();
columnLabels.forEach((label) => {
if (label.column_name && label.column_label) {
labelMap.set(label.column_name, label.column_label);
// 3. 라벨 및 inputType 정보를 맵으로 변환
const labelMap = new Map<string, { label: string; inputType: string }>();
columnLabels.forEach((col) => {
if (col.column_name) {
labelMap.set(col.column_name, {
label: col.column_label || col.column_name,
inputType: col.input_type || "text",
});
}
});
// 4. 컬럼 정보와 라벨 정보 결합
return columns.map((col) => ({
// 4. 컬럼 정보와 라벨/inputType 정보 결합
return columns.map((col) => {
const labelInfo = labelMap.get(col.column_name);
return {
columnName: col.column_name,
displayName: labelMap.get(col.column_name) || col.column_name, // 라벨이 있으면 사용, 없으면 컬럼명
displayName: labelInfo?.label || col.column_name,
dataType: col.data_type,
}));
inputType: labelInfo?.inputType || "text",
};
});
} catch (error) {
logger.error(`참조 테이블 컬럼 조회 실패: ${tableName}`, error);
return [];

View File

@ -883,16 +883,21 @@ class MasterDetailExcelService {
/**
* ( numberingRuleService )
* @param client DB
* @param ruleId ID
* @param companyCode
* @param formData ( )
*/
private async generateNumberWithRule(
client: any,
ruleId: string,
companyCode: string
companyCode: string,
formData?: Record<string, any>
): Promise<string> {
try {
// 기존 numberingRuleService를 사용하여 코드 할당
const { numberingRuleService } = await import("./numberingRuleService");
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData);
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);

View File

@ -984,9 +984,11 @@ export class NodeFlowExecutionService {
// 자동 생성 (채번 규칙)
const companyCode = context.buttonContext?.companyCode || "*";
try {
// 폼 데이터를 전달하여 날짜 컬럼 기준 생성 지원
value = await numberingRuleService.allocateCode(
mapping.numberingRuleId,
companyCode
companyCode,
data // 폼 데이터 전달 (날짜 컬럼 기준 생성 시 사용)
);
console.log(
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`

View File

@ -937,8 +937,15 @@ class NumberingRuleService {
/**
* ( )
* @param ruleId ID
* @param companyCode
* @param formData ( )
*/
async allocateCode(ruleId: string, companyCode: string): Promise<string> {
async allocateCode(
ruleId: string,
companyCode: string,
formData?: Record<string, any>
): Promise<string> {
const pool = getPool();
const client = await pool.connect();
@ -974,10 +981,40 @@ class NumberingRuleService {
case "date": {
// 날짜 (다양한 날짜 형식)
return this.formatDate(
new Date(),
autoConfig.dateFormat || "YYYYMMDD"
);
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
// 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
const columnValue = formData[autoConfig.sourceColumnName];
if (columnValue) {
// 날짜 문자열 또는 Date 객체를 Date로 변환
const dateValue = columnValue instanceof Date
? columnValue
: new Date(columnValue);
if (!isNaN(dateValue.getTime())) {
logger.info("컬럼 기준 날짜 생성", {
sourceColumn: autoConfig.sourceColumnName,
columnValue,
parsedDate: dateValue.toISOString(),
});
return this.formatDate(dateValue, dateFormat);
} else {
logger.warn("날짜 변환 실패, 현재 날짜 사용", {
sourceColumn: autoConfig.sourceColumnName,
columnValue,
});
}
} else {
logger.warn("소스 컬럼 값이 없음, 현재 날짜 사용", {
sourceColumn: autoConfig.sourceColumnName,
formDataKeys: Object.keys(formData),
});
}
}
// 기본: 현재 날짜 사용
return this.formatDate(new Date(), dateFormat);
}
case "text": {

View File

@ -1658,10 +1658,16 @@ export class ScreenManagementService {
? inputTypeMap.get(`${tableName}.${columnName}`)
: null;
// 🆕 Unified 컴포넌트는 덮어쓰지 않음 (새로운 컴포넌트 시스템 보호)
const savedComponentType = properties?.componentType;
const isUnifiedComponent = savedComponentType?.startsWith("unified-");
const component = {
id: layout.component_id,
// 🔥 최신 componentType이 있으면 type 덮어쓰기
type: latestTypeInfo?.componentType || layout.component_type as any,
// 🔥 최신 componentType이 있으면 type 덮어쓰기 (단, Unified 컴포넌트는 제외)
type: isUnifiedComponent
? layout.component_type as any // Unified는 저장된 값 유지
: (latestTypeInfo?.componentType || layout.component_type as any),
position: {
x: layout.position_x,
y: layout.position_y,
@ -1670,8 +1676,8 @@ export class ScreenManagementService {
size: { width: layout.width, height: layout.height },
parentId: layout.parent_id,
...properties,
// 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기
...(latestTypeInfo && {
// 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기 (단, Unified 컴포넌트는 제외)
...(!isUnifiedComponent && latestTypeInfo && {
widgetType: latestTypeInfo.inputType,
inputType: latestTypeInfo.inputType,
componentType: latestTypeInfo.componentType,

View File

@ -114,7 +114,8 @@ export class TableManagementService {
tableName: string,
page: number = 1,
size: number = 50,
companyCode?: string // 🔥 회사 코드 추가
companyCode?: string, // 🔥 회사 코드 추가
bustCache: boolean = false // 🔥 캐시 버스팅 옵션
): Promise<{
columns: ColumnTypeInfo[];
total: number;
@ -124,7 +125,7 @@ export class TableManagementService {
}> {
try {
logger.info(
`컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}`
`컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}, bustCache: ${bustCache}`
);
// 캐시 키 생성 (companyCode 포함)
@ -132,32 +133,37 @@ export class TableManagementService {
CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`;
const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
// 캐시에서 먼저 확인
const cachedResult = cache.get<{
columns: ColumnTypeInfo[];
total: number;
page: number;
size: number;
totalPages: number;
}>(cacheKey);
if (cachedResult) {
logger.info(
`컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}`
);
// 🔥 캐시 버스팅: bustCache가 true면 캐시 무시
if (!bustCache) {
// 캐시에서 먼저 확인
const cachedResult = cache.get<{
columns: ColumnTypeInfo[];
total: number;
page: number;
size: number;
totalPages: number;
}>(cacheKey);
if (cachedResult) {
logger.info(
`컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}`
);
// 디버깅: 캐시된 currency_code 확인
const cachedCurrency = cachedResult.columns.find(
(col: any) => col.columnName === "currency_code"
);
if (cachedCurrency) {
console.log(`💾 [캐시] currency_code:`, {
columnName: cachedCurrency.columnName,
inputType: cachedCurrency.inputType,
webType: cachedCurrency.webType,
});
// 디버깅: 캐시된 currency_code 확인
const cachedCurrency = cachedResult.columns.find(
(col: any) => col.columnName === "currency_code"
);
if (cachedCurrency) {
console.log(`💾 [캐시] currency_code:`, {
columnName: cachedCurrency.columnName,
inputType: cachedCurrency.inputType,
webType: cachedCurrency.webType,
});
}
return cachedResult;
}
return cachedResult;
} else {
logger.info(`🔥 캐시 버스팅: ${tableName} 캐시 무시`);
}
// 전체 컬럼 수 조회 (캐시 확인)
@ -4104,12 +4110,17 @@ export class TableManagementService {
// table_type_columns에서 입력타입 정보 조회
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
// detail_settings 컬럼에 유효하지 않은 JSON이 있을 수 있으므로 안전하게 처리
const rawInputTypes = await query<any>(
`SELECT DISTINCT ON (ttc.column_name)
ttc.column_name as "columnName",
COALESCE(cl.column_label, ttc.column_name) as "displayName",
ttc.input_type as "inputType",
COALESCE(ttc.detail_settings::jsonb, '{}'::jsonb) as "detailSettings",
CASE
WHEN ttc.detail_settings IS NULL OR ttc.detail_settings = '' THEN '{}'::jsonb
WHEN ttc.detail_settings ~ '^\\s*\\{.*\\}\\s*$' THEN ttc.detail_settings::jsonb
ELSE '{}'::jsonb
END as "detailSettings",
ttc.is_nullable as "isNullable",
ic.data_type as "dataType",
ttc.company_code as "companyCode"

View File

@ -0,0 +1,185 @@
# Phase 0: 컴포넌트 사용 현황 분석
## 분석 일시
2024-12-19
## 분석 대상
- 활성화된 화면 정의 (screen_definitions.is_active = 'Y')
- 화면 레이아웃 (screen_layouts)
---
## 1. 컴포넌트별 사용량 순위
### 상위 15개 컴포넌트
| 순위 | 컴포넌트 | 사용 횟수 | 사용 화면 수 | Unified 매핑 |
| :--: | :-------------------------- | :-------: | :----------: | :------------------------------ |
| 1 | button-primary | 571 | 364 | UnifiedInput (type: button) |
| 2 | text-input | 805 | 166 | **UnifiedInput (type: text)** |
| 3 | table-list | 130 | 130 | UnifiedList (viewMode: table) |
| 4 | table-search-widget | 127 | 127 | UnifiedList (searchable: true) |
| 5 | select-basic | 121 | 76 | **UnifiedSelect** |
| 6 | number-input | 86 | 34 | **UnifiedInput (type: number)** |
| 7 | date-input | 83 | 51 | **UnifiedDate** |
| 8 | file-upload | 41 | 18 | UnifiedMedia (type: file) |
| 9 | tabs-widget | 39 | 39 | UnifiedGroup (type: tabs) |
| 10 | split-panel-layout | 39 | 39 | UnifiedLayout (type: split) |
| 11 | category-manager | 38 | 38 | UnifiedBiz (type: category) |
| 12 | numbering-rule | 31 | 31 | UnifiedBiz (type: numbering) |
| 13 | selected-items-detail-input | 29 | 29 | 복합 컴포넌트 |
| 14 | modal-repeater-table | 25 | 25 | UnifiedList (modal: true) |
| 15 | image-widget | 29 | 29 | UnifiedMedia (type: image) |
---
## 2. Unified 컴포넌트별 통합 대상 분석
### UnifiedInput (예상 통합 대상: 891개)
| 기존 컴포넌트 | 사용 횟수 | 비율 |
| :------------ | :-------: | :---: |
| text-input | 805 | 90.3% |
| number-input | 86 | 9.7% |
**우선순위: 1위** - 가장 많이 사용되는 컴포넌트
### UnifiedSelect (예상 통합 대상: 140개)
| 기존 컴포넌트 | 사용 횟수 | widgetType |
| :------------------------ | :-------: | :--------- |
| select-basic (category) | 65 | category |
| select-basic (null) | 50 | - |
| autocomplete-search-input | 19 | entity |
| entity-search-input | 20 | entity |
| checkbox-basic | 7 | checkbox |
| radio-basic | 5 | radio |
**우선순위: 2위** - 다양한 모드 지원 필요
### UnifiedDate (예상 통합 대상: 83개)
| 기존 컴포넌트 | 사용 횟수 |
| :---------------- | :-------: |
| date-input (null) | 58 |
| date-input (date) | 23 |
| date-input (text) | 2 |
**우선순위: 3위**
### UnifiedList (예상 통합 대상: 283개)
| 기존 컴포넌트 | 사용 횟수 | 비고 |
| :-------------------- | :-------: | :---------- |
| table-list | 130 | 기본 테이블 |
| table-search-widget | 127 | 검색 테이블 |
| modal-repeater-table | 25 | 모달 반복 |
| repeater-field-group | 15 | 반복 필드 |
| card-display | 11 | 카드 표시 |
| simple-repeater-table | 1 | 단순 반복 |
**우선순위: 4위** - 핵심 데이터 표시 컴포넌트
### UnifiedMedia (예상 통합 대상: 70개)
| 기존 컴포넌트 | 사용 횟수 |
| :------------ | :-------: |
| file-upload | 41 |
| image-widget | 29 |
### UnifiedLayout (예상 통합 대상: 62개)
| 기존 컴포넌트 | 사용 횟수 |
| :------------------ | :-------: |
| split-panel-layout | 39 |
| screen-split-panel | 21 |
| split-panel-layout2 | 2 |
### UnifiedGroup (예상 통합 대상: 99개)
| 기존 컴포넌트 | 사용 횟수 |
| :-------------------- | :-------: |
| tabs-widget | 39 |
| conditional-container | 23 |
| section-paper | 11 |
| section-card | 10 |
| text-display | 13 |
| universal-form-modal | 7 |
| repeat-screen-modal | 5 |
### UnifiedBiz (예상 통합 대상: 79개)
| 기존 컴포넌트 | 사용 횟수 |
| :--------------------- | :-------: |
| category-manager | 38 |
| numbering-rule | 31 |
| flow-widget | 8 |
| rack-structure | 2 |
| related-data-buttons | 2 |
| location-swap-selector | 2 |
| tax-invoice-list | 1 |
---
## 3. 구현 우선순위 결정
### Phase 1 우선순위 (즉시 효과가 큰 컴포넌트)
| 순위 | Unified 컴포넌트 | 통합 대상 수 | 영향 화면 수 | 이유 |
| :---: | :---------------- | :----------: | :----------: | :--------------- |
| **1** | **UnifiedInput** | 891개 | 200+ | 가장 많이 사용 |
| **2** | **UnifiedSelect** | 140개 | 100+ | 다양한 모드 필요 |
| **3** | **UnifiedDate** | 83개 | 51 | 비교적 단순 |
### Phase 2 우선순위 (데이터 표시 컴포넌트)
| 순위 | Unified 컴포넌트 | 통합 대상 수 | 이유 |
| :---: | :---------------- | :----------: | :--------------- |
| **4** | **UnifiedList** | 283개 | 핵심 데이터 표시 |
| **5** | **UnifiedLayout** | 62개 | 레이아웃 구조 |
| **6** | **UnifiedGroup** | 99개 | 콘텐츠 그룹화 |
### Phase 3 우선순위 (특수 컴포넌트)
| 순위 | Unified 컴포넌트 | 통합 대상 수 | 이유 |
| :---: | :------------------- | :----------: | :------------ |
| **7** | **UnifiedMedia** | 70개 | 파일/이미지 |
| **8** | **UnifiedBiz** | 79개 | 비즈니스 특화 |
| **9** | **UnifiedHierarchy** | 0개 | 신규 기능 |
---
## 4. 주요 발견 사항
### 4.1 button-primary 분리 검토
- 사용량: 571개 (1위)
- 현재 계획: UnifiedInput에 포함
- **제안**: 별도 `UnifiedButton` 컴포넌트로 분리 검토
- 버튼은 입력과 성격이 다름
- 액션 타입, 스타일, 권한 등 복잡한 설정 필요
### 4.2 conditional-container 처리
- 사용량: 23개
- 현재 계획: 공통 conditional 속성으로 통합
- **확인 필요**: 기존 화면에서 어떻게 마이그레이션할지
### 4.3 category 관련 컴포넌트
- select-basic (category): 65개
- category-manager: 38개
- **총 103개**의 카테고리 관련 컴포넌트
- 카테고리 시스템 통합 중요
---
## 5. 다음 단계
1. [ ] 데이터 마이그레이션 전략 설계 (Phase 0-2)
2. [ ] sys_input_type JSON Schema 설계 (Phase 0-3)
3. [ ] DynamicConfigPanel 프로토타입 (Phase 0-4)
4. [ ] UnifiedInput 구현 시작 (Phase 1-1)

View File

@ -0,0 +1,393 @@
# Phase 0: 데이터 마이그레이션 전략
## 1. 현재 데이터 구조 분석
### screen_layouts.properties 구조
```jsonc
{
// 기본 정보
"type": "component",
"componentType": "text-input", // 기존 컴포넌트 타입
// 위치/크기
"position": { "x": 68, "y": 80, "z": 1 },
"size": { "width": 324, "height": 40 },
// 라벨 및 스타일
"label": "품목코드",
"style": {
"labelColor": "#000000",
"labelDisplay": true,
"labelFontSize": "14px",
"labelFontWeight": "500",
"labelMarginBottom": "8px"
},
// 데이터 바인딩
"tableName": "order_table",
"columnName": "part_code",
// 필드 속성
"required": true,
"readonly": false,
// 컴포넌트별 설정
"componentConfig": {
"type": "text-input",
"format": "none",
"webType": "text",
"multiline": false,
"placeholder": "텍스트를 입력하세요"
},
// 그리드 레이아웃
"gridColumns": 5,
"gridRowIndex": 0,
"gridColumnStart": 1,
"gridColumnSpan": "third",
// 기타
"parentId": null
}
```
---
## 2. 마이그레이션 전략: 하이브리드 방식
### 2.1 비파괴적 전환 (권장)
기존 필드를 유지하면서 새로운 필드를 추가하는 방식
```jsonc
{
// 기존 필드 유지 (하위 호환성)
"componentType": "text-input",
"componentConfig": { ... },
// 신규 필드 추가
"unifiedType": "UnifiedInput", // 새로운 통합 컴포넌트 타입
"unifiedConfig": { // 새로운 설정 구조
"type": "text",
"format": "none",
"placeholder": "텍스트를 입력하세요"
},
// 마이그레이션 메타데이터
"_migration": {
"version": "2.0",
"migratedAt": "2024-12-19T00:00:00Z",
"migratedBy": "system",
"originalType": "text-input"
}
}
```
### 2.2 렌더링 로직 수정
```typescript
// 렌더러에서 unifiedType 우선 사용
function renderComponent(props: ComponentProps) {
// 신규 타입이 있으면 Unified 컴포넌트 사용
if (props.unifiedType) {
return <UnifiedComponentRenderer
type={props.unifiedType}
config={props.unifiedConfig}
/>;
}
// 없으면 기존 레거시 컴포넌트 사용
return <LegacyComponentRenderer
type={props.componentType}
config={props.componentConfig}
/>;
}
```
---
## 3. 컴포넌트별 매핑 규칙
### 3.1 text-input → UnifiedInput
```typescript
// AS-IS
{
"componentType": "text-input",
"componentConfig": {
"type": "text-input",
"format": "none",
"webType": "text",
"multiline": false,
"placeholder": "텍스트를 입력하세요"
}
}
// TO-BE
{
"unifiedType": "UnifiedInput",
"unifiedConfig": {
"type": "text", // componentConfig.webType 또는 "text"
"format": "none", // componentConfig.format
"placeholder": "..." // componentConfig.placeholder
}
}
```
### 3.2 number-input → UnifiedInput
```typescript
// AS-IS
{
"componentType": "number-input",
"componentConfig": {
"type": "number-input",
"webType": "number",
"min": 0,
"max": 100,
"step": 1
}
}
// TO-BE
{
"unifiedType": "UnifiedInput",
"unifiedConfig": {
"type": "number",
"min": 0,
"max": 100,
"step": 1
}
}
```
### 3.3 select-basic → UnifiedSelect
```typescript
// AS-IS (code 타입)
{
"componentType": "select-basic",
"codeCategory": "ORDER_STATUS",
"componentConfig": {
"type": "select-basic",
"webType": "code",
"codeCategory": "ORDER_STATUS"
}
}
// TO-BE
{
"unifiedType": "UnifiedSelect",
"unifiedConfig": {
"mode": "dropdown",
"source": "code",
"codeGroup": "ORDER_STATUS"
}
}
// AS-IS (entity 타입)
{
"componentType": "select-basic",
"componentConfig": {
"type": "select-basic",
"webType": "entity",
"searchable": true,
"valueField": "id",
"displayField": "name"
}
}
// TO-BE
{
"unifiedType": "UnifiedSelect",
"unifiedConfig": {
"mode": "dropdown",
"source": "entity",
"searchable": true,
"valueField": "id",
"displayField": "name"
}
}
```
### 3.4 date-input → UnifiedDate
```typescript
// AS-IS
{
"componentType": "date-input",
"componentConfig": {
"type": "date-input",
"webType": "date",
"format": "YYYY-MM-DD"
}
}
// TO-BE
{
"unifiedType": "UnifiedDate",
"unifiedConfig": {
"type": "date",
"format": "YYYY-MM-DD"
}
}
```
---
## 4. 마이그레이션 스크립트
### 4.1 자동 마이그레이션 함수
```typescript
// lib/migration/componentMigration.ts
interface MigrationResult {
success: boolean;
unifiedType: string;
unifiedConfig: Record<string, any>;
}
export function migrateToUnified(
componentType: string,
componentConfig: Record<string, any>
): MigrationResult {
switch (componentType) {
case 'text-input':
return {
success: true,
unifiedType: 'UnifiedInput',
unifiedConfig: {
type: componentConfig.webType || 'text',
format: componentConfig.format || 'none',
placeholder: componentConfig.placeholder
}
};
case 'number-input':
return {
success: true,
unifiedType: 'UnifiedInput',
unifiedConfig: {
type: 'number',
min: componentConfig.min,
max: componentConfig.max,
step: componentConfig.step
}
};
case 'select-basic':
return {
success: true,
unifiedType: 'UnifiedSelect',
unifiedConfig: {
mode: 'dropdown',
source: componentConfig.webType || 'static',
codeGroup: componentConfig.codeCategory,
searchable: componentConfig.searchable,
valueField: componentConfig.valueField,
displayField: componentConfig.displayField
}
};
case 'date-input':
return {
success: true,
unifiedType: 'UnifiedDate',
unifiedConfig: {
type: componentConfig.webType || 'date',
format: componentConfig.format
}
};
default:
return {
success: false,
unifiedType: '',
unifiedConfig: {}
};
}
}
```
### 4.2 DB 마이그레이션 스크립트
```sql
-- 마이그레이션 백업 테이블 생성
CREATE TABLE screen_layouts_backup_v2 AS
SELECT * FROM screen_layouts;
-- 마이그레이션 실행 (text-input 예시)
UPDATE screen_layouts
SET properties = properties || jsonb_build_object(
'unifiedType', 'UnifiedInput',
'unifiedConfig', jsonb_build_object(
'type', COALESCE(properties->'componentConfig'->>'webType', 'text'),
'format', COALESCE(properties->'componentConfig'->>'format', 'none'),
'placeholder', properties->'componentConfig'->>'placeholder'
),
'_migration', jsonb_build_object(
'version', '2.0',
'migratedAt', NOW(),
'originalType', 'text-input'
)
)
WHERE properties->>'componentType' = 'text-input';
```
---
## 5. 롤백 전략
### 5.1 롤백 스크립트
```sql
-- 마이그레이션 전 상태로 복원
UPDATE screen_layouts sl
SET properties = slb.properties
FROM screen_layouts_backup_v2 slb
WHERE sl.layout_id = slb.layout_id;
-- 또는 신규 필드만 제거
UPDATE screen_layouts
SET properties = properties - 'unifiedType' - 'unifiedConfig' - '_migration';
```
### 5.2 단계적 롤백
```typescript
// 특정 화면만 롤백
async function rollbackScreen(screenId: number) {
await db.query(`
UPDATE screen_layouts sl
SET properties = properties - 'unifiedType' - 'unifiedConfig' - '_migration'
WHERE screen_id = $1
`, [screenId]);
}
```
---
## 6. 마이그레이션 일정
| 단계 | 작업 | 대상 | 시점 |
|:---:|:---|:---|:---|
| 1 | 백업 테이블 생성 | 전체 | Phase 1 시작 전 |
| 2 | UnifiedInput 마이그레이션 | text-input, number-input | Phase 1 중 |
| 3 | UnifiedSelect 마이그레이션 | select-basic | Phase 1 중 |
| 4 | UnifiedDate 마이그레이션 | date-input | Phase 1 중 |
| 5 | 검증 및 테스트 | 전체 | Phase 1 완료 후 |
| 6 | 레거시 필드 제거 | 전체 | Phase 5 (추후) |
---
## 7. 주의사항
1. **항상 백업 먼저**: 마이그레이션 전 반드시 백업 테이블 생성
2. **점진적 전환**: 한 번에 모든 컴포넌트를 마이그레이션하지 않음
3. **하위 호환성**: 기존 필드 유지로 롤백 가능하게
4. **테스트 필수**: 각 마이그레이션 단계별 화면 테스트

View File

@ -0,0 +1,192 @@
# Unified Components 구현 완료 보고서
## 구현 일시
2024-12-19
## 구현된 컴포넌트 목록 (10개)
### Phase 1: 핵심 입력 컴포넌트
| 컴포넌트 | 파일 | 모드/타입 | 설명 |
| :---------------- | :------------------ | :-------------------------------------------- | :---------------------- |
| **UnifiedInput** | `UnifiedInput.tsx` | text, number, password, slider, color, button | 통합 입력 컴포넌트 |
| **UnifiedSelect** | `UnifiedSelect.tsx` | dropdown, radio, check, tag, toggle, swap | 통합 선택 컴포넌트 |
| **UnifiedDate** | `UnifiedDate.tsx` | date, time, datetime + range | 통합 날짜/시간 컴포넌트 |
### Phase 2: 레이아웃 및 그룹 컴포넌트
| 컴포넌트 | 파일 | 모드/타입 | 설명 |
| :---------------- | :------------------ | :-------------------------------------------------------- | :--------------------- |
| **UnifiedList** | `UnifiedList.tsx` | table, card, kanban, list | 통합 리스트 컴포넌트 |
| **UnifiedLayout** | `UnifiedLayout.tsx` | grid, split, flex, divider, screen-embed | 통합 레이아웃 컴포넌트 |
| **UnifiedGroup** | `UnifiedGroup.tsx` | tabs, accordion, section, card-section, modal, form-modal | 통합 그룹 컴포넌트 |
### Phase 3: 미디어 및 비즈니스 컴포넌트
| 컴포넌트 | 파일 | 모드/타입 | 설명 |
| :------------------- | :--------------------- | :------------------------------------------------------------- | :---------------------- |
| **UnifiedMedia** | `UnifiedMedia.tsx` | file, image, video, audio | 통합 미디어 컴포넌트 |
| **UnifiedBiz** | `UnifiedBiz.tsx` | flow, rack, map, numbering, category, mapping, related-buttons | 통합 비즈니스 컴포넌트 |
| **UnifiedHierarchy** | `UnifiedHierarchy.tsx` | tree, org, bom, cascading | 통합 계층 구조 컴포넌트 |
---
## 공통 인프라
### 설정 패널
- **DynamicConfigPanel**: JSON Schema 기반 동적 설정 UI 생성
### 렌더러
- **UnifiedComponentRenderer**: unifiedType에 따른 동적 컴포넌트 렌더링
---
## 파일 구조
```
frontend/components/unified/
├── index.ts # 모듈 인덱스
├── UnifiedComponentRenderer.tsx # 동적 렌더러
├── DynamicConfigPanel.tsx # JSON Schema 설정 패널
├── UnifiedInput.tsx # 통합 입력
├── UnifiedSelect.tsx # 통합 선택
├── UnifiedDate.tsx # 통합 날짜
├── UnifiedList.tsx # 통합 리스트
├── UnifiedLayout.tsx # 통합 레이아웃
├── UnifiedGroup.tsx # 통합 그룹
├── UnifiedMedia.tsx # 통합 미디어
├── UnifiedBiz.tsx # 통합 비즈니스
└── UnifiedHierarchy.tsx # 통합 계층
frontend/types/
└── unified-components.ts # 타입 정의
db/migrations/
└── unified_component_schema.sql # DB 스키마 (미실행)
```
---
## 사용 예시
### 기본 사용법
```tsx
import {
UnifiedInput,
UnifiedSelect,
UnifiedDate,
UnifiedList,
UnifiedComponentRenderer
} from "@/components/unified";
// UnifiedInput 사용
<UnifiedInput
id="name"
label="이름"
required
config={{ type: "text", placeholder: "이름을 입력하세요" }}
value={name}
onChange={setName}
/>
// UnifiedSelect 사용
<UnifiedSelect
id="status"
label="상태"
config={{
mode: "dropdown",
source: "code",
codeGroup: "ORDER_STATUS",
searchable: true
}}
value={status}
onChange={setStatus}
/>
// UnifiedDate 사용
<UnifiedDate
id="orderDate"
label="주문일"
config={{ type: "date", format: "YYYY-MM-DD" }}
value={orderDate}
onChange={setOrderDate}
/>
// UnifiedList 사용
<UnifiedList
id="orderList"
label="주문 목록"
config={{
viewMode: "table",
searchable: true,
pageable: true,
pageSize: 10,
columns: [
{ field: "orderId", header: "주문번호", sortable: true },
{ field: "customerName", header: "고객명" },
{ field: "orderDate", header: "주문일", format: "date" },
]
}}
data={orders}
onRowClick={handleRowClick}
/>
```
### 동적 렌더링
```tsx
import { UnifiedComponentRenderer } from "@/components/unified";
// unifiedType에 따라 자동으로 적절한 컴포넌트 렌더링
<UnifiedComponentRenderer
props={{
unifiedType: "UnifiedInput",
id: "dynamicField",
label: "동적 필드",
config: { type: "text" },
value: fieldValue,
onChange: setFieldValue,
}}
/>;
```
---
## 주의사항
### 기존 컴포넌트와의 공존
1. **기존 컴포넌트는 그대로 유지**: 모든 레거시 컴포넌트는 정상 동작
2. **신규 화면에서만 Unified 컴포넌트 사용**: 기존 화면에 영향 없음
3. **마이그레이션 없음**: 자동 마이그레이션 진행하지 않음
### 데이터베이스 마이그레이션
`db/migrations/unified_component_schema.sql` 파일은 아직 실행되지 않았습니다.
필요시 수동으로 실행해야 합니다:
```bash
psql -h localhost -U postgres -d plm_db -f db/migrations/unified_component_schema.sql
```
---
## 다음 단계 (선택)
1. **화면 관리 에디터 통합**: Unified 컴포넌트를 화면 에디터의 컴포넌트 팔레트에 추가
2. **기존 비즈니스 컴포넌트 연동**: UnifiedBiz의 플레이스홀더를 실제 구현으로 교체
3. **테스트 페이지 작성**: 모든 Unified 컴포넌트 데모 페이지
4. **문서화**: 각 컴포넌트별 상세 사용 가이드
---
## 관련 문서
- `PLAN_RENEWAL.md`: 리뉴얼 계획서
- `docs/phase0-component-usage-analysis.md`: 컴포넌트 사용 현황 분석
- `docs/phase0-migration-strategy.md`: 마이그레이션 전략 (참고용)

View File

@ -589,3 +589,5 @@ const result = await executeNodeFlow(flowId, {

View File

@ -595,3 +595,5 @@ POST /multilang/keys/123/override
| 1.0 | 2026-01-13 | AI | 최초 작성 |

View File

@ -362,3 +362,5 @@

View File

@ -348,3 +348,5 @@ const getComponentValue = (componentId: string) => {

View File

@ -0,0 +1,211 @@
# 집계 위젯 (Aggregation Widget) 개발 진행상황
## 개요
데이터의 합계, 평균, 개수, 최대값, 최소값 등을 집계하여 표시하는 위젯
## 파일 위치
- **V2 버전**: `frontend/lib/registry/components/v2-aggregation-widget/`
- `index.ts` - 컴포넌트 정의
- `types.ts` - 타입 정의
- `AggregationWidgetComponent.tsx` - 메인 컴포넌트
- `AggregationWidgetConfigPanel.tsx` - 설정 패널
- `AggregationWidgetRenderer.tsx` - 렌더러
- **기존 버전**: `frontend/lib/registry/components/aggregation-widget/`
---
## 완료된 기능
### 1. 기본 집계 기능
- [x] 테이블 데이터 조회 및 집계 (SUM, AVG, COUNT, MAX, MIN)
- [x] 숫자형 컬럼 자동 감지 (`inputType` / `webType` 기반)
- [x] 집계 결과 포맷팅 (숫자, 통화, 퍼센트)
- [x] 가로/세로 레이아웃 지원
### 2. 데이터 소스 타입
- [x] `table` - 테이블에서 직접 조회
- [x] `component` - 다른 컴포넌트(리피터 등)에서 데이터 수신
- [x] `selection` - 선택된 행 데이터로 집계
### 3. 필터 조건
- [x] 필터 추가/삭제/활성화 UI
- [x] 연산자: =, !=, >, >=, <, <=, LIKE, IN, IS NULL, IS NOT NULL
- [x] 필터 결합 방식: AND / OR
- [x] 값 소스 타입:
- [x] `static` - 고정값 입력
- [x] `formField` - 폼 필드에서 가져오기
- [x] `selection` - 선택된 행에서 가져오기 (부분 완료)
- [x] `urlParam` - URL 파라미터에서 가져오기
- [x] 카테고리 타입 컬럼 - 콤보박스로 값 선택
### 4. 자동 새로고침
- [x] `autoRefresh` - 주기적 새로고침
- [x] `refreshInterval` - 새로고침 간격 (초)
- [x] `refreshOnFormChange` - 폼 데이터 변경 시 새로고침
### 5. 스타일 설정
- [x] 배경색, 테두리, 패딩
- [x] 폰트 크기, 색상
- [x] 라벨/아이콘 표시 여부
---
## 미완료 기능
### 1. 선택 데이터 필터 - 소스 컴포넌트 연동 (진행중)
**현재 상태**:
- `FilterCondition``sourceComponentId` 필드 추가됨
- 설정 패널 UI에 소스 컴포넌트 선택 드롭다운 추가됨
- 소스 컴포넌트 컬럼 로딩 함수 구현됨
**문제점**:
- `screenComponents`가 빈 배열로 전달되어 소스 컴포넌트 목록이 표시되지 않음
- `allComponents``screenComponents` 변환이 `getComponentConfigPanel.tsx`에서 수행되지만, 실제 컴포넌트 목록이 비어있음
**해결 필요 사항**:
1. `UnifiedPropertiesPanel`에서 `allComponents`가 제대로 전달되는지 확인
2. `getComponentConfigPanel.tsx`에서 `screenComponents` 변환 로직 디버깅
3. 필터링 조건 확인 (table-list, v2-table-list, unified-repeater 등)
**관련 코드**:
```typescript
// types.ts - FilterCondition
export interface FilterCondition {
// ...
sourceComponentId?: string; // 소스 컴포넌트 ID (NEW)
sourceColumnName?: string; // 소스 컬럼명
// ...
}
// AggregationWidgetConfigPanel.tsx
const selectableComponents = useMemo(() => {
return screenComponents.filter(comp =>
comp.componentType === "table-list" ||
comp.componentType === "v2-table-list" ||
// ...
);
}, [screenComponents]);
```
### 2. 런타임 선택 데이터 연동
**현재 상태**:
- `applyFilters` 함수에서 `selectedRows`를 사용하여 필터링
- 하지만 특정 컴포넌트(`sourceComponentId`)의 선택 데이터를 가져오는 로직 미구현
**해결 필요 사항**:
1. 각 컴포넌트별 선택 데이터를 관리하는 글로벌 상태 또는 이벤트 시스템 구현
2. `selectionChange` 이벤트에서 `componentId`별로 선택 데이터 저장
3. `applyFilters`에서 `sourceComponentId`에 해당하는 선택 데이터 사용
**예상 구현**:
```typescript
// 컴포넌트별 선택 데이터 저장 (전역 상태)
const componentSelections = useRef<Record<string, any[]>>({});
// 이벤트 리스너
window.addEventListener("selectionChange", (event) => {
const { componentId, selectedData } = event.detail;
componentSelections.current[componentId] = selectedData;
});
// 필터 적용 시
case "selection":
const sourceData = componentSelections.current[filter.sourceComponentId];
compareValue = sourceData?.[0]?.[filter.sourceColumnName];
break;
```
### 3. 리피터 컨테이너 내부 집계
**시나리오**:
- 리피터 컨테이너 내부에 집계 위젯 배치
- 각 반복 아이템별로 다른 집계 결과 표시
**현재 상태**:
- 리피터가 `formData`에 현재 아이템 데이터를 전달
- 필터에서 `valueSourceType: "formField"`를 사용하면 현재 아이템 기준 필터링 가능
- 테스트 미완료
**테스트 필요 케이스**:
1. 카테고리 리스트 리피터 + 집계 위젯 (해당 카테고리 상품 개수)
2. 주문 리스트 리피터 + 집계 위젯 (해당 주문의 상품 금액 합계)
---
## 사용 예시
### 기본 사용 (테이블 전체 집계)
```
데이터 소스: 테이블 → sales_order
집계 항목:
- 총 금액 (SUM of amount)
- 주문 건수 (COUNT)
- 평균 금액 (AVG of amount)
```
### 필터 사용 (조건부 집계)
```
데이터 소스: 테이블 → sales_order
필터 조건:
- status = '완료'
- order_date >= 2026-01-01
집계 항목:
- 완료 주문 금액 합계
```
### 선택 데이터 연동 (목표)
```
좌측: 품목 테이블 리스트 (item_mng)
우측: 집계 위젯
데이터 소스: 테이블 → sales_order
필터 조건:
- 컬럼: item_code
- 연산자: 같음 (=)
- 값 소스: 선택된 행
- 소스 컴포넌트: 품목 리스트
- 소스 컬럼: item_code
→ 품목 선택 시 해당 품목의 수주 금액 합계 표시
```
---
## 디버깅 로그
현재 설정 패널에 다음 로그가 추가되어 있음:
```typescript
console.log("[AggregationWidget] screenComponents:", screenComponents);
console.log("[AggregationWidget] selectableComponents:", filtered);
```
---
## 다음 단계
1. **소스 컴포넌트 목록 표시 문제 해결**
- `allComponents` 전달 경로 추적
- `screenComponents` 변환 로직 확인
2. **컴포넌트별 선택 데이터 관리 구현**
- 글로벌 상태 또는 Context 사용
- `selectionChange` 이벤트 표준화
3. **리피터 내부 집계 테스트**
- `formField` 필터로 현재 아이템 기준 집계 확인
4. **디버깅 로그 제거**
- 개발 완료 후 콘솔 로그 정리
---
## 관련 파일
- `frontend/lib/utils/getComponentConfigPanel.tsx` - `screenComponents` 변환
- `frontend/components/screen/panels/UnifiedPropertiesPanel.tsx` - `allComponents` 전달
- `frontend/components/screen/ScreenDesigner.tsx` - `layout.components` 전달

View File

@ -0,0 +1,339 @@
# 입력 컴포넌트 분석 및 통합 계획
> 작성일: 2024-12-23
> 상태: 1차 정리 완료
## 분석 대상 컴포넌트 목록
| 번호 | 컴포넌트 ID | 한글명 | 패널 표시 | 통합 대상 |
|------|-------------|--------|----------|----------|
| 1 | rack-structure | 렉 구조 설정 | 숨김 | UnifiedBiz (rack) |
| 2 | mail-recipient-selector | 메일 수신자 선택 | 숨김 | DataFlow 전용 |
| 3 | repeater-field-group | 반복 필드 그룹 | 숨김 | 현재 사용 안함 |
| 4 | universal-form-modal | 범용 폼 모달 | **유지** | 독립 유지 |
| 5 | selected-items-detail-input | 선택 항목 상세입력 | **유지** | 독립 유지 |
| 6 | entity-search-input | 엔티티 검색 입력 | 숨김 | UnifiedSelect (entity 모드) |
| 7 | image-widget | 이미지 위젯 | 숨김 | UnifiedMedia (image) |
| 8 | autocomplete-search-input | 자동완성 검색 입력 | 숨김 | UnifiedSelect (autocomplete 모드) |
| 9 | location-swap-selector | 출발지/도착지 선택 | **유지** | 독립 유지 |
| 10 | file-upload | 파일 업로드 | 숨김 | UnifiedMedia (file) |
---
## 1. 렉 구조 설정 (rack-structure)
### 현재 구현
- **위치**: `frontend/lib/registry/components/rack-structure/`
- **주요 기능**:
- 창고 렉 위치를 열 범위와 단 수로 일괄 생성
- 조건별 설정 (렉 라인, 열 범위, 단 수)
- 미리보기 및 통계 표시
- 템플릿 저장/불러오기
- **카테고리**: INPUT
- **크기**: 1200 x 800
### 분석
- WMS(창고관리) 전용 특수 컴포넌트
- 복잡한 비즈니스 로직 포함 (위치 코드 자동 생성)
- formData 컨텍스트 의존 (창고ID, 층, 구역 등)
### 통합 방안
- **결정**: `UnifiedBiz` 컴포넌트의 `rack` 비즈니스 타입으로 통합
- **이유**: 비즈니스 특화 컴포넌트이므로 UnifiedBiz가 적합
- **작업**:
- UnifiedBiz에서 bizType="rack" 선택 시 RackStructureComponent 렌더링
- 설정 패널 통합
---
## 2. 메일 수신자 선택 (mail-recipient-selector)
### 현재 구현
- **위치**: `frontend/lib/registry/components/mail-recipient-selector/`
- **주요 기능**:
- 내부 인원 선택 (user_info 테이블)
- 외부 이메일 직접 입력
- 수신자(To) / 참조(CC) 구분
- **카테고리**: INPUT
- **크기**: 400 x 200
### 분석
- 메일 발송 워크플로우 전용 컴포넌트
- 내부 사용자 검색 + 외부 이메일 입력 복합 기능
- DataFlow 노드에서 참조됨 (EmailActionProperties)
### 통합 방안
- **결정**: **독립 유지**
- **이유**:
- 메일 시스템 전용 복합 기능
- 다른 컴포넌트와 기능이 겹치지 않음
- DataFlow와의 긴밀한 연동
---
## 3. 반복 필드 그룹 (repeater-field-group)
### 현재 구현
- **위치**: `frontend/components/webtypes/RepeaterInput.tsx`, `frontend/components/webtypes/config/RepeaterConfigPanel.tsx`
- **주요 기능**:
- 동적 항목 추가/제거
- 다양한 필드 타입 지원 (text, number, select, category, calculated 등)
- 계산식 필드 (합계, 평균 등)
- 레이아웃 옵션 (grid, table, card)
- 드래그앤드롭 순서 변경
- **카테고리**: INPUT
- **크기**: 화면 설정에 따라 동적
### 분석
- 매우 복잡한 컴포넌트 (943줄)
- 견적서, 주문서 등 반복 입력이 필요한 화면에서 핵심 역할
- 카테고리 매핑, 계산식, 반응형 지원
### 통합 방안
- **결정**: **독립 유지**
- **이유**:
- 너무 복잡하고 기능이 방대함
- 이미 잘 동작하고 있음
- 통합 시 오히려 유지보수 어려워짐
---
## 4. 범용 폼 모달 (universal-form-modal)
### 현재 구현
- **위치**: `frontend/lib/registry/components/universal-form-modal/`
- **주요 기능**:
- 섹션 기반 폼 레이아웃
- 반복 섹션 (겸직 등록 등)
- 채번규칙 연동
- 다중 행 저장
- 외부 데이터 수신
- **카테고리**: INPUT
- **크기**: 800 x 600
### 분석
- ScreenModal, SaveModal과 기능 중복 가능성
- 섹션 기반 레이아웃이 핵심 차별점
- 복잡한 입력 시나리오 지원
### 통합 방안
- **결정**: `UnifiedGroup``formModal` 타입으로 통합 검토
- **현실적 접근**:
- 당장 통합보다는 ScreenModal 시스템과의 차별화 유지
- 향후 섹션 기반 레이아웃 기능을 ScreenModal에 반영
---
## 5. 선택 항목 상세입력 (selected-items-detail-input)
### 현재 구현
- **위치**: `frontend/lib/registry/components/selected-items-detail-input/`
- **주요 기능**:
- 선택된 데이터 목록 표시
- 각 항목별 추가 필드 입력
- 레이아웃 옵션 (grid, table)
- **카테고리**: INPUT
- **크기**: 800 x 400
### 분석
- RepeatScreenModal과 연계되는 컴포넌트
- 선택된 항목에 대한 상세 정보 일괄 입력 용도
- 특수한 사용 사례 (품목 선택 후 수량 입력 등)
### 통합 방안
- **결정**: **독립 유지**
- **이유**:
- 특수한 워크플로우 지원
- 다른 컴포넌트와 기능 중복 없음
---
## 6. 엔티티 검색 입력 (entity-search-input)
### 현재 구현
- **위치**: `frontend/lib/registry/components/entity-search-input/`
- **주요 기능**:
- 콤보박스 모드 (inline)
- 모달 검색 모드
- 추가 필드 표시 옵션
- **카테고리**: INPUT
- **크기**: 300 x 40
- **webType**: entity
### 분석
- UnifiedSelect의 entity 모드와 기능 중복
- 모달 검색 기능이 차별점
- EntityWidget과도 유사
### 통합 방안
- **결정**: `UnifiedSelect` entity 모드로 통합
- **작업**:
- UnifiedSelect에 `searchMode: "modal" | "inline" | "autocomplete"` 옵션 추가
- 모달 검색 UI 통합
- 기존 entity-search-input은 deprecated 처리
---
## 7. 이미지 위젯 (image-widget)
### 현재 구현
- **위치**: `frontend/lib/registry/components/image-widget/`
- **주요 기능**:
- 이미지 업로드
- 미리보기
- 드래그앤드롭 지원
- **카테고리**: INPUT
- **크기**: 200 x 200
- **webType**: image
### 분석
- UnifiedMedia의 ImageUploader와 기능 동일
- 이미 ImageWidget 컴포넌트 재사용 중
### 통합 방안
- **결정**: `UnifiedMedia` image 타입으로 통합 완료
- **상태**: 이미 UnifiedMedia.ImageUploader로 구현됨
- **작업**:
- 컴포넌트 패널에서 image-widget 제거
- UnifiedMedia 사용 권장
---
## 8. 자동완성 검색 입력 (autocomplete-search-input)
### 현재 구현
- **위치**: `frontend/lib/registry/components/autocomplete-search-input/`
- **주요 기능**:
- 타이핑 시 드롭다운 검색
- 엔티티 테이블 연동
- 추가 필드 표시
- **카테고리**: INPUT
- **크기**: 300 x 40
- **webType**: entity
### 분석
- entity-search-input과 유사하지만 UI 방식이 다름
- Command/Popover 기반 자동완성
### 통합 방안
- **결정**: `UnifiedSelect` entity 모드의 autocomplete 옵션으로 통합
- **작업**:
- UnifiedSelect에서 `searchMode: "autocomplete"` 옵션 추가
- 자동완성 검색 로직 통합
---
## 9. 출발지/도착지 선택 (location-swap-selector)
### 현재 구현
- **위치**: `frontend/lib/registry/components/location-swap-selector/`
- **주요 기능**:
- 출발지/도착지 두 개 필드 동시 관리
- 스왑 버튼으로 교환
- 모바일 최적화 UI
- 다양한 데이터 소스 (table, code, static)
- **카테고리**: INPUT
- **크기**: 400 x 100
### 분석
- 물류/운송 시스템 전용 컴포넌트
- 두 개의 Select를 묶은 복합 컴포넌트
- 스왑 기능이 핵심
### 통합 방안
- **결정**: **독립 유지**
- **이유**:
- 특수 용도 (물류 시스템)
- 다른 컴포넌트와 기능 중복 없음
- 복합 필드 관리 (출발지 + 도착지)
---
## 10. 파일 업로드 (file-upload)
### 현재 구현
- **위치**: `frontend/lib/registry/components/file-upload/`
- **주요 기능**:
- 파일 선택/업로드
- 드래그앤드롭
- 업로드 진행률 표시
- 파일 목록 관리
- **카테고리**: INPUT
- **크기**: 350 x 240
- **webType**: file
### 분석
- UnifiedMedia의 FileUploader와 기능 동일
- attach_file_info 테이블 연동
### 통합 방안
- **결정**: `UnifiedMedia` file 타입으로 통합
- **상태**: 이미 UnifiedMedia.FileUploader로 구현됨
- **작업**:
- 컴포넌트 패널에서 file-upload 제거
- UnifiedMedia 사용 권장
---
## 통합 우선순위 및 작업 계획
### Phase 1: 즉시 통합 가능 (작업 최소)
| 컴포넌트 | 통합 대상 | 예상 작업량 | 비고 |
|----------|----------|------------|------|
| image-widget | UnifiedMedia (image) | 1일 | 이미 구현됨, 패널에서 숨기기만 |
| file-upload | UnifiedMedia (file) | 1일 | 이미 구현됨, 패널에서 숨기기만 |
### Phase 2: 기능 통합 필요 (중간 작업)
| 컴포넌트 | 통합 대상 | 예상 작업량 | 비고 |
|----------|----------|------------|------|
| entity-search-input | UnifiedSelect (entity) | 3일 | 모달 검색 모드 추가 |
| autocomplete-search-input | UnifiedSelect (entity) | 2일 | autocomplete 모드 추가 |
| rack-structure | UnifiedBiz (rack) | 2일 | 비즈니스 타입 연결 |
### Phase 3: 독립 유지 (작업 없음)
| 컴포넌트 | 이유 |
|----------|------|
| mail-recipient-selector | 메일 시스템 전용 |
| repeater-field-group | 너무 복잡, 잘 동작 중 |
| universal-form-modal | ScreenModal과 차별화 필요 |
| selected-items-detail-input | 특수 워크플로우 |
| location-swap-selector | 물류 시스템 전용 |
---
## 결론
### 즉시 실행 가능한 작업
1. **ComponentsPanel 정리**:
- `image-widget`, `file-upload` 숨김 처리 (UnifiedMedia 사용)
- 중복 컴포넌트 정리
2. **UnifiedBiz 연결**:
- `bizType: "rack"` 선택 시 `RackStructureComponent` 렌더링 연결
### 향후 계획
1. UnifiedSelect에 entity 검색 모드 통합
2. UnifiedMedia 설정 패널 강화
3. 독립 유지 컴포넌트들의 문서화
---
## 컴포넌트 패널 정리 제안
### 숨길 컴포넌트 (Unified로 대체됨)
- `image-widget` → UnifiedMedia 사용
- `file-upload` → UnifiedMedia 사용
- `entity-search-input` → UnifiedSelect (entity 모드) 사용 예정
- `autocomplete-search-input` → UnifiedSelect (autocomplete 모드) 사용 예정
### 유지할 컴포넌트 (독립 기능)
- `rack-structure` - WMS 전용 (UnifiedBiz 연결 예정)
- `mail-recipient-selector` - 메일 시스템 전용
- `repeater-field-group` - 반복 입력 전용
- `universal-form-modal` - 복잡한 폼 전용
- `selected-items-detail-input` - 상세 입력 전용
- `location-swap-selector` - 물류 시스템 전용

View File

@ -4,12 +4,13 @@ import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList } from "lucide-react";
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2 } from "lucide-react";
import ScreenList from "@/components/screen/ScreenList";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import TemplateManager from "@/components/screen/TemplateManager";
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow";
import { UnifiedComponentsDemo } from "@/components/unified";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
@ -17,7 +18,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import CreateScreenModal from "@/components/screen/CreateScreenModal";
// 단계별 진행을 위한 타입 정의
type Step = "list" | "design" | "template";
type Step = "list" | "design" | "template" | "unified-test";
type ViewMode = "tree" | "table";
export default function ScreenManagementPage() {
@ -130,6 +131,15 @@ export default function ScreenManagementPage() {
);
}
// Unified 컴포넌트 테스트 모드
if (currentStep === "unified-test") {
return (
<div className="fixed inset-0 z-50 bg-background">
<UnifiedComponentsDemo onBack={() => goToStep("list")} />
</div>
);
}
return (
<div className="flex h-screen flex-col bg-background overflow-hidden">
{/* 페이지 헤더 */}
@ -140,6 +150,15 @@ export default function ScreenManagementPage() {
<p className="text-sm text-muted-foreground"> </p>
</div>
<div className="flex items-center gap-2">
{/* Unified 컴포넌트 테스트 버튼 */}
<Button
variant="outline"
onClick={() => goToNextStep("unified-test")}
className="gap-2"
>
<TestTube2 className="h-4 w-4" />
Unified
</Button>
{/* 뷰 모드 전환 */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<TabsList className="h-9">

View File

@ -23,6 +23,8 @@ import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/c
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조건부 표시 평가
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
function ScreenViewPage() {
const params = useParams();
@ -113,7 +115,7 @@ function ScreenViewPage() {
// 편집 모달 이벤트 리스너 등록
useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => {
console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
setEditModalConfig({
screenId: event.detail.screenId,
@ -227,6 +229,67 @@ function ScreenViewPage() {
initAutoFill();
}, [layout, user]);
// 🆕 조건부 비활성화/숨김 시 해당 필드 값 초기화
// 조건 필드들의 값을 추적하여 변경 시에만 실행
const conditionalFieldValues = useMemo(() => {
if (!layout?.components) return "";
// 조건부 설정에 사용되는 필드들의 현재 값을 JSON 문자열로 만들어 비교
const conditionFields = new Set<string>();
layout.components.forEach((component) => {
const conditional = (component as any).conditional;
if (conditional?.enabled && conditional.field) {
conditionFields.add(conditional.field);
}
});
const values: Record<string, any> = {};
conditionFields.forEach((field) => {
values[field] = (formData as Record<string, any>)[field];
});
return JSON.stringify(values);
}, [layout?.components, formData]);
useEffect(() => {
if (!layout?.components) return;
const fieldsToReset: string[] = [];
layout.components.forEach((component) => {
const conditional = (component as any).conditional;
if (!conditional?.enabled) return;
const conditionalResult = evaluateConditional(
conditional,
formData as Record<string, any>,
layout.components,
);
// 숨김 또는 비활성화 상태인 경우
if (!conditionalResult.visible || conditionalResult.disabled) {
const fieldName = (component as any).columnName || component.id;
const currentValue = (formData as Record<string, any>)[fieldName];
// 값이 있으면 초기화 대상에 추가
if (currentValue !== undefined && currentValue !== "" && currentValue !== null) {
fieldsToReset.push(fieldName);
}
}
});
// 초기화할 필드가 있으면 한 번에 처리
if (fieldsToReset.length > 0) {
setFormData((prev) => {
const updated = { ...prev };
fieldsToReset.forEach((fieldName) => {
updated[fieldName] = "";
});
return updated;
});
}
}, [conditionalFieldValues, layout?.components]);
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 초기 로딩 시에만 계산
// 브라우저 배율 조정 시 메뉴와 화면이 함께 축소/확대되도록 resize 이벤트는 감지하지 않음
useEffect(() => {
@ -264,8 +327,8 @@ function ScreenViewPage() {
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율
} else {
// 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정)
const MARGIN_X = 32;
const availableWidth = containerWidth - MARGIN_X;
const MARGIN_X = 32;
const availableWidth = containerWidth - MARGIN_X;
newScale = availableWidth / designWidth;
}
@ -345,9 +408,10 @@ function ScreenViewPage() {
{/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */}
{layoutReady && layout && layout.components.length > 0 ? (
<div
className="bg-background relative"
style={{
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
<div
className="bg-background relative"
style={{
width: `${screenWidth}px`,
height: `${screenHeight}px`,
minWidth: `${screenWidth}px`,
@ -425,8 +489,73 @@ function ScreenViewPage() {
(c as any).componentType === "conditional-container",
);
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정
const adjustedComponents = regularComponents.map((component) => {
// 🆕 같은 X 영역(섹션)에서 컴포넌트들이 겹치지 않도록 자동 수직 정렬
const autoLayoutComponents = (() => {
// X 위치 기준으로 섹션 그룹화 (50px 오차 범위)
const X_THRESHOLD = 50;
const GAP = 16; // 컴포넌트 간 간격
// 컴포넌트를 X 섹션별로 그룹화
const sections: Map<number, typeof regularComponents> = new Map();
regularComponents.forEach((comp) => {
const x = comp.position.x;
let foundSection = false;
for (const [sectionX, components] of sections.entries()) {
if (Math.abs(x - sectionX) < X_THRESHOLD) {
components.push(comp);
foundSection = true;
break;
}
}
if (!foundSection) {
sections.set(x, [comp]);
}
});
// 각 섹션 내에서 Y 위치 순으로 정렬 후 자동 배치
const adjustedMap = new Map<string, typeof regularComponents[0]>();
for (const [sectionX, components] of sections.entries()) {
// 섹션 내 2개 이상 컴포넌트가 있을 때만 자동 배치
if (components.length >= 2) {
// Y 위치 순으로 정렬
const sorted = [...components].sort((a, b) => a.position.y - b.position.y);
let currentY = sorted[0].position.y;
sorted.forEach((comp, index) => {
if (index === 0) {
adjustedMap.set(comp.id, comp);
} else {
// 이전 컴포넌트 아래로 배치
const prevComp = sorted[index - 1];
const prevAdjusted = adjustedMap.get(prevComp.id) || prevComp;
const prevBottom = prevAdjusted.position.y + (prevAdjusted.size?.height || 100);
const newY = prevBottom + GAP;
adjustedMap.set(comp.id, {
...comp,
position: {
...comp.position,
y: newY,
},
});
}
});
} else {
// 단일 컴포넌트는 그대로
components.forEach((comp) => adjustedMap.set(comp.id, comp));
}
}
return regularComponents.map((comp) => adjustedMap.get(comp.id) || comp);
})();
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 추가 조정
const adjustedComponents = autoLayoutComponents.map((component) => {
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
const isConditionalContainer = (component as any).componentId === "conditional-container";
@ -447,30 +576,15 @@ function ScreenViewPage() {
}
}
// 🆕 조건부 컨테이너 높이 조정
// 조건부 컨테이너 높이 조정
for (const container of conditionalContainers) {
const isBelow = component.position.y > container.position.y;
const actualHeight = conditionalContainerHeights[container.id];
const originalHeight = container.size?.height || 200;
const heightDiff = actualHeight ? actualHeight - originalHeight : 0;
console.log(`🔍 높이 조정 체크:`, {
componentId: component.id,
componentY: component.position.y,
containerY: container.position.y,
isBelow,
actualHeight,
originalHeight,
heightDiff,
containerId: container.id,
containerSize: container.size,
});
if (isBelow && heightDiff > 0) {
totalHeightAdjustment += heightDiff;
console.log(
`📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`,
);
}
}
@ -491,9 +605,30 @@ function ScreenViewPage() {
<>
{/* 일반 컴포넌트들 */}
{adjustedComponents.map((component) => {
// 조건부 표시 설정이 있는 경우에만 평가
const conditional = (component as any).conditional;
let conditionalDisabled = false;
if (conditional?.enabled) {
const conditionalResult = evaluateConditional(
conditional,
formData as Record<string, any>,
layout?.components || [],
);
// 조건에 따라 숨김 처리
if (!conditionalResult.visible) {
return null;
}
// 조건에 따라 비활성화 처리
conditionalDisabled = conditionalResult.disabled;
}
// 화면 관리 해상도를 사용하므로 위치 조정 불필요
return (
<RealtimePreview
conditionalDisabled={conditionalDisabled}
key={component.id}
component={component}
isSelected={false}
@ -769,7 +904,8 @@ function ScreenViewPage() {
</>
);
})()}
</div>
</div>
</ScreenMultiLangProvider>
) : (
// 빈 화면일 때
<div className="bg-background flex items-center justify-center" style={{ minHeight: screenHeight }}>

View File

@ -141,21 +141,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
selectedIds,
} = event.detail;
console.log("📦 [ScreenModal] 모달 열기 이벤트 수신:", {
screenId,
title,
selectedData: eventSelectedData,
selectedIds,
});
// 🆕 모달 열린 시간 기록
modalOpenedAtRef.current = Date.now();
console.log("🕐 [ScreenModal] 모달 열림 시간 기록:", modalOpenedAtRef.current);
// 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용)
if (eventSelectedData && Array.isArray(eventSelectedData)) {
setSelectedData(eventSelectedData);
console.log("📦 [ScreenModal] 선택된 데이터 저장:", eventSelectedData.length, "건");
} else {
setSelectedData([]);
}
@ -168,22 +159,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
});
// pushState로 URL 변경 (페이지 새로고침 없이)
window.history.pushState({}, "", currentUrl.toString());
console.log("✅ URL 파라미터 추가:", urlParams);
}
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
if (editData) {
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
// 🆕 배열인 경우 두 가지 데이터를 설정:
// 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등)
// 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등)
if (Array.isArray(editData)) {
const firstRecord = editData[0] || {};
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정:`, {
formData: "첫 번째 레코드 (일반 입력 필드용)",
selectedData: "전체 배열 (다중 항목 컴포넌트용)",
});
setFormData(firstRecord); // 🔧 일반 입력 필드용 (객체)
setSelectedData(editData); // 🔧 다중 항목 컴포넌트용 (배열) - groupedData로 전달됨
setOriginalData(firstRecord); // 첫 번째 레코드를 원본으로 저장
@ -220,9 +204,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const sourceValue = rawParentData[mapping.sourceColumn];
if (sourceValue !== undefined && sourceValue !== null) {
parentData[mapping.targetColumn] = sourceValue;
console.log(
`🔗 [ScreenModal] 매핑 필드 전달: ${mapping.sourceColumn}${mapping.targetColumn} = ${sourceValue}`,
);
}
}
@ -247,13 +228,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
parentData[key] = value;
console.log(`🔗 [ScreenModal] 연결 필드 자동 감지: ${key} = ${value}`);
}
}
}
if (Object.keys(parentData).length > 0) {
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정 (연결 필드만):", parentData);
setFormData(parentData);
} else {
setFormData({});
@ -277,7 +256,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// dataSourceId 파라미터 제거
currentUrl.searchParams.delete("dataSourceId");
window.history.pushState({}, "", currentUrl.toString());
// console.log("🧹 URL 파라미터 제거");
}
setModalState({
@ -292,8 +270,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setOriginalData(null); // 🆕 원본 데이터 초기화
setSelectedData([]); // 🆕 선택된 데이터 초기화
setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
// console.log("🔄 연속 모드 초기화: false");
localStorage.setItem("screenModal_continuousMode", "false");
};
// 저장 성공 이벤트 처리 (연속 등록 모드 지원)
@ -301,36 +278,24 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 🆕 모달이 열린 후 500ms 이내의 저장 성공 이벤트는 무시 (이전 이벤트 방지)
const timeSinceOpen = Date.now() - modalOpenedAtRef.current;
if (timeSinceOpen < 500) {
// console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen });
return;
}
const isContinuousMode = continuousMode;
// console.log("💾 저장 성공 이벤트 수신");
// console.log("📌 현재 연속 모드 상태:", isContinuousMode);
// console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
if (isContinuousMode) {
// 연속 모드: 폼만 초기화하고 모달은 유지
// console.log("✅ 연속 모드 활성화 - 폼 초기화 및 화면 리셋");
// 1. 폼 데이터 초기화
setFormData({});
// 2. 리셋 키 변경 (컴포넌트 강제 리마운트)
setResetKey((prev) => prev + 1);
// console.log("🔄 resetKey 증가 - 컴포넌트 리마운트");
// 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성)
// 화면 데이터 다시 로드 (채번 규칙 새로 생성)
if (modalState.screenId) {
// console.log("🔄 화면 데이터 다시 로드:", modalState.screenId);
loadScreenData(modalState.screenId);
}
toast.success("저장되었습니다. 계속 입력하세요.");
} else {
// 일반 모드: 모달 닫기
// console.log("❌ 일반 모드 - 모달 닫기");
handleCloseModal();
}
};
@ -357,16 +322,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
try {
setLoading(true);
console.log("화면 데이터 로딩 시작:", screenId);
// 화면 정보와 레이아웃 데이터 로딩
const [screenInfo, layoutData] = await Promise.all([
screenApi.getScreen(screenId),
screenApi.getLayout(screenId),
]);
console.log("API 응답:", { screenInfo, layoutData });
// 🆕 URL 파라미터 확인 (수정 모드)
if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search);
@ -375,36 +336,19 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const tableName = urlParams.get("tableName") || screenInfo.tableName;
const groupByColumnsParam = urlParams.get("groupByColumns");
console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam });
// 수정 모드이고 editId가 있으면 해당 레코드 조회
if (mode === "edit" && editId && tableName) {
try {
console.log("🔍 수정 데이터 조회 시작:", { tableName, editId, groupByColumnsParam });
const { dataApi } = await import("@/lib/api/data");
// groupByColumns 파싱
let groupByColumns: string[] = [];
if (groupByColumnsParam) {
try {
groupByColumns = JSON.parse(groupByColumnsParam);
console.log("✅ [ScreenModal] groupByColumns 파싱 성공:", groupByColumns);
} catch (e) {
console.warn("groupByColumns 파싱 실패:", e);
} catch {
// groupByColumns 파싱 실패 시 무시
}
} else {
console.warn("⚠️ [ScreenModal] groupByColumnsParam이 없습니다!");
}
console.log("🚀 [ScreenModal] API 호출 직전:", {
tableName,
editId,
enableEntityJoin: true,
groupByColumns,
groupByColumnsLength: groupByColumns.length,
});
// 🆕 apiClient를 named import로 가져오기
const { apiClient } = await import("@/lib/api/client");
const params: any = {
@ -412,37 +356,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
};
if (groupByColumns.length > 0) {
params.groupByColumns = JSON.stringify(groupByColumns);
console.log("✅ [ScreenModal] groupByColumns를 params에 추가:", params.groupByColumns);
}
console.log("📡 [ScreenModal] 실제 API 요청:", {
url: `/data/${tableName}/${editId}`,
params,
});
const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params });
const response = apiResponse.data;
console.log("📩 [ScreenModal] API 응답 받음:", {
success: response.success,
hasData: !!response.data,
dataType: response.data ? (Array.isArray(response.data) ? "배열" : "객체") : "없음",
dataLength: Array.isArray(response.data) ? response.data.length : 1,
});
if (response.success && response.data) {
// 배열인 경우 (그룹핑) vs 단일 객체
const isArray = Array.isArray(response.data);
if (isArray) {
console.log(`✅ 수정 데이터 로드 완료 (그룹 레코드: ${response.data.length}개)`);
console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2));
} else {
console.log("✅ 수정 데이터 로드 완료 (필드 수:", Object.keys(response.data).length, ")");
console.log("📊 모든 필드 키:", Object.keys(response.data));
console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2));
}
// 🔧 날짜 필드 정규화 (타임존 제거)
const normalizeDates = (data: any): any => {
if (Array.isArray(data)) {
@ -457,10 +376,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
for (const [key, value] of Object.entries(data)) {
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
// ISO 날짜 형식 감지: YYYY-MM-DD만 추출
const before = value;
const after = value.split("T")[0];
console.log(`🔧 [날짜 정규화] ${key}: ${before}${after}`);
normalized[key] = after;
normalized[key] = value.split("T")[0];
} else {
normalized[key] = value;
}
@ -468,31 +384,21 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
return normalized;
};
console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2));
const normalizedData = normalizeDates(response.data);
console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2));
// 🔧 배열 데이터는 formData로 설정하지 않음 (SelectedItemsDetailInput만 사용)
if (Array.isArray(normalizedData)) {
console.log(
"⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.",
);
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
setOriginalData(normalizedData[0] || null); // 🆕 첫 번째 레코드를 원본으로 저장
} else {
setFormData(normalizedData);
setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
}
// setFormData 직후 확인
console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)");
console.log("🔄 setOriginalData 호출 완료 (UPDATE 판단용)");
} else {
console.error("❌ 수정 데이터 로드 실패:", response.error);
toast.error("데이터를 불러올 수 없습니다.");
}
} catch (error) {
console.error("수정 데이터 조회 오류:", error);
console.error("수정 데이터 조회 오류:", error);
toast.error("데이터를 불러오는 중 오류가 발생했습니다.");
}
}
@ -514,11 +420,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
offsetX: 0,
offsetY: 0,
};
console.log("✅ 화면 관리 해상도 사용:", dimensions);
} else {
// 해상도 정보가 없으면 자동 계산
dimensions = calculateScreenDimensions(components);
console.log("⚠️ 자동 계산된 크기 사용:", dimensions);
}
setScreenDimensions(dimensions);
@ -527,11 +431,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
components,
screenInfo: screenInfo,
});
console.log("화면 데이터 설정 완료:", {
componentsCount: components.length,
dimensions,
screenInfo,
});
} else {
throw new Error("화면 데이터가 없습니다");
}
@ -553,7 +452,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
currentUrl.searchParams.delete("tableName");
currentUrl.searchParams.delete("groupByColumns");
window.history.pushState({}, "", currentUrl.toString());
console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)");
}
setModalState({
@ -711,15 +609,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
},
};
// 🆕 formData 전달 확인 로그
console.log("📝 [ScreenModal] InteractiveScreenViewerDynamic에 formData 전달:", {
componentId: component.id,
componentType: component.type,
componentComponentType: (component as any).componentType, // 🆕 실제 componentType 확인
hasFormData: !!formData,
formDataKeys: formData ? Object.keys(formData) : [],
});
return (
<InteractiveScreenViewerDynamic
key={`${component.id}-${resetKey}`}
@ -728,19 +617,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
formData={formData}
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={(fieldName, value) => {
console.log("🔧 [ScreenModal] onFormDataChange 호출:", { fieldName, value });
setFormData((prev) => {
const newFormData = {
...prev,
[fieldName]: value,
};
console.log("🔧 [ScreenModal] formData 업데이트:", { prev, newFormData });
return newFormData;
});
}}
onRefresh={() => {
// 부모 화면의 테이블 새로고침 이벤트 발송
console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송");
window.dispatchEvent(new CustomEvent("refreshTable"));
}}
screenInfo={{
@ -774,7 +660,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const isChecked = checked === true;
setContinuousMode(isChecked);
localStorage.setItem("screenModal_continuousMode", String(isChecked));
console.log("🔄 연속 모드 변경:", isChecked);
}}
/>
<Label htmlFor="continuous-mode" className="cursor-pointer text-sm font-normal select-none">

View File

@ -1,10 +1,17 @@
"use client";
import React from "react";
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { CodePartType, DATE_FORMAT_OPTIONS } from "@/types/numbering-rule";
import { tableManagementApi } from "@/lib/api/tableManagement";
interface AutoConfigPanelProps {
partType: CodePartType;
@ -13,6 +20,18 @@ interface AutoConfigPanelProps {
isPreview?: boolean;
}
interface TableInfo {
tableName: string;
displayName: string;
}
interface ColumnInfo {
columnName: string;
displayName: string;
dataType: string;
inputType?: string;
}
export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
partType,
config = {},
@ -104,28 +123,11 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
// 3. 날짜
if (partType === "date") {
return (
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={config.dateFormat || "YYYYMMDD"}
onValueChange={(value) => onChange({ ...config, dateFormat: value })}
disabled={isPreview}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMAT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
{option.label} ({option.example})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
<DateConfigPanel
config={config}
onChange={onChange}
isPreview={isPreview}
/>
);
}
@ -150,3 +152,314 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
return null;
};
/**
*
* -
* -
*/
interface DateConfigPanelProps {
config?: any;
onChange: (config: any) => void;
isPreview?: boolean;
}
const DateConfigPanel: React.FC<DateConfigPanelProps> = ({
config = {},
onChange,
isPreview = false,
}) => {
// 테이블 목록
const [tables, setTables] = useState<TableInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
// 컬럼 목록
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [columnComboboxOpen, setColumnComboboxOpen] = useState(false);
// 체크박스 상태
const useColumnValue = config.useColumnValue || false;
const sourceTableName = config.sourceTableName || "";
const sourceColumnName = config.sourceColumnName || "";
// 테이블 목록 로드
useEffect(() => {
if (useColumnValue && tables.length === 0) {
loadTables();
}
}, [useColumnValue]);
// 테이블 변경 시 컬럼 로드
useEffect(() => {
if (sourceTableName) {
loadColumns(sourceTableName);
} else {
setColumns([]);
}
}, [sourceTableName]);
const loadTables = async () => {
setLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
const tableList = response.data.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.displayName || t.table_label || t.tableName || t.table_name,
}));
setTables(tableList);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setLoadingTables(false);
}
};
const loadColumns = async (tableName: string) => {
setLoadingColumns(true);
try {
const response = await tableManagementApi.getColumnList(tableName);
if (response.success && response.data) {
const rawColumns = response.data?.columns || response.data;
// 날짜 타입 컬럼만 필터링
const dateColumns = (rawColumns as any[]).filter((col: any) => {
const inputType = col.inputType || col.input_type || "";
const dataType = (col.dataType || col.data_type || "").toLowerCase();
return (
inputType === "date" ||
inputType === "datetime" ||
dataType.includes("date") ||
dataType.includes("timestamp")
);
});
setColumns(
dateColumns.map((col: any) => ({
columnName: col.columnName || col.column_name,
displayName: col.displayName || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || "",
inputType: col.inputType || col.input_type || "",
}))
);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
} finally {
setLoadingColumns(false);
}
};
// 선택된 테이블/컬럼 라벨
const selectedTableLabel = useMemo(() => {
const found = tables.find((t) => t.tableName === sourceTableName);
return found ? `${found.displayName} (${found.tableName})` : "";
}, [tables, sourceTableName]);
const selectedColumnLabel = useMemo(() => {
const found = columns.find((c) => c.columnName === sourceColumnName);
return found ? `${found.displayName} (${found.columnName})` : "";
}, [columns, sourceColumnName]);
return (
<div className="space-y-3 sm:space-y-4">
{/* 날짜 형식 선택 */}
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={config.dateFormat || "YYYYMMDD"}
onValueChange={(value) => onChange({ ...config, dateFormat: value })}
disabled={isPreview}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMAT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
{option.label} ({option.example})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
{useColumnValue
? "선택한 컬럼의 날짜 값이 이 형식으로 변환됩니다"
: "현재 날짜가 자동으로 입력됩니다"}
</p>
</div>
{/* 컬럼 값 기준 생성 체크박스 */}
<div className="flex items-start gap-2">
<Checkbox
id="useColumnValue"
checked={useColumnValue}
onCheckedChange={(checked) => {
onChange({
...config,
useColumnValue: checked,
// 체크 해제 시 테이블/컬럼 초기화
...(checked ? {} : { sourceTableName: "", sourceColumnName: "" }),
});
}}
disabled={isPreview}
className="mt-0.5"
/>
<div className="flex-1">
<Label
htmlFor="useColumnValue"
className="cursor-pointer text-xs font-medium sm:text-sm"
>
</Label>
<p className="text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
</div>
{/* 테이블 선택 (체크 시 표시) */}
{useColumnValue && (
<>
<div>
<Label className="text-xs font-medium sm:text-sm"></Label>
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
disabled={isPreview || loadingTables}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{loadingTables
? "로딩 중..."
: sourceTableName
? selectedTableLabel
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => {
onChange({
...config,
sourceTableName: table.tableName,
sourceColumnName: "", // 테이블 변경 시 컬럼 초기화
});
setTableComboboxOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
sourceTableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName}</span>
<span className="text-[10px] text-gray-500">{table.tableName}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 컬럼 선택 */}
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Popover open={columnComboboxOpen} onOpenChange={setColumnComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={columnComboboxOpen}
disabled={isPreview || loadingColumns || !sourceTableName}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{loadingColumns
? "로딩 중..."
: !sourceTableName
? "테이블을 먼저 선택하세요"
: sourceColumnName
? selectedColumnLabel
: columns.length === 0
? "날짜 컬럼이 없습니다"
: "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
</CommandEmpty>
<CommandGroup>
{columns.map((column) => (
<CommandItem
key={column.columnName}
value={`${column.displayName} ${column.columnName}`}
onSelect={() => {
onChange({ ...config, sourceColumnName: column.columnName });
setColumnComboboxOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
sourceColumnName === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{column.displayName}</span>
<span className="text-[10px] text-gray-500">
{column.columnName} ({column.inputType || column.dataType})
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{sourceTableName && columns.length === 0 && !loadingColumns && (
<p className="mt-1 text-[10px] text-amber-600 sm:text-xs">
</p>
)}
</div>
</>
)}
</div>
);
};

View File

@ -479,18 +479,6 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</p>
</div>
{/* 세 번째 줄: 자동 감지된 테이블 정보 표시 */}
{currentTableName && (
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<div className="border-input bg-muted text-muted-foreground flex h-9 items-center rounded-md border px-3 text-sm">
{currentTableName}
</div>
<p className="text-muted-foreground text-xs">
({currentTableName})
</p>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">

View File

@ -44,6 +44,22 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
// 3. 날짜
case "date": {
const format = autoConfig.dateFormat || "YYYYMMDD";
// 컬럼 기준 생성인 경우 placeholder 표시
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
// 형식에 맞는 placeholder 반환
switch (format) {
case "YYYY": return "[YYYY]";
case "YY": return "[YY]";
case "YYYYMM": return "[YYYYMM]";
case "YYMM": return "[YYMM]";
case "YYYYMMDD": return "[YYYYMMDD]";
case "YYMMDD": return "[YYMMDD]";
default: return "[DATE]";
}
}
// 현재 날짜 기준 생성
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");

View File

@ -185,16 +185,18 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
}
}, [open, screenCode]);
// 테이블 선택은 선택 사항 - 컴포넌트별로 테이블을 설정할 수 있음
const isValid = useMemo(() => {
const baseValid = screenName.trim().length > 0 && screenCode.trim().length > 0;
if (dataSourceType === "database") {
return baseValid && tableName.trim().length > 0;
// 테이블 선택은 선택 사항 (비워두면 컴포넌트별로 테이블 설정)
return baseValid;
} else {
// REST API: 연결 선택 필수
return baseValid && selectedRestApiId !== null;
}
}, [screenName, screenCode, tableName, dataSourceType, selectedRestApiId]);
}, [screenName, screenCode, dataSourceType, selectedRestApiId]);
// 테이블 필터링 (내부 DB용)
const filteredTables = useMemo(() => {
@ -230,8 +232,8 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
};
if (dataSourceType === "database") {
// 데이터베이스 소스
createData.tableName = tableName.trim();
// 데이터베이스 소스 - 테이블 선택은 선택 사항
createData.tableName = tableName.trim() || null; // 비어있으면 null
createData.dbSourceType = selectedDbSource === "internal" ? "internal" : "external";
createData.dbConnectionId = selectedDbSource === "internal" ? undefined : Number(selectedDbSource);
} else {
@ -507,7 +509,10 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
{/* 테이블 선택 (데이터베이스 모드일 때만) */}
{dataSourceType === "database" && (
<div className="space-y-2">
<Label htmlFor="tableName"> *</Label>
<Label htmlFor="tableName"> ()</Label>
<p className="text-muted-foreground text-xs">
.
</p>
<Select
value={tableName}
onValueChange={setTableName}
@ -521,7 +526,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={loadingExternalTables ? "로딩 중..." : "테이블 선택하세요"} />
<SelectValue placeholder={loadingExternalTables ? "로딩 중..." : "(선택 사항) 기본 테이블 선택"} />
</SelectTrigger>
<SelectContent className="max-h-80">
{/* 검색 입력 필드 */}

View File

@ -568,11 +568,18 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
);
}
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
const { widgetType, label: originalLabel, placeholder, required, readonly, columnName } = comp;
const fieldName = columnName || comp.id;
const currentValue = formData[fieldName] || "";
// 스타일 적용
// 🆕 엔티티 조인 컬럼은 읽기 전용으로 처리
const isEntityJoin = (comp as any).isEntityJoin === true;
const isReadonly = readonly || isEntityJoin;
// 다국어 라벨 적용 (langKey가 있으면 번역 텍스트 사용)
const compLangKey = (comp as any).langKey;
const label = compLangKey && translations[compLangKey] ? translations[compLangKey] : originalLabel;
const applyStyles = (element: React.ReactElement) => {
if (!comp.style) return element;
@ -683,7 +690,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
placeholder={isAutoInput ? `자동입력: ${config?.autoValueType}` : finalPlaceholder}
value={displayValue}
onChange={isAutoInput ? undefined : handleInputChange}
disabled={readonly || isAutoInput}
disabled={isReadonly || isAutoInput}
readOnly={isAutoInput}
required={required}
minLength={config?.minLength}
@ -724,7 +731,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
placeholder={finalPlaceholder}
value={currentValue}
onChange={(e) => updateFormData(fieldName, e.target.valueAsNumber || 0)}
disabled={readonly}
disabled={isReadonly}
required={required}
min={config?.min}
max={config?.max}
@ -763,7 +770,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
disabled={isReadonly}
required={required}
minLength={config?.minLength}
maxLength={config?.maxLength}
@ -815,7 +822,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
value={currentValue}
onChange={(value) => updateFormData(fieldName, value)}
placeholder={finalPlaceholder}
disabled={readonly}
disabled={isReadonly}
required={required}
/>,
);
@ -833,7 +840,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
value={currentValue}
onChange={(value) => updateFormData(fieldName, value)}
placeholder={finalPlaceholder}
disabled={readonly}
disabled={isReadonly}
required={required}
/>,
);
@ -850,7 +857,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
<Select
value={currentValue || config?.defaultValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
disabled={isReadonly}
required={required}
>
<SelectTrigger className="h-full w-full">
@ -897,7 +904,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
id={fieldName}
checked={isChecked}
onCheckedChange={(checked) => updateFormData(fieldName, checked)}
disabled={readonly}
disabled={isReadonly}
required={required}
/>
<label htmlFor={fieldName} className="text-sm">
@ -943,7 +950,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
value=""
checked={selectedValue === ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
disabled={isReadonly}
required={required}
className="h-4 w-4"
/>
@ -961,7 +968,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
value={option.value}
checked={selectedValue === option.value}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly || option.disabled}
disabled={isReadonly || option.disabled}
required={required}
className="h-4 w-4"
/>
@ -1002,7 +1009,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
disabled={isReadonly}
required={required}
min={config?.minDate}
max={config?.maxDate}
@ -1019,7 +1026,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
<Button
variant="outline"
className="h-full w-full justify-start text-left font-normal"
disabled={readonly}
disabled={isReadonly}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateValue ? format(dateValue, "PPP", { locale: ko }) : config?.defaultValue || finalPlaceholder}
@ -1062,7 +1069,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
disabled={isReadonly}
required={required}
min={config?.minDate}
max={config?.maxDate}
@ -1246,7 +1253,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
type="file"
data-field={fieldName}
onChange={handleFileChange}
disabled={readonly}
disabled={isReadonly}
required={required}
multiple={config?.multiple}
accept={config?.accept}
@ -1354,7 +1361,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
<Select
value={currentValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
disabled={isReadonly}
required={required}
>
<SelectTrigger className="h-full w-full">
@ -1915,11 +1922,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return applyStyles(
<button
onClick={handleButtonClick}
disabled={readonly}
disabled={isReadonly}
className={`w-full rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${
hasCustomColors
? ''
: 'bg-background border border-foreground text-foreground shadow-xs hover:bg-muted/50'
? ""
: "bg-background border border-foreground text-foreground shadow-xs hover:bg-muted/50"
}`}
style={{
height: "100%",
@ -1940,7 +1947,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
placeholder={placeholder || "입력하세요..."}
value={currentValue}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
disabled={isReadonly}
required={required}
className="w-full"
style={{ height: "100%" }}

View File

@ -19,6 +19,7 @@ import { FlowVisibilityConfig } from "@/types/control-management";
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator";
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer";
@ -56,7 +57,7 @@ interface InteractiveScreenViewerProps {
// 원본 데이터 (수정 모드에서 UPDATE 판단용)
originalData?: Record<string, any> | null;
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
parentTabId?: string; // 부모 탭 ID
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
}
@ -334,6 +335,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 동적 대화형 위젯 렌더링
const renderInteractiveWidget = (comp: ComponentData) => {
// 조건부 표시 평가
const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents);
// 조건에 따라 숨김 처리
if (!conditionalResult.visible) {
return null;
}
// 데이터 테이블 컴포넌트 처리
if (isDataTableComponent(comp)) {
return (
@ -431,6 +440,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
});
};
// 조건부 비활성화 적용
const isConditionallyDisabled = conditionalResult.disabled;
// 동적 웹타입 렌더링 사용
if (widgetType) {
try {
@ -444,7 +456,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
onFormDataChange: handleFormDataChange,
formData: formData, // 🆕 전체 formData 전달
isInteractive: true,
readonly: readonly,
readonly: readonly || isConditionallyDisabled, // 조건부 비활성화 적용
disabled: isConditionallyDisabled, // 조건부 비활성화 전달
required: required,
placeholder: placeholder,
className: "w-full h-full",
@ -470,7 +483,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
value={currentValue}
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
placeholder={`${widgetType} (렌더링 오류)`}
disabled={readonly}
disabled={readonly || isConditionallyDisabled}
required={required}
className="h-full w-full"
/>
@ -486,7 +499,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
value={currentValue}
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
placeholder={placeholder || "입력하세요"}
disabled={readonly}
disabled={readonly || isConditionallyDisabled}
required={required}
className="h-full w-full"
/>
@ -528,6 +541,20 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const response = await dynamicFormApi.saveData(saveData);
if (response.success) {
const masterRecordId = response.data?.id || formData.id;
// 리피터 데이터 저장 이벤트 발생 (UnifiedRepeater 컴포넌트가 리스닝)
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
masterRecordId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
mainFormData: formData,
tableName: screenInfo.tableName,
},
}),
);
toast.success("데이터가 성공적으로 저장되었습니다.");
} else {
toast.error(response.message || "저장에 실패했습니다.");
@ -604,7 +631,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
try {
const { default: apiClient } = await import("@/lib/api/client");
const columnsResponse = await apiClient.get(
`/table-management/tables/${quickInsertConfig.targetTable}/columns`
`/table-management/tables/${quickInsertConfig.targetTable}/columns`,
);
if (columnsResponse.data?.success && columnsResponse.data?.data) {
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
@ -694,18 +721,18 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}
// 시스템 컬럼 제외
const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name'];
const systemColumns = ["id", "created_date", "updated_date", "writer", "writer_name"];
if (systemColumns.includes(key)) {
continue;
}
// _label, _name 으로 끝나는 표시용 컬럼 제외
if (key.endsWith('_label') || key.endsWith('_name')) {
if (key.endsWith("_label") || key.endsWith("_name")) {
continue;
}
// 값이 있으면 자동 추가
if (val !== undefined && val !== null && val !== '') {
if (val !== undefined && val !== null && val !== "") {
insertData[key] = val;
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
}
@ -736,14 +763,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
console.log("📍 중복 체크 조건:", searchConditions);
// 기존 데이터 조회
const checkResponse = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/data`,
{
page: 1,
pageSize: 1,
search: searchConditions,
}
);
const checkResponse = await apiClient.post(`/table-management/tables/${quickInsertConfig.targetTable}/data`, {
page: 1,
pageSize: 1,
search: searchConditions,
});
console.log("📍 중복 체크 응답:", checkResponse.data);
@ -765,7 +789,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const response = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
insertData
insertData,
);
if (response.data?.success) {
@ -1006,7 +1030,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
{popupScreen && (
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
<DialogContent
className="overflow-hidden p-0 max-w-none"
className="max-w-none overflow-hidden p-0"
style={{
width: popupScreen.size === "small" ? "600px" : popupScreen.size === "large" ? "1400px" : "1000px",
height: "800px",

View File

@ -65,6 +65,9 @@ interface RealtimePreviewProps {
// 🆕 조건부 컨테이너 높이 변화 콜백
onHeightChange?: (componentId: string, newHeight: number) => void;
// 🆕 조건부 비활성화 상태
conditionalDisabled?: boolean;
}
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
@ -94,7 +97,7 @@ const getWidgetIcon = (widgetType: WebType | undefined): React.ReactNode => {
return iconMap[widgetType] || <Type className="h-3 w-3" />;
};
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
component,
isSelected = false,
isDesignMode = true, // 기본값은 편집 모드
@ -129,6 +132,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
formData,
onFormDataChange,
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
conditionalDisabled, // 🆕 조건부 비활성화 상태
}) => {
// 🆕 화면 다국어 컨텍스트
const { getTranslatedText } = useScreenMultiLang();
@ -513,6 +517,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
sortOrder={sortOrder}
columnOrder={columnOrder}
onHeightChange={onHeightChange}
conditionalDisabled={conditionalDisabled}
/>
</div>
@ -536,6 +541,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
);
};
// React.memo로 래핑하여 불필요한 리렌더링 방지
export const RealtimePreviewDynamic = React.memo(RealtimePreviewDynamicComponent);
// displayName 설정 (디버깅용)
RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic";
// 기존 RealtimePreview와의 호환성을 위한 export
export { RealtimePreviewDynamic as RealtimePreview };
export default RealtimePreviewDynamic;

View File

@ -17,7 +17,7 @@ import {
SCREEN_RESOLUTIONS,
} from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
import { getComponentIdFromWebType } from "@/lib/utils/webTypeMapping";
import { getComponentIdFromWebType, createUnifiedConfigFromColumn, getUnifiedConfigFromWebType } from "@/lib/utils/webTypeMapping";
import {
createGroupComponent,
calculateBoundingBox,
@ -101,7 +101,6 @@ import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
// 새로운 통합 UI 컴포넌트
import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar";
import { SlimToolbar } from "./toolbar/SlimToolbar";
import { UnifiedPropertiesPanel } from "./panels/UnifiedPropertiesPanel";
@ -941,11 +940,35 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
: null;
const tableLabel = currentTable?.displayName || tableName;
// 현재 화면의 테이블 컬럼 정보 조회
const columnsResponse = await tableTypeApi.getColumns(tableName);
// 현재 화면의 테이블 컬럼 정보 조회 (캐시 버스팅으로 최신 데이터 가져오기)
const columnsResponse = await tableTypeApi.getColumns(tableName, true);
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type;
// widgetType 결정: inputType(entity 등) > webType > widget_type
const inputType = col.inputType || col.input_type;
const widgetType = inputType || col.widgetType || col.widget_type || col.webType || col.web_type;
// detailSettings 파싱 (문자열이면 JSON 파싱)
let detailSettings = col.detailSettings || col.detail_settings;
if (typeof detailSettings === "string") {
try {
detailSettings = JSON.parse(detailSettings);
} catch (e) {
console.warn("detailSettings 파싱 실패:", e);
detailSettings = {};
}
}
// 엔티티 타입 디버깅
if (inputType === "entity" || widgetType === "entity") {
console.log("🔍 엔티티 컬럼 감지:", {
columnName: col.columnName || col.column_name,
inputType,
widgetType,
detailSettings,
referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table,
});
}
return {
tableName: col.tableName || tableName,
@ -953,7 +976,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
webType: col.webType || col.web_type,
input_type: col.inputType || col.input_type,
input_type: inputType,
inputType: inputType,
widgetType,
isNullable: col.isNullable || col.is_nullable,
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
@ -961,10 +985,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value,
// 엔티티 타입용 참조 테이블 정보
referenceTable: col.referenceTable || col.reference_table,
referenceColumn: col.referenceColumn || col.reference_column,
displayColumn: col.displayColumn || col.display_column,
// 엔티티 타입용 참조 테이블 정보 (detailSettings에서 추출)
referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table,
referenceColumn: detailSettings?.referenceColumn || col.referenceColumn || col.reference_column,
displayColumn: detailSettings?.displayColumn || col.displayColumn || col.display_column,
// detailSettings 전체 보존 (Unified 컴포넌트용)
detailSettings,
};
});
@ -984,6 +1010,94 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
loadScreenDataSource();
}, [selectedScreen?.tableName, selectedScreen?.screenName, selectedScreen?.dataSourceType, selectedScreen?.restApiConnectionId, selectedScreen?.restApiEndpoint, selectedScreen?.restApiJsonPath]);
// 테이블 선택 핸들러 - 사이드바에서 테이블 선택 시 호출
const handleTableSelect = useCallback(async (tableName: string) => {
console.log("📊 테이블 선택:", tableName);
try {
// 테이블 라벨 조회
const tableListResponse = await tableManagementApi.getTableList();
const currentTable =
tableListResponse.success && tableListResponse.data
? tableListResponse.data.find((t: any) => (t.tableName || t.table_name) === tableName)
: null;
const tableLabel = currentTable?.displayName || currentTable?.table_label || tableName;
// 테이블 컬럼 정보 조회
const columnsResponse = await tableTypeApi.getColumns(tableName, true);
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
const inputType = col.inputType || col.input_type;
const widgetType = inputType || col.widgetType || col.widget_type || col.webType || col.web_type;
let detailSettings = col.detailSettings || col.detail_settings;
if (typeof detailSettings === "string") {
try {
detailSettings = JSON.parse(detailSettings);
} catch (e) {
detailSettings = {};
}
}
return {
tableName: col.tableName || tableName,
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
webType: col.webType || col.web_type,
input_type: inputType,
inputType: inputType,
widgetType,
isNullable: col.isNullable || col.is_nullable,
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
columnDefault: col.columnDefault || col.column_default,
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value,
referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table,
referenceColumn: detailSettings?.referenceColumn || col.referenceColumn || col.reference_column,
displayColumn: detailSettings?.displayColumn || col.displayColumn || col.display_column,
detailSettings,
};
});
const tableInfo: TableInfo = {
tableName,
tableLabel,
columns,
};
setTables([tableInfo]);
toast.success(`테이블 "${tableLabel}" 선택됨`);
// 기존 테이블과 다른 테이블 선택 시, 기존 컴포넌트 중 다른 테이블 컬럼은 제거
if (tables.length > 0 && tables[0].tableName !== tableName) {
setLayout((prev) => {
const newComponents = prev.components.filter((comp) => {
// 테이블 컬럼 기반 컴포넌트인지 확인
if (comp.tableName && comp.tableName !== tableName) {
console.log("🗑️ 다른 테이블 컴포넌트 제거:", comp.tableName, comp.columnName);
return false;
}
return true;
});
if (newComponents.length < prev.components.length) {
toast.info(`이전 테이블(${tables[0].tableName})의 컴포넌트가 ${prev.components.length - newComponents.length}개 제거되었습니다.`);
}
return {
...prev,
components: newComponents,
};
});
}
} catch (error) {
console.error("테이블 정보 로드 실패:", error);
toast.error("테이블 정보를 불러오는데 실패했습니다.");
}
}, [tables]);
// 화면 레이아웃 로드
useEffect(() => {
if (selectedScreen?.screenId) {
@ -2108,6 +2222,55 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return;
}
}
// 🎯 리피터 컨테이너 내부 드롭 처리
const dropTarget = e.target as HTMLElement;
const repeatContainer = dropTarget.closest('[data-repeat-container="true"]');
if (repeatContainer) {
const containerId = repeatContainer.getAttribute("data-component-id");
if (containerId) {
// 해당 리피터 컨테이너 찾기
const targetComponent = layout.components.find((c) => c.id === containerId);
const compType = (targetComponent as any)?.componentType;
// v2-repeat-container 또는 repeat-container 모두 지원
if (targetComponent && (compType === "repeat-container" || compType === "v2-repeat-container")) {
const currentConfig = (targetComponent as any).componentConfig || {};
const currentChildren = currentConfig.children || [];
// 새 자식 컴포넌트 생성
const newChild = {
id: `slot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
componentType: component.id || component.componentType || "text-display",
label: component.name || component.label || "새 컴포넌트",
fieldName: "",
position: { x: 0, y: currentChildren.length * 40 },
size: component.defaultSize || { width: 200, height: 32 },
componentConfig: component.defaultConfig || {},
};
// 컴포넌트 업데이트
const updatedComponent = {
...targetComponent,
componentConfig: {
...currentConfig,
children: [...currentChildren, newChild],
},
};
const newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === containerId ? updatedComponent : c
),
};
setLayout(newLayout);
saveToHistory(newLayout);
return; // 리피터 컨테이너 처리 완료
}
}
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
@ -2448,6 +2611,50 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const dropTarget = e.target as HTMLElement;
const formContainer = dropTarget.closest('[data-form-container="true"]');
// 🎯 리피터 컨테이너 내부에 컬럼 드롭 시 처리
const repeatContainer = dropTarget.closest('[data-repeat-container="true"]');
if (repeatContainer && type === "column" && column) {
const containerId = repeatContainer.getAttribute("data-component-id");
if (containerId) {
const targetComponent = layout.components.find((c) => c.id === containerId);
const rcType = (targetComponent as any)?.componentType;
if (targetComponent && (rcType === "repeat-container" || rcType === "v2-repeat-container")) {
const currentConfig = (targetComponent as any).componentConfig || {};
const currentChildren = currentConfig.children || [];
// 새 자식 컴포넌트 생성 (컬럼 기반)
const newChild = {
id: `slot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
componentType: column.widgetType || "text-display",
label: column.columnLabel || column.columnName,
fieldName: column.columnName,
position: { x: 0, y: currentChildren.length * 40 },
size: { width: 200, height: 32 },
componentConfig: {},
};
const updatedComponent = {
...targetComponent,
componentConfig: {
...currentConfig,
children: [...currentChildren, newChild],
},
};
const newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === containerId ? updatedComponent : c
),
};
setLayout(newLayout);
saveToHistory(newLayout);
return;
}
}
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
@ -2676,28 +2883,43 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const relativeX = e.clientX - containerRect.left;
const relativeY = e.clientY - containerRect.top;
// 웹타입을 새로운 컴포넌트 ID로 매핑
const componentId = getComponentIdFromWebType(column.widgetType);
// console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`);
// 🆕 Unified 컴포넌트 매핑 사용
const unifiedMapping = createUnifiedConfigFromColumn({
widgetType: column.widgetType,
columnName: column.columnName,
columnLabel: column.columnLabel,
codeCategory: column.codeCategory,
inputType: column.inputType,
required: column.required,
detailSettings: column.detailSettings, // 엔티티 참조 정보 전달
// column_labels 직접 필드도 전달
referenceTable: column.referenceTable,
referenceColumn: column.referenceColumn,
displayColumn: column.displayColumn,
});
// 웹타입별 기본 너비 계산 (10px 단위 고정)
const componentWidth = getDefaultWidth(column.widgetType);
console.log("🎯 폼 컨테이너 컴포넌트 생성:", {
console.log("🎯 폼 컨테이너 Unified 컴포넌트 생성:", {
widgetType: column.widgetType,
unifiedType: unifiedMapping.componentType,
componentWidth,
});
// 엔티티 조인 컬럼인 경우 읽기 전용으로 설정
const isEntityJoinColumn = column.isEntityJoin === true;
newComponent = {
id: generateComponentId(),
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
type: "component", // ✅ Unified 컴포넌트 시스템 사용
label: column.columnLabel || column.columnName,
tableName: table.tableName,
columnName: column.columnName,
required: column.required,
readonly: false,
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
componentType: unifiedMapping.componentType, // unified-input, unified-select 등
position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
// 코드 타입인 경우 코드 카테고리 정보 추가
@ -2705,6 +2927,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
column.codeCategory && {
codeCategory: column.codeCategory,
}),
// 엔티티 조인 정보 저장
...(isEntityJoinColumn && {
isEntityJoin: true,
entityJoinTable: column.entityJoinTable,
entityJoinColumn: column.entityJoinColumn,
}),
style: {
labelDisplay: false, // 라벨 숨김
labelFontSize: "12px",
@ -2713,43 +2941,51 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
labelMarginBottom: "6px",
},
componentConfig: {
type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존
inputType: column.inputType, // ✅ input_type 추가 (category 등)
...getDefaultWebTypeConfig(column.widgetType),
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
type: unifiedMapping.componentType, // unified-input, unified-select 등
...unifiedMapping.componentConfig, // Unified 컴포넌트 기본 설정
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
},
};
} else {
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
}
} else {
// 일반 캔버스에 드롭한 경우 - 새로운 컴포넌트 시스템 사용
const componentId = getComponentIdFromWebType(column.widgetType);
// console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`);
// 일반 캔버스에 드롭한 경우 - 🆕 Unified 컴포넌트 시스템 사용
const unifiedMapping = createUnifiedConfigFromColumn({
widgetType: column.widgetType,
columnName: column.columnName,
columnLabel: column.columnLabel,
codeCategory: column.codeCategory,
inputType: column.inputType,
required: column.required,
detailSettings: column.detailSettings, // 엔티티 참조 정보 전달
// column_labels 직접 필드도 전달
referenceTable: column.referenceTable,
referenceColumn: column.referenceColumn,
displayColumn: column.displayColumn,
});
// 웹타입별 기본 너비 계산 (10px 단위 고정)
const componentWidth = getDefaultWidth(column.widgetType);
console.log("🎯 캔버스 컴포넌트 생성:", {
console.log("🎯 캔버스 Unified 컴포넌트 생성:", {
widgetType: column.widgetType,
unifiedType: unifiedMapping.componentType,
componentWidth,
});
// 엔티티 조인 컬럼인 경우 읽기 전용으로 설정
const isEntityJoinColumn = column.isEntityJoin === true;
newComponent = {
id: generateComponentId(),
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
type: "component", // ✅ Unified 컴포넌트 시스템 사용
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
tableName: table.tableName,
columnName: column.columnName,
required: column.required,
readonly: false,
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
componentType: unifiedMapping.componentType, // unified-input, unified-select 등
position: { x, y, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
// 코드 타입인 경우 코드 카테고리 정보 추가
@ -2757,6 +2993,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
column.codeCategory && {
codeCategory: column.codeCategory,
}),
// 엔티티 조인 정보 저장
...(isEntityJoinColumn && {
isEntityJoin: true,
entityJoinTable: column.entityJoinTable,
entityJoinColumn: column.entityJoinColumn,
}),
style: {
labelDisplay: false, // 라벨 숨김
labelFontSize: "14px",
@ -2765,16 +3007,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
labelMarginBottom: "8px",
},
componentConfig: {
type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존
inputType: column.inputType, // ✅ input_type 추가 (category 등)
...getDefaultWebTypeConfig(column.widgetType),
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
type: unifiedMapping.componentType, // unified-input, unified-select 등
...unifiedMapping.componentConfig, // Unified 컴포넌트 기본 설정
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
},
};
}
@ -4315,18 +4550,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
onBack={onBackToList}
onSave={handleSave}
isSaving={isSaving}
onResolutionChange={setScreenResolution}
gridSettings={layout.gridSettings}
onGridSettingsChange={updateGridSettings}
onGenerateMultilang={handleGenerateMultilang}
isGeneratingMultilang={isGeneratingMultilang}
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
isPanelOpen={panelStates.unified?.isOpen || false}
onTogglePanel={() => togglePanel("unified")}
/>
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
{/* 메인 컨테이너 (패널들 + 캔버스) */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측 통합 툴바 */}
<LeftUnifiedToolbar buttons={defaultToolbarButtons} panelStates={panelStates} onTogglePanel={togglePanel} />
{/* 통합 패널 */}
{/* 통합 패널 - 좌측 사이드바 제거 후 너비 300px로 확장 */}
{panelStates.unified?.isOpen && (
<div className="border-border bg-card flex h-full w-[240px] flex-col border-r shadow-sm overflow-hidden">
<div className="border-border bg-card flex h-full w-[300px] flex-col border-r shadow-sm overflow-hidden">
<div className="border-border flex items-center justify-between border-b px-4 py-3 shrink-0">
<h3 className="text-foreground text-sm font-semibold"></h3>
<button
@ -4360,8 +4597,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
selectedTableName={selectedScreen.tableName}
selectedTableName={selectedScreen?.tableName}
placedColumns={placedColumns}
onTableSelect={handleTableSelect}
showTableSelector={true}
/>
</TabsContent>
@ -4369,9 +4608,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
<UnifiedPropertiesPanel
selectedComponent={selectedComponent || undefined}
tables={tables}
gridSettings={layout.gridSettings}
onUpdateProperty={updateComponentProperty}
onGridSettingsChange={updateGridSettings}
onDeleteComponent={deleteComponent}
onCopyComponent={copyComponent}
currentTable={tables.length > 0 ? tables[0] : undefined}
@ -4383,8 +4620,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
updateComponentProperty(selectedComponent.id, "style", style);
}
}}
currentResolution={screenResolution}
onResolutionChange={handleResolutionChange}
allComponents={layout.components} // 🆕 플로우 위젯 감지용
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
@ -4545,9 +4780,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={(e) => {
onDropCapture={(e) => {
// 캡처 단계에서 드롭 이벤트를 처리하여 자식 요소 드롭도 감지
e.preventDefault();
// console.log("🎯 캔버스 드롭 이벤트 발생");
handleDrop(e);
}}
>

View File

@ -4,13 +4,24 @@ import { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Palette, Type, Square } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Palette, Type, Square, ChevronDown } from "lucide-react";
import { ComponentStyle } from "@/types/screen";
import { ColorPickerWithTransparent } from "./common/ColorPickerWithTransparent";
interface StyleEditorProps {
style: ComponentStyle;
onStyleChange: (style: ComponentStyle) => void;
className?: string;
}
export default function StyleEditor({ style, onStyleChange, className }: StyleEditorProps) {
const [localStyle, setLocalStyle] = useState<ComponentStyle>(style || {});
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
border: false,
background: false,
text: false,
});
useEffect(() => {
setLocalStyle(style || {});
@ -22,232 +33,255 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
onStyleChange(newStyle);
};
return (
<div className={`space-y-4 p-3 ${className}`}>
{/* 테두리 섹션 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Square className="text-primary h-3.5 w-3.5" />
<h3 className="text-sm font-semibold"></h3>
</div>
<Separator className="my-1.5" />
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="borderWidth" className="text-xs font-medium">
</Label>
<Input
id="borderWidth"
type="text"
placeholder="1px"
value={localStyle.borderWidth || ""}
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="borderStyle" className="text-xs font-medium">
</Label>
<Select
value={localStyle.borderStyle || "solid"}
onValueChange={(value) => handleStyleChange("borderStyle", value)}
>
<SelectTrigger className=" text-xsh-6 w-full px-2 py-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid" className="text-xs">
</SelectItem>
<SelectItem value="dashed" className="text-xs">
</SelectItem>
<SelectItem value="dotted" className="text-xs">
</SelectItem>
<SelectItem value="none" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
const toggleSection = (section: string) => {
setOpenSections((prev) => ({
...prev,
[section]: !prev[section],
}));
};
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="borderColor" className="text-xs font-medium">
</Label>
<ColorPickerWithTransparent
id="borderColor"
value={localStyle.borderColor}
onChange={(value) => handleStyleChange("borderColor", value)}
defaultColor="#e5e7eb"
placeholder="#e5e7eb"
/>
return (
<div className={`space-y-2 ${className}`}>
{/* 테두리 섹션 */}
<Collapsible open={openSections.border} onOpenChange={() => toggleSection("border")}>
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-gray-50 px-2 py-1.5 hover:bg-gray-100">
<div className="flex items-center gap-1.5">
<Square className="text-primary h-3 w-3" />
<span className="text-xs font-medium"></span>
</div>
<ChevronDown
className={`h-3 w-3 text-gray-500 transition-transform ${openSections.border ? "rotate-180" : ""}`}
/>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="space-y-2 pl-1">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="borderWidth" className="text-xs font-medium">
</Label>
<Input
id="borderWidth"
type="text"
placeholder="1px"
value={localStyle.borderWidth || ""}
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="borderStyle" className="text-xs font-medium">
</Label>
<Select
value={localStyle.borderStyle || "solid"}
onValueChange={(value) => handleStyleChange("borderStyle", value)}
>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid" className="text-xs">
</SelectItem>
<SelectItem value="dashed" className="text-xs">
</SelectItem>
<SelectItem value="dotted" className="text-xs">
</SelectItem>
<SelectItem value="none" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="borderRadius" className="text-xs font-medium">
</Label>
<Input
id="borderRadius"
type="text"
placeholder="5px"
value={localStyle.borderRadius || ""}
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
/>
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="borderColor" className="text-xs font-medium">
</Label>
<ColorPickerWithTransparent
id="borderColor"
value={localStyle.borderColor}
onChange={(value) => handleStyleChange("borderColor", value)}
defaultColor="#e5e7eb"
placeholder="#e5e7eb"
/>
</div>
<div className="space-y-1">
<Label htmlFor="borderRadius" className="text-xs font-medium">
</Label>
<Input
id="borderRadius"
type="text"
placeholder="5px"
value={localStyle.borderRadius || ""}
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
</div>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* 배경 섹션 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Palette className="text-primary h-3.5 w-3.5" />
<h3 className="text-sm font-semibold"></h3>
</div>
<Separator className="my-1.5" />
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="backgroundColor" className="text-xs font-medium">
</Label>
<ColorPickerWithTransparent
id="backgroundColor"
value={localStyle.backgroundColor}
onChange={(value) => handleStyleChange("backgroundColor", value)}
defaultColor="#ffffff"
placeholder="#ffffff"
/>
<Collapsible open={openSections.background} onOpenChange={() => toggleSection("background")}>
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-gray-50 px-2 py-1.5 hover:bg-gray-100">
<div className="flex items-center gap-1.5">
<Palette className="text-primary h-3 w-3" />
<span className="text-xs font-medium"></span>
</div>
<div className="space-y-1">
<Label htmlFor="backgroundImage" className="text-xs font-medium">
(CSS)
</Label>
<Input
id="backgroundImage"
type="text"
placeholder="url('image.jpg')"
value={localStyle.backgroundImage || ""}
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
( )
</p>
</div>
</div>
</div>
{/* 텍스트 섹션 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Type className="text-primary h-3.5 w-3.5" />
<h3 className="text-sm font-semibold"></h3>
</div>
<Separator className="my-1.5" />
<div className="space-y-2">
<div className="space-y-2">
<ChevronDown
className={`h-3 w-3 text-gray-500 transition-transform ${openSections.background ? "rotate-180" : ""}`}
/>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="space-y-2 pl-1">
<div className="space-y-1">
<Label htmlFor="color" className="text-xs font-medium">
<Label htmlFor="backgroundColor" className="text-xs font-medium">
</Label>
<ColorPickerWithTransparent
id="color"
value={localStyle.color}
onChange={(value) => handleStyleChange("color", value)}
defaultColor="#000000"
placeholder="#000000"
id="backgroundColor"
value={localStyle.backgroundColor}
onChange={(value) => handleStyleChange("backgroundColor", value)}
defaultColor="#ffffff"
placeholder="#ffffff"
/>
</div>
<div className="space-y-1">
<Label htmlFor="fontSize" className="text-xs font-medium">
<Label htmlFor="backgroundImage" className="text-xs font-medium">
(CSS)
</Label>
<Input
id="fontSize"
id="backgroundImage"
type="text"
placeholder="14px"
value={localStyle.fontSize || ""}
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
placeholder="url('image.jpg')"
value={localStyle.backgroundImage || ""}
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
/>
<p className="text-muted-foreground text-[10px]"> ( )</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="fontWeight" className="text-xs font-medium">
</Label>
<Select
value={localStyle.fontWeight || "normal"}
onValueChange={(value) => handleStyleChange("fontWeight", value)}
>
<SelectTrigger className=" text-xsh-6 w-full px-2 py-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal" className="text-xs">
</SelectItem>
<SelectItem value="bold" className="text-xs">
</SelectItem>
<SelectItem value="100" className="text-xs">
100
</SelectItem>
<SelectItem value="400" className="text-xs">
400
</SelectItem>
<SelectItem value="500" className="text-xs">
500
</SelectItem>
<SelectItem value="600" className="text-xs">
600
</SelectItem>
<SelectItem value="700" className="text-xs">
700
</SelectItem>
</SelectContent>
</Select>
{/* 텍스트 섹션 */}
<Collapsible open={openSections.text} onOpenChange={() => toggleSection("text")}>
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-gray-50 px-2 py-1.5 hover:bg-gray-100">
<div className="flex items-center gap-1.5">
<Type className="text-primary h-3 w-3" />
<span className="text-xs font-medium"></span>
</div>
<ChevronDown
className={`h-3 w-3 text-gray-500 transition-transform ${openSections.text ? "rotate-180" : ""}`}
/>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="space-y-2 pl-1">
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="color" className="text-xs font-medium">
</Label>
<ColorPickerWithTransparent
id="color"
value={localStyle.color}
onChange={(value) => handleStyleChange("color", value)}
defaultColor="#000000"
placeholder="#000000"
/>
</div>
<div className="space-y-1">
<Label htmlFor="fontSize" className="text-xs font-medium">
</Label>
<Input
id="fontSize"
type="text"
placeholder="14px"
value={localStyle.fontSize || ""}
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="textAlign" className="text-xs font-medium">
</Label>
<Select
value={localStyle.textAlign || "left"}
onValueChange={(value) => handleStyleChange("textAlign", value)}
>
<SelectTrigger className=" text-xsh-6 w-full px-2 py-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left" className="text-xs">
</SelectItem>
<SelectItem value="center" className="text-xs">
</SelectItem>
<SelectItem value="right" className="text-xs">
</SelectItem>
<SelectItem value="justify" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="fontWeight" className="text-xs font-medium">
</Label>
<Select
value={localStyle.fontWeight || "normal"}
onValueChange={(value) => handleStyleChange("fontWeight", value)}
>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal" className="text-xs">
</SelectItem>
<SelectItem value="bold" className="text-xs">
</SelectItem>
<SelectItem value="100" className="text-xs">
100
</SelectItem>
<SelectItem value="400" className="text-xs">
400
</SelectItem>
<SelectItem value="500" className="text-xs">
500
</SelectItem>
<SelectItem value="600" className="text-xs">
600
</SelectItem>
<SelectItem value="700" className="text-xs">
700
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="textAlign" className="text-xs font-medium">
</Label>
<Select
value={localStyle.textAlign || "left"}
onValueChange={(value) => handleStyleChange("textAlign", value)}
>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left" className="text-xs">
</SelectItem>
<SelectItem value="center" className="text-xs">
</SelectItem>
<SelectItem value="right" className="text-xs">
</SelectItem>
<SelectItem value="justify" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}

View File

@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button";
@ -106,6 +107,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
const [modalSourceSearch, setModalSourceSearch] = useState<Record<number, string>>({});
const [modalTargetSearch, setModalTargetSearch] = useState<Record<number, string>>({});
// 🆕 modal 액션용 필드 매핑 상태
const [modalActionSourceTable, setModalActionSourceTable] = useState<string | null>(null);
const [modalActionTargetTable, setModalActionTargetTable] = useState<string | null>(null);
const [modalActionSourceColumns, setModalActionSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalActionTargetColumns, setModalActionTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalActionFieldMappings, setModalActionFieldMappings] = useState<Array<{ sourceField: string; targetField: string }>>([]);
const [modalFieldMappingSourceOpen, setModalFieldMappingSourceOpen] = useState<Record<number, boolean>>({});
const [modalFieldMappingTargetOpen, setModalFieldMappingTargetOpen] = useState<Record<number, boolean>>({});
const [modalFieldMappingSourceSearch, setModalFieldMappingSourceSearch] = useState<Record<number, string>>({});
const [modalFieldMappingTargetSearch, setModalFieldMappingTargetSearch] = useState<Record<number, string>>({});
// 🎯 플로우 위젯이 화면에 있는지 확인
const hasFlowWidget = useMemo(() => {
const found = allComponents.some((comp: any) => {
@ -332,6 +344,123 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
loadColumns();
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
// 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드
useEffect(() => {
const actionType = config.action?.type;
if (actionType !== "modal") return;
const autoDetect = config.action?.autoDetectDataSource;
if (!autoDetect) {
// 데이터 전달이 비활성화되면 상태 초기화
setModalActionSourceTable(null);
setModalActionTargetTable(null);
setModalActionSourceColumns([]);
setModalActionTargetColumns([]);
return;
}
const targetScreenId = config.action?.targetScreenId;
if (!targetScreenId) return;
const loadModalActionMappingData = async () => {
// 1. 소스 테이블 감지 (현재 화면)
let sourceTableName: string | null = currentTableName || null;
// allComponents에서 분할패널/테이블리스트/통합목록 감지
for (const comp of allComponents) {
const compType = comp.componentType || (comp as any).componentConfig?.type;
const compConfig = (comp as any).componentConfig || {};
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
sourceTableName = compConfig.leftPanel?.tableName || compConfig.tableName || null;
if (sourceTableName) break;
}
if (compType === "table-list") {
sourceTableName = compConfig.tableName || compConfig.selectedTable || null;
if (sourceTableName) break;
}
if (compType === "unified-list") {
sourceTableName = compConfig.dataSource?.table || compConfig.tableName || null;
if (sourceTableName) break;
}
}
setModalActionSourceTable(sourceTableName);
// 2. 대상 화면의 테이블 조회
let targetTableName: string | null = null;
try {
const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`);
if (screenResponse.data.success && screenResponse.data.data) {
targetTableName = screenResponse.data.data.tableName || null;
} else if (screenResponse.data?.tableName) {
// 직접 데이터 반환 형식인 경우
targetTableName = screenResponse.data.tableName || null;
}
} catch (error) {
console.error("대상 화면 정보 로드 실패:", error);
}
setModalActionTargetTable(targetTableName);
// 3. 소스 테이블 컬럼 로드
if (sourceTableName) {
try {
const response = await apiClient.get(`/table-management/tables/${sourceTableName}/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)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
}));
setModalActionSourceColumns(columns);
}
}
} catch (error) {
console.error("소스 테이블 컬럼 로드 실패:", error);
}
}
// 4. 대상 테이블 컬럼 로드
if (targetTableName) {
try {
const response = await apiClient.get(`/table-management/tables/${targetTableName}/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)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
}));
setModalActionTargetColumns(columns);
}
}
} catch (error) {
console.error("대상 테이블 컬럼 로드 실패:", error);
}
}
// 5. 기존 필드 매핑 로드 또는 자동 매핑 생성
const existingMappings = config.action?.fieldMappings || [];
if (existingMappings.length > 0) {
setModalActionFieldMappings(existingMappings);
} else if (sourceTableName && targetTableName && sourceTableName === targetTableName) {
// 테이블이 같으면 자동 매핑 (동일 컬럼명)
setModalActionFieldMappings([]); // 빈 배열 = 자동 매핑
}
};
loadModalActionMappingData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.action?.type, config.action?.autoDetectDataSource, config.action?.targetScreenId, currentTableName, allComponents]);
// 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용)
useEffect(() => {
if (!currentTableName) return;
@ -350,7 +479,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
}));
setCurrentTableColumns(columns);
console.log(`✅ 현재 테이블 ${currentTableName} 컬럼 로드 성공:`, columns.length, "개");
}
}
} catch (error) {
@ -684,24 +812,34 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectValue placeholder="버튼 액션 선택" />
</SelectTrigger>
<SelectContent>
{/* 핵심 액션 */}
<SelectItem value="save"></SelectItem>
<SelectItem value="delete"></SelectItem>
<SelectItem value="edit"></SelectItem>
<SelectItem value="copy"> ( )</SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="transferData"> </SelectItem>
<SelectItem value="openModalWithData"> + </SelectItem>
<SelectItem value="openRelatedModal"> </SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>
<SelectItem value="transferData"> </SelectItem>
{/* 엑셀 관련 */}
<SelectItem value="excel_download"> </SelectItem>
<SelectItem value="excel_upload"> </SelectItem>
{/* 고급 기능 */}
<SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem>
{/* 특수 기능 (필요 시 사용) */}
<SelectItem value="barcode_scan"> </SelectItem>
<SelectItem value="code_merge"> </SelectItem>
{/* <SelectItem value="empty_vehicle">공차등록</SelectItem> */}
<SelectItem value="operation_control"> </SelectItem>
{/* 🔒 - , UI
<SelectItem value="copy"> ( )</SelectItem>
<SelectItem value="openRelatedModal"> </SelectItem>
<SelectItem value="openModalWithData">(deprecated) + </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>
<SelectItem value="code_merge"> </SelectItem>
<SelectItem value="empty_vehicle"></SelectItem>
*/}
</SelectContent>
</Select>
</div>
@ -768,8 +906,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
className="h-6 w-full justify-between px-2 py-0 text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
@ -829,39 +966,225 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</PopoverContent>
</Popover>
</div>
{/* 선택된 데이터 전달 옵션 */}
<div className="flex items-center space-x-2">
<Checkbox
id="auto-detect-data-source"
checked={component.componentConfig?.action?.autoDetectDataSource === true}
onCheckedChange={(checked) => {
onUpdateProperty("componentConfig.action.autoDetectDataSource", checked);
if (!checked) {
// 체크 해제 시 필드 매핑도 초기화
onUpdateProperty("componentConfig.action.fieldMappings", []);
}
}}
/>
<div className="flex flex-col">
<Label htmlFor="auto-detect-data-source" className="text-sm cursor-pointer">
</Label>
<p className="text-xs text-muted-foreground">
TableList/SplitPanel에서
</p>
</div>
</div>
{/* 🆕 필드 매핑 UI (데이터 전달 활성화 + 테이블이 다른 경우) */}
{component.componentConfig?.action?.autoDetectDataSource === true && (
<div className="mt-4 space-y-3 rounded-lg border bg-background p-3">
{/* 테이블 정보 표시 */}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<Database className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">:</span>
<span className="font-medium">{modalActionSourceTable || "감지 중..."}</span>
</div>
<span className="text-muted-foreground"></span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{modalActionTargetTable || "감지 중..."}</span>
</div>
</div>
{/* 테이블이 같으면 자동 매핑 안내 */}
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable === modalActionTargetTable && (
<div className="rounded-md bg-green-50 p-2 text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400">
. .
</div>
)}
{/* 테이블이 다르면 필드 매핑 UI 표시 */}
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable !== modalActionTargetTable && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => {
const newMappings = [...(component.componentConfig?.action?.fieldMappings || []), { sourceField: "", targetField: "" }];
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(component.componentConfig?.action?.fieldMappings || []).length === 0 && (
<p className="text-xs text-muted-foreground">
. .
</p>
)}
{(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => (
<div key={index} className="flex items-center gap-2">
{/* 소스 필드 선택 */}
<Popover
open={modalFieldMappingSourceOpen[index] || false}
onOpenChange={(open) => setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
{mapping.sourceField
? modalActionSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
: "소스 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingSourceSearch[index] || ""}
onValueChange={(val) => setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))}
/>
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{modalActionSourceColumns
.filter((col) =>
col.name.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) ||
col.label.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase())
)
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])];
newMappings[index] = { ...newMappings[index], sourceField: col.name };
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: false }));
}}
>
<Check
className={cn("mr-2 h-4 w-4", mapping.sourceField === col.name ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span className="text-xs font-medium">{col.label}</span>
<span className="text-[10px] text-muted-foreground">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<span className="text-xs text-muted-foreground"></span>
{/* 대상 필드 선택 */}
<Popover
open={modalFieldMappingTargetOpen[index] || false}
onOpenChange={(open) => setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
{mapping.targetField
? modalActionTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
: "대상 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingTargetSearch[index] || ""}
onValueChange={(val) => setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))}
/>
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{modalActionTargetColumns
.filter((col) =>
col.name.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) ||
col.label.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase())
)
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])];
newMappings[index] = { ...newMappings[index], targetField: col.name };
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: false }));
}}
>
<Check
className={cn("mr-2 h-4 w-4", mapping.targetField === col.name ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span className="text-xs font-medium">{col.label}</span>
<span className="text-[10px] text-muted-foreground">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:bg-destructive/10"
onClick={() => {
const newMappings = (component.componentConfig?.action?.fieldMappings || []).filter((_: any, i: number) => i !== index);
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 */}
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 (deprecated - 하위 호환성 유지) */}
{component.componentConfig?.action?.type === "openModalWithData" && (
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4 dark:bg-blue-950/20">
<h4 className="text-foreground text-sm font-medium"> + </h4>
<p className="text-muted-foreground text-xs">TableList에서 </p>
<div>
<Label htmlFor="data-source-id">
ID <span className="text-primary">()</span>
</Label>
<Input
id="data-source-id"
placeholder="비워두면 자동으로 감지됩니다"
value={component.componentConfig?.action?.dataSourceId || ""}
onChange={(e) => {
onUpdateProperty("componentConfig.action.dataSourceId", e.target.value);
}}
/>
<p className="text-primary mt-1 text-xs font-medium">
TableList를
</p>
<p className="text-muted-foreground mt-1 text-xs">
감지: 현재 TableList
<br />
전달: 이전
<br />
tableName으로
<br /> 설정: 필요시 (: item_info)
</p>
</div>
<div className="mt-4 space-y-4 rounded-lg border bg-amber-50 p-4 dark:bg-amber-950/20">
<h4 className="text-sm font-medium text-foreground"> + </h4>
<p className="text-xs text-amber-600 dark:text-amber-400">
"모달 열기" . "모달 열기" + "선택된 데이터 전달" .
</p>
{/* 🆕 블록 기반 제목 빌더 */}
<div className="space-y-2">

View File

@ -207,6 +207,7 @@ const NON_INPUT_COMPONENT_TYPES = new Set([
"modal",
"drawer",
"form-layout",
"aggregation-widget",
]);
// 컴포넌트가 입력 폼인지 확인
@ -491,7 +492,7 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
const extractFromComponent = (comp: ComponentData, parentType?: string, parentLabel?: string) => {
const anyComp = comp as any;
const config = anyComp.componentConfig;
const config = anyComp.componentConfig || anyComp.config;
const compType = anyComp.componentType || anyComp.type;
const compLabel = anyComp.label || anyComp.title || compType;
@ -728,6 +729,23 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
});
}
// 11. 집계 위젯 (aggregation-widget) 항목 라벨
if (compType === "aggregation-widget" && config?.items && Array.isArray(config.items)) {
config.items.forEach((item: any, index: number) => {
if (item.columnLabel && typeof item.columnLabel === "string") {
addLabel(
`${comp.id}_agg_${item.id || index}`,
item.columnLabel,
"label",
compType,
compLabel,
item.labelLangKeyId,
item.labelLangKey
);
}
});
}
// 자식 컴포넌트 재귀 탐색
if (anyComp.children && Array.isArray(anyComp.children)) {
anyComp.children.forEach((child: ComponentData) => {

View File

@ -2,11 +2,10 @@
import React, { useState, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ComponentDefinition, ComponentCategory } from "@/types/component";
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database, Wrench } from "lucide-react";
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Database, GripVertical } from "lucide-react";
import { TableInfo, ColumnInfo } from "@/types/screen";
import TablesPanel from "./TablesPanel";
@ -19,6 +18,9 @@ interface ComponentsPanelProps {
onTableDragStart?: (e: React.DragEvent, table: TableInfo, column?: ColumnInfo) => void;
selectedTableName?: string;
placedColumns?: Set<string>; // 이미 배치된 컬럼명 집합
// 테이블 선택 관련 props
onTableSelect?: (tableName: string) => void; // 테이블 선택 콜백
showTableSelector?: boolean; // 테이블 선택 UI 표시 여부 (기본: 테이블 없으면 표시)
}
export function ComponentsPanel({
@ -29,45 +31,123 @@ export function ComponentsPanel({
onTableDragStart,
selectedTableName,
placedColumns,
onTableSelect,
showTableSelector = true,
}: ComponentsPanelProps) {
const [searchQuery, setSearchQuery] = useState("");
// 레지스트리에서 모든 컴포넌트 조회
const allComponents = useMemo(() => {
const components = ComponentRegistry.getAllComponents();
// 수동으로 table-list 컴포넌트 추가 (임시)
const hasTableList = components.some((c) => c.id === "table-list");
if (!hasTableList) {
components.push({
id: "table-list",
name: "데이터 테이블 v2",
description: "검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트",
category: "display",
tags: ["table", "data", "crud"],
defaultSize: { width: 1000, height: 680 },
} as ComponentDefinition);
}
// v2-table-list가 자동 등록되므로 수동 추가 불필요
return components;
}, []);
// Unified 컴포넌트 정의 (새로운 통합 컴포넌트 시스템)
// 입력 컴포넌트(unified-input, unified-select, unified-date)는 테이블 컬럼 드래그 시 자동 생성되므로 숨김
const unifiedComponents = useMemo(
() =>
[
// unified-input: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
// unified-select: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
// unified-date: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
// unified-layout: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
// unified-group: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
// unified-list: table-list, card-display로 분리하여 숨김 처리
// unified-media 제거 - 테이블 컬럼의 image/file 입력 타입으로 사용
// unified-biz 제거 - 개별 컴포넌트(flow-widget, rack-structure, numbering-rule)로 직접 표시
// unified-hierarchy 제거 - 현재 미사용
{
id: "v2-unified-repeater",
name: "리피터 그리드",
description: "행 단위로 데이터를 추가/수정/삭제",
category: "data" as ComponentCategory,
tags: ["repeater", "table", "modal", "button", "unified", "v2"],
defaultSize: { width: 600, height: 300 },
},
] as ComponentDefinition[],
[],
);
// 카테고리별 컴포넌트 그룹화
const componentsByCategory = useMemo(() => {
// 숨길 컴포넌트 ID 목록 (기본 입력 컴포넌트들)
const hiddenInputComponents = ["text-input", "number-input", "date-input", "textarea-basic"];
// 숨길 컴포넌트 ID 목록
const hiddenComponents = [
// 기본 입력 컴포넌트 (테이블 컬럼 드래그 시 자동 생성)
"text-input",
"number-input",
"date-input",
"textarea-basic",
// Unified 컴포넌트로 대체됨
"image-widget", // → UnifiedMedia (image)
"file-upload", // → UnifiedMedia (file)
"entity-search-input", // → UnifiedSelect (entity 모드)
"autocomplete-search-input", // → UnifiedSelect (autocomplete 모드)
// DataFlow 전용 (일반 화면에서 불필요)
"mail-recipient-selector",
// 현재 사용 안함
"repeater-field-group",
// unified-repeater로 통합됨
"simple-repeater-table", // → unified-repeater (inline 모드)
"modal-repeater-table", // → unified-repeater (modal 모드)
// 특수 업무용 컴포넌트 (일반 화면에서 불필요)
"tax-invoice-list", // 세금계산서 전용
"customer-item-mapping", // 고객-품목 매핑 전용
// card-display는 별도 컴포넌트로 유지
// unified-media로 통합됨
"image-display", // → unified-media (image)
// 공통코드관리로 통합 예정
"category-manager", // → 공통코드관리 기능으로 통합 예정
// 분할 패널 정리 (split-panel-layout v1 유지)
"split-panel-layout2", // → split-panel-layout로 통합
"screen-split-panel", // 화면 임베딩 방식은 사용하지 않음
// 미완성/미사용 컴포넌트 (기존 화면 호환성 유지, 새 추가만 막음)
"accordion-basic", // 아코디언 컴포넌트
"conditional-container", // 조건부 컨테이너
"universal-form-modal", // 범용 폼 모달
// 통합 미디어 (테이블 컬럼 입력 타입으로 사용)
"unified-media", // → 테이블 컬럼의 image/file 입력 타입으로 사용
// 플로우 위젯 숨김 처리
"flow-widget",
// 선택 항목 상세입력 - 기존 컴포넌트 조합으로 대체 가능
"selected-items-detail-input",
// 연관 데이터 버튼 - unified-repeater로 대체 가능
"related-data-buttons",
// ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) =====
"button-primary", // → v2-button-primary
"split-panel-layout", // → v2-split-panel-layout
"aggregation-widget", // → v2-aggregation-widget
"card-display", // → v2-card-display
"table-list", // → v2-table-list
"text-display", // → v2-text-display
"divider-line", // → v2-divider-line
"numbering-rule", // → v2-numbering-rule
"section-paper", // → v2-section-paper
"section-card", // → v2-section-card
"location-swap-selector", // → v2-location-swap-selector
"rack-structure", // → v2-rack-structure
"unified-repeater", // → v2-unified-repeater (아래 unifiedComponents에서 별도 처리)
"repeat-container", // → v2-repeat-container
"repeat-screen-modal", // → v2-repeat-screen-modal
"pivot-grid", // → v2-pivot-grid
"table-search-widget", // → v2-table-search-widget
"tabs", // → v2-tabs
];
return {
input: allComponents.filter(
(c) => c.category === ComponentCategory.INPUT && !hiddenInputComponents.includes(c.id),
input: allComponents.filter((c) => c.category === ComponentCategory.INPUT && !hiddenComponents.includes(c.id)),
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION && !hiddenComponents.includes(c.id)),
display: allComponents.filter(
(c) => c.category === ComponentCategory.DISPLAY && !hiddenComponents.includes(c.id),
),
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
data: allComponents.filter((c) => c.category === ComponentCategory.DATA), // 🆕 데이터 카테고리 추가
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY),
data: allComponents.filter((c) => c.category === ComponentCategory.DATA && !hiddenComponents.includes(c.id)),
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT && !hiddenComponents.includes(c.id)),
utility: allComponents.filter(
(c) => c.category === ComponentCategory.UTILITY && !hiddenComponents.includes(c.id),
),
unified: unifiedComponents,
};
}, [allComponents]);
}, [allComponents, unifiedComponents]);
// 카테고리별 검색 필터링
const getFilteredComponents = (category: keyof typeof componentsByCategory) => {
@ -114,7 +194,25 @@ export function ComponentsPanel({
e.dataTransfer.effectAllowed = "copy";
};
// 컴포넌트 카드 렌더링 함수
// 카테고리별 배경색 매핑
const getCategoryColor = (category: string) => {
switch (category) {
case "data":
return "from-blue-500/10 to-blue-600/10 text-blue-600 group-hover:from-blue-500/20 group-hover:to-blue-600/20";
case "display":
return "from-emerald-500/10 to-emerald-600/10 text-emerald-600 group-hover:from-emerald-500/20 group-hover:to-emerald-600/20";
case "input":
return "from-violet-500/10 to-violet-600/10 text-violet-600 group-hover:from-violet-500/20 group-hover:to-violet-600/20";
case "layout":
return "from-amber-500/10 to-amber-600/10 text-amber-600 group-hover:from-amber-500/20 group-hover:to-amber-600/20";
case "action":
return "from-rose-500/10 to-rose-600/10 text-rose-600 group-hover:from-rose-500/20 group-hover:to-rose-600/20";
default:
return "from-slate-500/10 to-slate-600/10 text-slate-600 group-hover:from-slate-500/20 group-hover:to-slate-600/20";
}
};
// 컴포넌트 카드 렌더링 함수 (컴팩트 버전)
const renderComponentCard = (component: ComponentDefinition) => (
<div
key={component.id}
@ -128,21 +226,27 @@ export function ComponentsPanel({
e.currentTarget.style.opacity = "1";
e.currentTarget.style.transform = "none";
}}
className="group bg-card hover:border-primary/50 cursor-grab rounded-lg border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:scale-[0.98] active:cursor-grabbing"
className="group bg-card hover:border-primary/40 cursor-grab rounded-lg border px-3 py-2.5 transition-all duration-200 hover:shadow-sm active:scale-[0.98] active:cursor-grabbing"
>
<div className="flex items-start gap-3">
<div className="bg-primary/10 text-primary group-hover:bg-primary/20 flex h-10 w-10 items-center justify-center rounded-md transition-all duration-200">
<div className="flex items-center gap-2.5">
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-gradient-to-br transition-all duration-200 ${getCategoryColor(component.category)}`}
>
{getCategoryIcon(component.category)}
</div>
<div className="min-w-0 flex-1">
<h4 className="mb-1 text-xs leading-tight font-semibold">{component.name}</h4>
<p className="text-muted-foreground mb-1.5 line-clamp-2 text-xs leading-relaxed">{component.description}</p>
<div className="flex items-center">
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium">
<span className="text-foreground block truncate text-xs font-medium">{component.name}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-[10px] capitalize">{component.category}</span>
<span className="text-muted-foreground/60 text-[10px]">|</span>
<span className="text-muted-foreground text-[10px]">
{component.defaultSize.width}×{component.defaultSize.height}
</span>
</div>
</div>
<div className="text-muted-foreground/40 group-hover:text-muted-foreground/60 transition-colors">
<GripVertical className="h-3.5 w-3.5" />
</div>
</div>
</div>
);
@ -186,124 +290,50 @@ export function ComponentsPanel({
</div>
</div>
{/* 카테고리 탭 */}
<Tabs defaultValue="input" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-7 gap-1 p-1">
<TabsTrigger
value="tables"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
title="테이블"
>
{/* 테이블 / 컴포넌트 탭 */}
<Tabs defaultValue="tables" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mb-3 grid h-8 w-full shrink-0 grid-cols-2 gap-1 p-1">
<TabsTrigger value="tables" className="flex items-center justify-center gap-1 text-xs">
<Database className="h-3 w-3" />
<span className="hidden"></span>
<span></span>
</TabsTrigger>
<TabsTrigger value="input" className="flex items-center justify-center gap-0.5 px-0 text-[10px]" title="입력">
<Edit3 className="h-3 w-3" />
<span className="hidden"></span>
</TabsTrigger>
<TabsTrigger
value="data"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
title="데이터"
>
<Grid className="h-3 w-3" />
<span className="hidden"></span>
</TabsTrigger>
<TabsTrigger
value="action"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
title="액션"
>
<Zap className="h-3 w-3" />
<span className="hidden"></span>
</TabsTrigger>
<TabsTrigger
value="display"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
title="표시"
>
<BarChart3 className="h-3 w-3" />
<span className="hidden"></span>
</TabsTrigger>
<TabsTrigger
value="layout"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
title="레이아웃"
>
<Layers className="h-3 w-3" />
<span className="hidden"></span>
</TabsTrigger>
<TabsTrigger
value="utility"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
title="유틸리티"
>
<Wrench className="h-3 w-3" />
<span className="hidden"></span>
<TabsTrigger value="components" className="flex items-center justify-center gap-1 text-xs">
<Package className="h-3 w-3" />
<span></span>
</TabsTrigger>
</TabsList>
{/* 테이블 탭 */}
{/* 테이블 컬럼 탭 */}
<TabsContent value="tables" className="mt-0 flex-1 overflow-y-auto">
{tables.length > 0 && onTableDragStart ? (
<TablesPanel
tables={tables}
searchTerm={searchTerm}
onSearchChange={onSearchChange || (() => {})}
onDragStart={onTableDragStart}
selectedTableName={selectedTableName}
placedColumns={placedColumns}
/>
) : (
<div className="flex h-32 items-center justify-center text-center">
<div className="p-6">
<Database className="text-muted-foreground/40 mx-auto mb-2 h-10 w-10" />
<p className="text-muted-foreground text-xs font-medium"> </p>
</div>
</div>
)}
<TablesPanel
tables={tables}
searchTerm={searchTerm}
onSearchChange={onSearchChange || (() => {})}
onDragStart={onTableDragStart || (() => {})}
selectedTableName={selectedTableName}
placedColumns={placedColumns}
onTableSelect={onTableSelect}
showTableSelector={showTableSelector}
/>
</TabsContent>
{/* 입력 컴포넌트 */}
<TabsContent value="input" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{getFilteredComponents("input").length > 0
? getFilteredComponents("input").map(renderComponentCard)
: renderEmptyState()}
</TabsContent>
{/* 컴포넌트 탭 */}
<TabsContent value="components" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{(() => {
const allFilteredComponents = [
...getFilteredComponents("unified"),
...getFilteredComponents("action"),
...getFilteredComponents("display"),
...getFilteredComponents("data"),
...getFilteredComponents("layout"),
...getFilteredComponents("input"),
...getFilteredComponents("utility"),
];
{/* 데이터 컴포넌트 */}
<TabsContent value="data" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{getFilteredComponents("data").length > 0
? getFilteredComponents("data").map(renderComponentCard)
: renderEmptyState()}
</TabsContent>
{/* 액션 컴포넌트 */}
<TabsContent value="action" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{getFilteredComponents("action").length > 0
? getFilteredComponents("action").map(renderComponentCard)
: renderEmptyState()}
</TabsContent>
{/* 표시 컴포넌트 */}
<TabsContent value="display" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{getFilteredComponents("display").length > 0
? getFilteredComponents("display").map(renderComponentCard)
: renderEmptyState()}
</TabsContent>
{/* 레이아웃 컴포넌트 */}
<TabsContent value="layout" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{getFilteredComponents("layout").length > 0
? getFilteredComponents("layout").map(renderComponentCard)
: renderEmptyState()}
</TabsContent>
{/* 유틸리티 컴포넌트 */}
<TabsContent value="utility" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{getFilteredComponents("utility").length > 0
? getFilteredComponents("utility").map(renderComponentCard)
: renderEmptyState()}
return allFilteredComponents.length > 0
? allFilteredComponents.map(renderComponentCard)
: renderEmptyState();
})()}
</TabsContent>
</Tabs>

View File

@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { Settings, Database } from "lucide-react";
import { Settings, Database, Zap } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
@ -22,6 +22,8 @@ import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
import { BaseInputType, getBaseInputType, getDetailTypes, DetailTypeOption } from "@/types/input-type-mapping";
import { ConditionalConfigPanel } from "@/components/unified/ConditionalConfigPanel";
import { ConditionalConfig } from "@/types/unified-components";
// 새로운 컴포넌트 설정 패널들 import
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
@ -1192,7 +1194,28 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
}}
/>
{/* 🆕 테이블 데이터 자동 입력 섹션 (component 타입용) */}
{/* 조건부 표시 설정 (component 타입용) */}
<div className="space-y-4 rounded-md border border-gray-200 p-4">
<ConditionalConfigPanel
config={selectedComponent.conditional as ConditionalConfig | undefined}
onChange={(newConfig) => {
onUpdateProperty(selectedComponent.id, "conditional", newConfig);
}}
availableFields={components
.filter((c) => c.id !== selectedComponent.id && (c.type === "widget" || c.type === "component"))
.map((c) => ({
id: c.id,
label: (c as any).label || (c as any).columnName || c.id,
type: (c as any).widgetType || (c as any).componentConfig?.webType,
options: (c as any).webTypeConfig?.options || [],
}))}
currentComponentId={selectedComponent.id}
/>
</div>
<Separator />
{/* 테이블 데이터 자동 입력 섹션 (component 타입용) */}
<div className="space-y-4 rounded-md border border-gray-200 p-4">
<h4 className="flex items-center gap-2 text-sm font-medium">
<Database className="h-4 w-4" />
@ -1400,9 +1423,29 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
{/* 상세 설정 영역 */}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 w-full min-w-0">
<div className="space-y-6 w-full min-w-0">
{console.log("🔍 [DetailSettingsPanel] widget 타입:", selectedComponent?.type, "autoFill:", widget.autoFill)}
{/* 🆕 자동 입력 섹션 */}
<div className="space-y-4 rounded-md border border-red-500 bg-yellow-50 p-4">
{/* 조건부 표시 설정 */}
<div className="space-y-4 rounded-md border border-gray-200 p-4">
<ConditionalConfigPanel
config={widget.conditional as ConditionalConfig | undefined}
onChange={(newConfig) => {
onUpdateProperty(widget.id, "conditional", newConfig);
}}
availableFields={components
.filter((c) => c.id !== widget.id && (c.type === "widget" || c.type === "component"))
.map((c) => ({
id: c.id,
label: (c as any).label || (c as any).columnName || c.id,
type: (c as any).widgetType || (c as any).componentConfig?.webType,
options: (c as any).webTypeConfig?.options || [],
}))}
currentComponentId={widget.id}
/>
</div>
<Separator />
{/* 자동 입력 섹션 */}
<div className="space-y-4 rounded-md border border-gray-200 p-4">
<h4 className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4" />
🔥 ()

View File

@ -1,9 +1,50 @@
"use client";
import React from "react";
import React, { useState, useEffect, useCallback } from "react";
import { Badge } from "@/components/ui/badge";
import { Database, Type, Hash, Calendar, CheckSquare, List, AlignLeft, Code, Building, File } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Database,
Type,
Hash,
Calendar,
CheckSquare,
List,
AlignLeft,
Code,
Building,
File,
Link2,
ChevronDown,
ChevronRight,
Plus,
Search,
X,
} from "lucide-react";
import { TableInfo, WebType } from "@/types/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableManagementApi } from "@/lib/api/tableManagement";
interface EntityJoinColumn {
columnName: string;
columnLabel: string;
dataType: string;
inputType?: string;
description?: string;
}
interface EntityJoinTable {
tableName: string;
currentDisplayColumn: string;
availableColumns: EntityJoinColumn[];
}
interface TablesPanelProps {
tables: TableInfo[];
@ -12,6 +53,9 @@ interface TablesPanelProps {
onDragStart: (e: React.DragEvent, table: TableInfo, column?: any) => void;
selectedTableName?: string;
placedColumns?: Set<string>; // 이미 배치된 컬럼명 집합 (tableName.columnName 형식)
// 테이블 선택 관련 props
onTableSelect?: (tableName: string) => void; // 테이블 선택 콜백
showTableSelector?: boolean; // 테이블 선택 UI 표시 여부
}
// 위젯 타입별 아이콘
@ -52,16 +96,135 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
searchTerm,
onDragStart,
placedColumns = new Set(),
onTableSelect,
showTableSelector = false,
}) => {
// 엔티티 조인 컬럼 상태
const [entityJoinTables, setEntityJoinTables] = useState<EntityJoinTable[]>([]);
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
const [expandedJoinTables, setExpandedJoinTables] = useState<Set<string>>(new Set());
// 전체 테이블 목록 (테이블 선택용)
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingAllTables, setLoadingAllTables] = useState(false);
const [tableSearchTerm, setTableSearchTerm] = useState("");
const [showTableSelectDropdown, setShowTableSelectDropdown] = useState(false);
// 시스템 컬럼 목록 (숨김 처리)
const systemColumns = new Set([
'id',
'created_date',
'updated_date',
'writer',
'company_code'
"id",
"created_date",
"updated_date",
"writer",
"company_code",
]);
// 전체 테이블 목록 로드
const loadAllTables = useCallback(async () => {
if (allTables.length > 0) return; // 이미 로드됨
setLoadingAllTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.displayName || t.table_label || t.tableName || t.table_name,
})));
}
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
} finally {
setLoadingAllTables(false);
}
}, [allTables.length]);
// 테이블 선택 시 호출
const handleTableSelect = (tableName: string) => {
setShowTableSelectDropdown(false);
setTableSearchTerm("");
onTableSelect?.(tableName);
};
// 필터링된 테이블 목록
const filteredAllTables = tableSearchTerm
? allTables.filter(
(t) =>
t.tableName.toLowerCase().includes(tableSearchTerm.toLowerCase()) ||
t.displayName.toLowerCase().includes(tableSearchTerm.toLowerCase())
)
: allTables;
// 메인 테이블명 추출
const mainTableName = tables[0]?.tableName;
// 엔티티 조인 컬럼 로드
useEffect(() => {
const fetchEntityJoinColumns = async () => {
if (!mainTableName) {
setEntityJoinTables([]);
return;
}
setLoadingEntityJoins(true);
try {
const result = await entityJoinApi.getEntityJoinColumns(mainTableName);
setEntityJoinTables(result.joinTables || []);
// 기본적으로 모든 조인 테이블 펼치기
setExpandedJoinTables(new Set(result.joinTables?.map((t) => t.tableName) || []));
} catch (error) {
console.error("엔티티 조인 컬럼 조회 오류:", error);
setEntityJoinTables([]);
} finally {
setLoadingEntityJoins(false);
}
};
fetchEntityJoinColumns();
}, [mainTableName]);
// 조인 테이블 펼치기/접기 토글
const toggleJoinTable = (tableName: string) => {
setExpandedJoinTables((prev) => {
const newSet = new Set(prev);
if (newSet.has(tableName)) {
newSet.delete(tableName);
} else {
newSet.add(tableName);
}
return newSet;
});
};
// 엔티티 조인 컬럼 드래그 핸들러
const handleEntityJoinDragStart = (
e: React.DragEvent,
joinTable: EntityJoinTable,
column: EntityJoinColumn,
) => {
// "테이블명.컬럼명" 형식으로 컬럼 정보 생성
const fullColumnName = `${joinTable.tableName}.${column.columnName}`;
const columnData = {
columnName: fullColumnName,
columnLabel: column.columnLabel || column.columnName,
dataType: column.dataType,
widgetType: "text" as WebType,
isEntityJoin: true,
entityJoinTable: joinTable.tableName,
entityJoinColumn: column.columnName,
};
// 기존 테이블 정보를 기반으로 가상의 테이블 정보 생성
const virtualTable: TableInfo = {
tableName: mainTableName || "",
tableLabel: tables[0]?.tableLabel || mainTableName || "",
columns: [columnData],
};
onDragStart(e, virtualTable, columnData);
};
// 이미 배치된 컬럼과 시스템 컬럼을 제외한 테이블 정보 생성
const tablesWithAvailableColumns = tables.map((table) => ({
...table,
@ -105,6 +268,91 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
return (
<div className="flex h-full flex-col">
{/* 테이블 선택 버튼 (메인 테이블이 없을 때 또는 showTableSelector가 true일 때) */}
{(showTableSelector || tables.length === 0) && (
<div className="border-b p-3">
<div className="relative">
<Button
variant="outline"
size="sm"
className="w-full justify-between"
onClick={() => {
setShowTableSelectDropdown(!showTableSelectDropdown);
if (!showTableSelectDropdown) {
loadAllTables();
}
}}
>
<span className="flex items-center gap-2">
<Plus className="h-3.5 w-3.5" />
{tables.length > 0 ? "테이블 추가/변경" : "테이블 선택"}
</span>
<ChevronDown className="h-4 w-4" />
</Button>
{/* 드롭다운 */}
{showTableSelectDropdown && (
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-white shadow-lg">
{/* 검색 */}
<div className="border-b p-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="테이블 검색..."
value={tableSearchTerm}
onChange={(e) => setTableSearchTerm(e.target.value)}
autoFocus
className="w-full rounded-md border px-8 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{tableSearchTerm && (
<button
onClick={() => setTableSearchTerm("")}
className="absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-3.5 w-3.5 text-gray-400" />
</button>
)}
</div>
</div>
{/* 테이블 목록 */}
<div className="max-h-60 overflow-y-auto">
{loadingAllTables ? (
<div className="p-4 text-center text-sm text-gray-500"> ...</div>
) : filteredAllTables.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500">
{tableSearchTerm ? "검색 결과 없음" : "테이블 없음"}
</div>
) : (
filteredAllTables.map((t) => (
<button
key={t.tableName}
onClick={() => handleTableSelect(t.tableName)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100"
>
<Database className="h-3.5 w-3.5 text-blue-600" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{t.displayName}</div>
<div className="truncate text-xs text-gray-500">{t.tableName}</div>
</div>
</button>
))
)}
</div>
</div>
)}
</div>
{/* 현재 테이블 정보 */}
{tables.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground">
: {tables[0]?.tableLabel || tables[0]?.tableName}
</div>
)}
</div>
)}
{/* 테이블과 컬럼 평면 목록 */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-2">
@ -126,18 +374,19 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
{table.columns.map((column) => (
<div
key={column.columnName}
className="hover:bg-accent/50 flex cursor-grab items-center justify-between rounded-md p-2 transition-colors"
className="hover:bg-accent/50 flex cursor-grab items-center gap-2 rounded-md p-2 transition-colors"
draggable
onDragStart={(e) => onDragStart(e, table, column)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
{getWidgetIcon(column.widgetType)}
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{column.columnLabel || column.columnName}</div>
<div className="text-muted-foreground truncate text-[10px]">{column.dataType}</div>
<div
className="text-xs font-medium"
title={column.columnLabel || column.columnName}
>
{column.columnLabel || column.columnName}
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
{column.widgetType}
@ -153,6 +402,103 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
</div>
</div>
))}
{/* 엔티티 조인 컬럼 섹션 */}
{entityJoinTables.length > 0 && (
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 px-2 py-1">
<Link2 className="h-3.5 w-3.5 text-cyan-600" />
<span className="text-muted-foreground text-xs font-medium"> </span>
<Badge variant="outline" className="h-4 px-1.5 text-[10px]">
{entityJoinTables.length}
</Badge>
</div>
{entityJoinTables.map((joinTable) => {
const isExpanded = expandedJoinTables.has(joinTable.tableName);
// 검색어로 필터링
const filteredColumns = searchTerm
? joinTable.availableColumns.filter(
(col) =>
col.columnName.toLowerCase().includes(searchTerm.toLowerCase()) ||
col.columnLabel.toLowerCase().includes(searchTerm.toLowerCase()),
)
: joinTable.availableColumns;
// 검색 결과가 없으면 표시하지 않음
if (searchTerm && filteredColumns.length === 0) {
return null;
}
return (
<div key={joinTable.tableName} className="space-y-1">
{/* 조인 테이블 헤더 */}
<div
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"
onClick={() => toggleJoinTable(joinTable.tableName)}
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-3 w-3 text-cyan-600" />
) : (
<ChevronRight className="h-3 w-3 text-cyan-600" />
)}
<Building className="h-3.5 w-3.5 text-cyan-600" />
<span className="text-xs font-semibold text-cyan-800">{joinTable.tableName}</span>
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
{filteredColumns.length}
</Badge>
</div>
</div>
{/* 조인 컬럼 목록 */}
{isExpanded && (
<div className="space-y-1 pl-4">
{filteredColumns.map((column) => {
const fullColumnName = `${joinTable.tableName}.${column.columnName}`;
const isPlaced = placedColumns.has(fullColumnName);
if (isPlaced) return null;
return (
<div
key={column.columnName}
className="flex cursor-grab items-center gap-2 rounded-md border border-cyan-200 bg-cyan-50/50 p-2 transition-colors hover:bg-cyan-100"
draggable
onDragStart={(e) => handleEntityJoinDragStart(e, joinTable, column)}
title="읽기 전용 - 조인된 테이블에서 참조"
>
<Link2 className="h-3 w-3 flex-shrink-0 text-cyan-500" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium" title={column.columnLabel || column.columnName}>
{column.columnLabel || column.columnName}
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
<Badge variant="secondary" className="h-4 border-gray-300 bg-gray-100 px-1 text-[9px] text-gray-600">
</Badge>
<Badge variant="outline" className="h-4 border-cyan-300 px-1.5 text-[10px]">
{column.inputType || "text"}
</Badge>
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
{/* 로딩 표시 */}
{loadingEntityJoins && (
<div className="text-muted-foreground flex items-center justify-center py-4 text-xs">
...
</div>
)}
</div>
</div>
</div>

View File

@ -393,33 +393,33 @@ const fallbackTemplates: TemplateComponent[] = [
],
},
// 아코디언 영역
{
id: "area-accordion",
name: "아코디언 영역",
description: "접고 펼칠 수 있는 섹션들로 구성된 영역",
category: "area",
icon: <ChevronDown className="h-4 w-4" />,
defaultSize: { width: 500, height: 600 },
components: [
{
type: "area",
label: "아코디언 영역",
position: { x: 0, y: 0 },
size: { width: 500, height: 600 },
layoutType: "accordion",
layoutConfig: {
allowMultiple: false,
defaultExpanded: ["section1"],
},
style: {
backgroundColor: "#ffffff",
border: "1px solid #e5e7eb",
borderRadius: "8px",
},
},
],
},
// 아코디언 영역 - 숨김 처리
// {
// id: "area-accordion",
// name: "아코디언 영역",
// description: "접고 펼칠 수 있는 섹션들로 구성된 영역",
// category: "area",
// icon: <ChevronDown className="h-4 w-4" />,
// defaultSize: { width: 500, height: 600 },
// components: [
// {
// type: "area",
// label: "아코디언 영역",
// position: { x: 0, y: 0 },
// size: { width: 500, height: 600 },
// layoutType: "accordion",
// layoutConfig: {
// allowMultiple: false,
// defaultExpanded: ["section1"],
// },
// style: {
// backgroundColor: "#ffffff",
// border: "1px solid #e5e7eb",
// borderRadius: "8px",
// },
// },
// ],
// },
];
interface TemplatesPanelProps {

View File

@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown, Settings, Info, Database, Trash2, Copy, Palette, Monitor } from "lucide-react";
import { ChevronDown, Settings, Info, Database, Trash2, Copy, Palette } from "lucide-react";
import {
ComponentData,
WebType,
@ -59,24 +59,15 @@ import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
import StyleEditor from "../StyleEditor";
import ResolutionPanel from "./ResolutionPanel";
import { Slider } from "@/components/ui/slider";
import { Grid3X3, Eye, EyeOff, Zap } from "lucide-react";
import { Zap } from "lucide-react";
import { ConditionalConfigPanel } from "@/components/unified/ConditionalConfigPanel";
import { ConditionalConfig } from "@/types/unified-components";
interface UnifiedPropertiesPanelProps {
selectedComponent?: ComponentData;
tables: TableInfo[];
gridSettings?: {
columns: number;
gap: number;
padding: number;
snapToGrid: boolean;
showGrid: boolean;
gridColor?: string;
gridOpacity?: number;
};
onUpdateProperty: (componentId: string, path: string, value: any) => void;
onGridSettingsChange?: (settings: any) => void;
onDeleteComponent?: (componentId: string) => void;
onCopyComponent?: (componentId: string) => void;
currentTable?: TableInfo;
@ -84,9 +75,6 @@ interface UnifiedPropertiesPanelProps {
dragState?: any;
// 스타일 관련
onStyleChange?: (style: any) => void;
// 해상도 관련
currentResolution?: { name: string; width: number; height: number };
onResolutionChange?: (resolution: { name: string; width: number; height: number }) => void;
// 🆕 플로우 위젯 감지용
allComponents?: ComponentData[];
// 🆕 메뉴 OBJID (코드/카테고리 스코프용)
@ -98,9 +86,7 @@ interface UnifiedPropertiesPanelProps {
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
selectedComponent,
tables,
gridSettings,
onUpdateProperty,
onGridSettingsChange,
onDeleteComponent,
onCopyComponent,
currentTable,
@ -109,8 +95,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
dragState,
onStyleChange,
menuObjid,
currentResolution,
onResolutionChange,
allComponents = [], // 🆕 기본값 빈 배열
}) => {
const { webTypes } = useWebTypes({ active: "Y" });
@ -163,106 +147,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}
}, [selectedComponent?.size?.width, selectedComponent?.id]);
// 격자 설정 업데이트 함수 (early return 이전에 정의)
const updateGridSetting = (key: string, value: any) => {
if (onGridSettingsChange && gridSettings) {
onGridSettingsChange({
...gridSettings,
[key]: value,
});
}
};
// 격자 설정 렌더링 (early return 이전에 정의)
const renderGridSettings = () => {
if (!gridSettings || !onGridSettingsChange) return null;
// 최대 컬럼 수 계산
const MIN_COLUMN_WIDTH = 30;
const maxColumns = currentResolution
? Math.floor(
(currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) /
(MIN_COLUMN_WIDTH + gridSettings.gap),
)
: 24;
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Grid3X3 className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4>
</div>
<div className="space-y-3">
{/* 토글들 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{gridSettings.showGrid ? (
<Eye className="text-primary h-3 w-3" />
) : (
<EyeOff className="text-muted-foreground h-3 w-3" />
)}
<Label htmlFor="showGrid" className="text-xs font-medium">
</Label>
</div>
<Checkbox
id="showGrid"
checked={gridSettings.showGrid}
onCheckedChange={(checked) => updateGridSetting("showGrid", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className="text-primary h-3 w-3" />
<Label htmlFor="snapToGrid" className="text-xs font-medium">
</Label>
</div>
<Checkbox
id="snapToGrid"
checked={gridSettings.snapToGrid}
onCheckedChange={(checked) => updateGridSetting("snapToGrid", checked)}
/>
</div>
{/* 10px 단위 스냅 안내 */}
<div className="bg-muted/50 rounded-md p-2">
<p className="text-muted-foreground text-[10px]"> 10px .</p>
</div>
</div>
</div>
);
};
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
// 컴포넌트가 선택되지 않았을 때는 안내 메시지만 표시
if (!selectedComponent) {
return (
<div className="flex h-full flex-col overflow-x-auto bg-white">
{/* 해상도 설정과 격자 설정 표시 */}
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
<div className="space-y-4 text-xs">
{/* 해상도 설정 */}
{currentResolution && onResolutionChange && (
<>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Monitor className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4>
</div>
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
</div>
<Separator className="my-2" />
</>
)}
{/* 격자 설정 */}
{renderGridSettings()}
{/* 안내 메시지 */}
<Separator className="my-4" />
<div className="flex flex-col items-center justify-center py-8 text-center">
<Settings className="text-muted-foreground/30 mb-2 h-8 w-8" />
<p className="text-muted-foreground text-[10px]"> </p>
@ -313,6 +204,53 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
selectedComponent.componentConfig?.id ||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
// 🆕 Unified 컴포넌트 직접 감지 및 설정 패널 렌더링
if (componentId?.startsWith("unified-")) {
const unifiedConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
"unified-input": require("@/components/unified/config-panels/UnifiedInputConfigPanel").UnifiedInputConfigPanel,
"unified-select": require("@/components/unified/config-panels/UnifiedSelectConfigPanel")
.UnifiedSelectConfigPanel,
"unified-date": require("@/components/unified/config-panels/UnifiedDateConfigPanel").UnifiedDateConfigPanel,
"unified-list": require("@/components/unified/config-panels/UnifiedListConfigPanel").UnifiedListConfigPanel,
"unified-layout": require("@/components/unified/config-panels/UnifiedLayoutConfigPanel")
.UnifiedLayoutConfigPanel,
"unified-group": require("@/components/unified/config-panels/UnifiedGroupConfigPanel").UnifiedGroupConfigPanel,
"unified-media": require("@/components/unified/config-panels/UnifiedMediaConfigPanel").UnifiedMediaConfigPanel,
"unified-biz": require("@/components/unified/config-panels/UnifiedBizConfigPanel").UnifiedBizConfigPanel,
"unified-hierarchy": require("@/components/unified/config-panels/UnifiedHierarchyConfigPanel")
.UnifiedHierarchyConfigPanel,
};
const UnifiedConfigPanel = unifiedConfigPanels[componentId];
if (UnifiedConfigPanel) {
const currentConfig = selectedComponent.componentConfig || {};
const handleUnifiedConfigChange = (newConfig: any) => {
onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig });
};
// 컬럼의 inputType 가져오기 (entity 타입인지 확인용)
const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
// 현재 화면의 테이블명 가져오기
const currentTableName = tables?.[0]?.tableName;
// 컴포넌트별 추가 props
const extraProps: Record<string, any> = {};
if (componentId === "unified-select") {
extraProps.inputType = inputType;
}
if (componentId === "unified-list") {
extraProps.currentTableName = currentTableName;
}
return (
<div key={selectedComponent.id} className="space-y-4">
<UnifiedConfigPanel config={currentConfig} onChange={handleUnifiedConfigChange} {...extraProps} />
</div>
);
}
}
if (componentId) {
const definition = ComponentRegistry.getComponent(componentId);
@ -347,10 +285,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
return (
<div key={selectedComponent.id} className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="text-primary h-4 w-4" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<Suspense
fallback={
<div className="flex items-center justify-center py-8">
@ -368,6 +302,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (채번 규칙 등)
/>
</Suspense>
</div>
@ -715,16 +650,89 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
const group = selectedComponent as GroupComponent;
const area = selectedComponent as AreaComponent;
// 라벨 설정이 표시될 입력 필드 타입들
const inputFieldTypes = [
"text",
"number",
"decimal",
"date",
"datetime",
"time",
"email",
"tel",
"url",
"password",
"textarea",
"select",
"dropdown",
"entity",
"code",
"checkbox",
"radio",
"boolean",
"file",
"autocomplete",
"text-input",
"number-input",
"date-input",
"textarea-basic",
"select-basic",
"checkbox-basic",
"radio-basic",
"entity-search-input",
"autocomplete-search-input",
// 새로운 통합 입력 컴포넌트
"unified-input",
"unified-select",
"unified-entity-select",
"unified-checkbox",
"unified-radio",
"unified-textarea",
"unified-date",
"unified-datetime",
"unified-time",
"unified-file",
];
// 현재 컴포넌트가 입력 필드인지 확인
const componentType = widget.widgetType || (widget as any).componentId || (widget as any).componentType;
const isInputField = inputFieldTypes.includes(componentType);
return (
<div className="space-y-2">
{/* 라벨 + 최소 높이 (같은 행) */}
{/* 너비 + 높이 (같은 행) */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Label className="text-xs"> (px)</Label>
<Input
value={widget.label || ""}
onChange={(e) => handleUpdate("label", e.target.value)}
placeholder="라벨"
type="number"
min={10}
max={3840}
step="1"
value={localWidth}
onChange={(e) => {
setLocalWidth(e.target.value);
}}
onBlur={(e) => {
const value = parseInt(e.target.value) || 0;
if (value >= 10) {
const snappedValue = Math.round(value / 10) * 10;
handleUpdate("size.width", snappedValue);
setLocalWidth(String(snappedValue));
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
const value = parseInt(e.currentTarget.value) || 0;
if (value >= 10) {
const snappedValue = Math.round(value / 10) * 10;
handleUpdate("size.width", snappedValue);
setLocalWidth(String(snappedValue));
}
e.currentTarget.blur();
}
}}
placeholder="100"
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
@ -734,11 +742,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
type="number"
value={localHeight}
onChange={(e) => {
// 입력 중에는 로컬 상태만 업데이트 (자유 입력)
setLocalHeight(e.target.value);
}}
onBlur={(e) => {
// 포커스를 잃을 때 10px 단위로 스냅
const value = parseInt(e.target.value) || 0;
if (value >= 10) {
const snappedValue = Math.round(value / 10) * 10;
@ -747,7 +753,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}
}}
onKeyDown={(e) => {
// Enter 키를 누르면 즉시 적용 (10px 단위로 스냅)
if (e.key === "Enter") {
const value = parseInt(e.currentTarget.value) || 0;
if (value >= 10) {
@ -755,7 +760,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdate("size.height", snappedValue);
setLocalHeight(String(snappedValue));
}
e.currentTarget.blur(); // 포커스 제거
e.currentTarget.blur();
}
}}
step={1}
@ -765,19 +770,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div>
</div>
{/* Placeholder (widget만) */}
{selectedComponent.type === "widget" && (
<div className="space-y-1">
<Label className="text-xs">Placeholder</Label>
<Input
value={widget.placeholder || ""}
onChange={(e) => handleUpdate("placeholder", e.target.value)}
placeholder="입력 안내 텍스트"
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
)}
{/* Title (group/area) */}
{(selectedComponent.type === "group" || selectedComponent.type === "area") && (
<div className="space-y-1">
@ -804,116 +796,74 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div>
)}
{/* Width + Z-Index (같은 행) */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> (px)</Label>
<div className="flex items-center gap-1">
<Input
type="number"
min={10}
max={3840}
step="1"
value={localWidth}
onChange={(e) => {
// 입력 중에는 로컬 상태만 업데이트 (자유 입력)
setLocalWidth(e.target.value);
}}
onBlur={(e) => {
// 포커스를 잃을 때 10px 단위로 스냅
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 10) {
const snappedValue = Math.round(value / 10) * 10;
handleUpdate("size.width", snappedValue);
setLocalWidth(String(snappedValue));
}
}}
onKeyDown={(e) => {
// Enter 키를 누르면 즉시 적용 (10px 단위로 스냅)
if (e.key === "Enter") {
const value = parseInt(e.currentTarget.value, 10);
if (!isNaN(value) && value >= 10) {
const snappedValue = Math.round(value / 10) * 10;
handleUpdate("size.width", snappedValue);
setLocalWidth(String(snappedValue));
}
e.currentTarget.blur(); // 포커스 제거
}
}}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Z-Index</Label>
<Input
type="number"
step="1"
value={currentPosition.z || 1}
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
className="h-6 w-full px-2 py-0 text-xs"
className="text-xs"
/>
</div>
{/* Z-Index */}
<div className="space-y-1">
<Label className="text-xs">Z-Index</Label>
<Input
type="number"
step="1"
value={currentPosition.z || 1}
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
{/* 라벨 스타일 */}
<Collapsible>
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg bg-slate-50 p-2 text-xs font-medium hover:bg-slate-100">
<ChevronDown className="h-3.5 w-3.5" />
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={selectedComponent.style?.labelText || selectedComponent.label || ""}
onChange={(e) => handleUpdate("style.labelText", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
className="text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
{/* 라벨 스타일 - 입력 필드에서만 표시 */}
{isInputField && (
<Collapsible>
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg bg-slate-50 p-2 text-xs font-medium hover:bg-slate-100">
<ChevronDown className="h-3.5 w-3.5" />
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Label className="text-xs"> </Label>
<Input
value={selectedComponent.style?.labelFontSize || "12px"}
onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)}
value={selectedComponent.style?.labelText || selectedComponent.label || ""}
onChange={(e) => handleUpdate("style.labelText", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<ColorPickerWithTransparent
value={selectedComponent.style?.labelColor}
onChange={(value) => handleUpdate("style.labelColor", value)}
defaultColor="#212121"
placeholder="#212121"
/>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={selectedComponent.style?.labelFontSize || "12px"}
onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<ColorPickerWithTransparent
value={selectedComponent.style?.labelColor}
onChange={(value) => handleUpdate("style.labelColor", value)}
defaultColor="#212121"
placeholder="#212121"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={selectedComponent.style?.labelMarginBottom || "4px"}
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
className="text-xs"
/>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={selectedComponent.style?.labelMarginBottom || "4px"}
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
<div className="flex items-center space-x-2 pt-5">
<Checkbox
checked={selectedComponent.style?.labelDisplay !== false}
onCheckedChange={(checked) => handleUpdate("style.labelDisplay", checked)}
className="h-4 w-4"
/>
<Label className="text-xs"></Label>
</div>
</div>
<div className="flex items-center space-x-2 pt-5">
<Checkbox
checked={selectedComponent.style?.labelDisplay !== false}
onCheckedChange={(checked) => handleUpdate("style.labelDisplay", checked)}
className="h-4 w-4"
/>
<Label className="text-xs"></Label>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</CollapsibleContent>
</Collapsible>
)}
{/* 옵션 */}
<div className="grid grid-cols-2 gap-2">
@ -989,6 +939,16 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
);
}
// 🆕 3.5. Unified 컴포넌트 - 반드시 다른 체크보다 먼저 처리
const unifiedComponentType =
(selectedComponent as any).componentType || selectedComponent.componentConfig?.type || "";
if (unifiedComponentType.startsWith("unified-")) {
const configPanel = renderComponentConfigPanel();
if (configPanel) {
return <div className="space-y-4">{configPanel}</div>;
}
}
// 4. 새로운 컴포넌트 시스템 (button, card 등)
const componentType = selectedComponent.componentConfig?.type || selectedComponent.type;
const hasNewConfigPanel =
@ -1443,24 +1403,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
{/* 통합 컨텐츠 (탭 제거) */}
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
<div className="space-y-4 text-xs">
{/* 해상도 설정 - 항상 맨 위에 표시 */}
{currentResolution && onResolutionChange && (
<>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Monitor className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4>
</div>
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
</div>
<Separator className="my-2" />
</>
)}
{/* 격자 설정 - 해상도 설정 아래 표시 */}
{renderGridSettings()}
{gridSettings && onGridSettingsChange && <Separator className="my-2" />}
{/* 기본 설정 */}
{renderBasicTab()}
@ -1468,6 +1410,93 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<Separator className="my-2" />
{renderDetailTab()}
{/* 조건부 표시 설정 */}
{selectedComponent && (
<>
<Separator className="my-2" />
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Zap className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4>
</div>
<div className="rounded-md border border-gray-200 p-2">
<ConditionalConfigPanel
config={
(selectedComponent as any).conditional || {
enabled: false,
field: "",
operator: "=",
value: "",
action: "show",
}
}
onChange={(newConfig: ConditionalConfig | undefined) => {
handleUpdate("conditional", newConfig);
}}
availableFields={
allComponents
?.filter((c) => {
// 자기 자신 제외
if (c.id === selectedComponent.id) return false;
// widget 타입 또는 component 타입 (Unified 컴포넌트 포함)
return c.type === "widget" || c.type === "component";
})
.map((c) => {
const widgetType = (c as any).widgetType || (c as any).componentType || "text";
const config = (c as any).componentConfig || (c as any).webTypeConfig || {};
const detailSettings = (c as any).detailSettings || {};
// 정적 옵션 추출 (select, dropdown, radio, entity 등)
let options: Array<{ value: string; label: string }> | undefined;
// Unified 컴포넌트의 경우
if (config.options && Array.isArray(config.options)) {
options = config.options;
}
// 레거시 컴포넌트의 경우
else if ((c as any).options && Array.isArray((c as any).options)) {
options = (c as any).options;
}
// 엔티티 정보 추출 (config > detailSettings > 직접 속성 순으로 우선순위)
const entityTable =
config.entityTable ||
detailSettings.referenceTable ||
(c as any).entityTable ||
(c as any).referenceTable;
const entityValueColumn =
config.entityValueColumn ||
detailSettings.referenceColumn ||
(c as any).entityValueColumn ||
(c as any).referenceColumn;
const entityLabelColumn =
config.entityLabelColumn ||
detailSettings.displayColumn ||
(c as any).entityLabelColumn ||
(c as any).displayColumn;
// 공통코드 정보 추출
const codeGroup = config.codeGroup || detailSettings.codeGroup || (c as any).codeGroup;
return {
id: (c as any).columnName || c.id,
label: (c as any).label || config.label || c.id,
type: widgetType,
options,
entityTable,
entityValueColumn,
entityLabelColumn,
codeGroup,
};
}) || []
}
currentComponentId={selectedComponent.id}
/>
</div>
</div>
</>
)}
{/* 스타일 설정 */}
{selectedComponent && (
<>

View File

@ -1,9 +1,52 @@
"use client";
import React from "react";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Database, ArrowLeft, Save, Monitor, Smartphone, Languages, Settings2 } from "lucide-react";
import { ScreenResolution } from "@/types/screen";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Database,
ArrowLeft,
Save,
Monitor,
Smartphone,
Tablet,
ChevronDown,
Settings,
Grid3X3,
Eye,
EyeOff,
Zap,
Languages,
Settings2,
PanelLeft,
PanelLeftClose,
} from "lucide-react";
import { ScreenResolution, SCREEN_RESOLUTIONS } from "@/types/screen";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface GridSettings {
columns: number;
gap: number;
padding: number;
snapToGrid: boolean;
showGrid: boolean;
gridColor?: string;
gridOpacity?: number;
}
interface SlimToolbarProps {
screenName?: string;
@ -13,9 +56,15 @@ interface SlimToolbarProps {
onSave: () => void;
isSaving?: boolean;
onPreview?: () => void;
onResolutionChange?: (resolution: ScreenResolution) => void;
gridSettings?: GridSettings;
onGridSettingsChange?: (settings: GridSettings) => void;
onGenerateMultilang?: () => void;
isGeneratingMultilang?: boolean;
onOpenMultilangSettings?: () => void;
// 패널 토글 기능
isPanelOpen?: boolean;
onTogglePanel?: () => void;
}
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
@ -26,19 +75,86 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
onSave,
isSaving = false,
onPreview,
onResolutionChange,
gridSettings,
onGridSettingsChange,
onGenerateMultilang,
isGeneratingMultilang = false,
onOpenMultilangSettings,
isPanelOpen = false,
onTogglePanel,
}) => {
// 사용자 정의 해상도 상태
const [customWidth, setCustomWidth] = useState("");
const [customHeight, setCustomHeight] = useState("");
const [showCustomInput, setShowCustomInput] = useState(false);
const getCategoryIcon = (category: string) => {
switch (category) {
case "desktop":
return <Monitor className="h-4 w-4 text-blue-600" />;
case "tablet":
return <Tablet className="h-4 w-4 text-green-600" />;
case "mobile":
return <Smartphone className="h-4 w-4 text-purple-600" />;
default:
return <Monitor className="h-4 w-4 text-blue-600" />;
}
};
const handleCustomResolution = () => {
const width = parseInt(customWidth);
const height = parseInt(customHeight);
if (width > 0 && height > 0 && onResolutionChange) {
const customResolution: ScreenResolution = {
width,
height,
name: `사용자 정의 (${width}×${height})`,
category: "custom",
};
onResolutionChange(customResolution);
setShowCustomInput(false);
}
};
const updateGridSetting = (key: keyof GridSettings, value: boolean) => {
if (onGridSettingsChange && gridSettings) {
onGridSettingsChange({
...gridSettings,
[key]: value,
});
}
};
return (
<div className="flex h-14 items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 shadow-sm">
{/* 좌측: 네비게이션 및 화면 정보 */}
{/* 좌측: 네비게이션 + 패널 토글 + 화면 정보 */}
<div className="flex items-center space-x-4">
<Button variant="ghost" size="sm" onClick={onBack} className="flex items-center space-x-2">
<ArrowLeft className="h-4 w-4" />
<span></span>
</Button>
{onTogglePanel && <div className="h-6 w-px bg-gray-300" />}
{/* 패널 토글 버튼 */}
{onTogglePanel && (
<Button
variant={isPanelOpen ? "default" : "outline"}
size="sm"
onClick={onTogglePanel}
className="flex items-center space-x-2"
title="패널 열기/닫기 (P)"
>
{isPanelOpen ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelLeft className="h-4 w-4" />
)}
<span></span>
</Button>
)}
<div className="h-6 w-px bg-gray-300" />
<div className="flex items-center space-x-3">
@ -53,16 +169,149 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
</div>
</div>
{/* 해상도 정보 표시 */}
{/* 해상도 선택 드롭다운 */}
{screenResolution && (
<>
<div className="h-6 w-px bg-gray-300" />
<div className="flex items-center space-x-2 rounded-md bg-blue-50 px-3 py-1.5">
<Monitor className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-900">{screenResolution.name}</span>
<span className="text-xs text-blue-600">
({screenResolution.width} × {screenResolution.height})
</span>
<Popover open={showCustomInput} onOpenChange={setShowCustomInput}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center space-x-2 rounded-md bg-blue-50 px-3 py-1.5 transition-colors hover:bg-blue-100">
{getCategoryIcon(screenResolution.category || "desktop")}
<span className="text-sm font-medium text-blue-900">{screenResolution.name}</span>
<span className="text-xs text-blue-600">
({screenResolution.width} × {screenResolution.height})
</span>
{onResolutionChange && <ChevronDown className="h-3 w-3 text-blue-600" />}
</button>
</DropdownMenuTrigger>
{onResolutionChange && (
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs text-gray-500"></DropdownMenuLabel>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "desktop").map((resolution) => (
<DropdownMenuItem
key={resolution.name}
onClick={() => onResolutionChange(resolution)}
className="flex items-center space-x-2"
>
<Monitor className="h-4 w-4 text-blue-600" />
<span className="flex-1">{resolution.name}</span>
<span className="text-xs text-gray-400">
{resolution.width}×{resolution.height}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-gray-500">릿</DropdownMenuLabel>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "tablet").map((resolution) => (
<DropdownMenuItem
key={resolution.name}
onClick={() => onResolutionChange(resolution)}
className="flex items-center space-x-2"
>
<Tablet className="h-4 w-4 text-green-600" />
<span className="flex-1">{resolution.name}</span>
<span className="text-xs text-gray-400">
{resolution.width}×{resolution.height}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-gray-500"></DropdownMenuLabel>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "mobile").map((resolution) => (
<DropdownMenuItem
key={resolution.name}
onClick={() => onResolutionChange(resolution)}
className="flex items-center space-x-2"
>
<Smartphone className="h-4 w-4 text-purple-600" />
<span className="flex-1">{resolution.name}</span>
<span className="text-xs text-gray-400">
{resolution.width}×{resolution.height}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-gray-500"> </DropdownMenuLabel>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setCustomWidth(screenResolution.width.toString());
setCustomHeight(screenResolution.height.toString());
setShowCustomInput(true);
}}
className="flex items-center space-x-2"
>
<Settings className="h-4 w-4 text-gray-600" />
<span className="flex-1"> ...</span>
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
<PopoverContent align="start" className="w-64 p-3">
<div className="space-y-3">
<div className="text-sm font-medium"> </div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-gray-500"> (px)</Label>
<Input
type="number"
value={customWidth}
onChange={(e) => setCustomWidth(e.target.value)}
placeholder="1920"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"> (px)</Label>
<Input
type="number"
value={customHeight}
onChange={(e) => setCustomHeight(e.target.value)}
placeholder="1080"
className="h-8 text-xs"
/>
</div>
</div>
<Button onClick={handleCustomResolution} size="sm" className="w-full">
</Button>
</div>
</PopoverContent>
</Popover>
</>
)}
{/* 격자 설정 */}
{gridSettings && onGridSettingsChange && (
<>
<div className="h-6 w-px bg-gray-300" />
<div className="flex items-center space-x-2 rounded-md bg-gray-50 px-3 py-1.5">
<Grid3X3 className="h-4 w-4 text-gray-600" />
<div className="flex items-center space-x-3">
<label className="flex cursor-pointer items-center space-x-1.5">
{gridSettings.showGrid ? (
<Eye className="h-3.5 w-3.5 text-primary" />
) : (
<EyeOff className="h-3.5 w-3.5 text-gray-400" />
)}
<Checkbox
checked={gridSettings.showGrid}
onCheckedChange={(checked) => updateGridSetting("showGrid", checked as boolean)}
className="h-3.5 w-3.5"
/>
<span className="text-xs text-gray-600"> </span>
</label>
<label className="flex cursor-pointer items-center space-x-1.5">
<Zap className={`h-3.5 w-3.5 ${gridSettings.snapToGrid ? "text-primary" : "text-gray-400"}`} />
<Checkbox
checked={gridSettings.snapToGrid}
onCheckedChange={(checked) => updateGridSetting("snapToGrid", checked as boolean)}
className="h-3.5 w-3.5"
/>
<span className="text-xs text-gray-600"> </span>
</label>
</div>
</div>
</>
)}

View File

@ -15,38 +15,48 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
// react-day-picker v9 클래스명
months: "flex flex-col sm:flex-row gap-4",
month: "flex flex-col gap-4",
month_caption: "flex justify-center pt-1 relative items-center h-7",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
nav: "flex items-center gap-1",
button_previous: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex w-full",
head_cell:
button_next: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1",
),
month_grid: "w-full border-collapse",
weekdays: "flex w-full",
weekday:
"text-muted-foreground rounded-md w-9 h-9 font-normal text-[0.8rem] inline-flex items-center justify-center",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
week: "flex w-full mt-2",
day: "h-9 w-9 text-center text-sm p-0 relative",
day_button: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100",
),
range_end: "day-range-end",
selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground rounded-md",
today: "bg-accent text-accent-foreground rounded-md",
outside:
"text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
disabled: "text-muted-foreground opacity-50",
range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
Chevron: ({ orientation }) => {
if (orientation === "left") {
return <ChevronLeft className="h-4 w-4" />;
}
return <ChevronRight className="h-4 w-4" />;
},
}}
{...props}
/>

View File

@ -0,0 +1,493 @@
"use client";
/**
* ConditionalConfigPanel
*
* /// UI
*
* :
* - >
* - >
*/
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Zap, Plus, Trash2, HelpCircle, Check, ChevronsUpDown } from "lucide-react";
import { ConditionalConfig } from "@/types/unified-components";
import { cn } from "@/lib/utils";
// ===== 타입 정의 =====
interface FieldOption {
id: string;
label: string;
type?: string; // text, number, select, checkbox, entity, code 등
options?: Array<{ value: string; label: string }>; // select 타입일 경우 옵션들
// 동적 옵션 로드를 위한 정보
entityTable?: string;
entityValueColumn?: string;
entityLabelColumn?: string;
codeGroup?: string;
}
interface ConditionalConfigPanelProps {
/** 현재 조건부 설정 */
config?: ConditionalConfig;
/** 설정 변경 콜백 */
onChange: (config: ConditionalConfig | undefined) => void;
/** 같은 화면에 있는 다른 필드들 (조건 필드로 선택 가능) */
availableFields: FieldOption[];
/** 현재 컴포넌트 ID (자기 자신은 조건 필드에서 제외) */
currentComponentId?: string;
}
// 연산자 옵션
const OPERATORS: Array<{ value: ConditionalConfig["operator"]; label: string; description: string }> = [
{ value: "=", label: "같음", description: "값이 정확히 일치할 때" },
{ value: "!=", label: "다름", description: "값이 일치하지 않을 때" },
{ value: ">", label: "보다 큼", description: "값이 더 클 때 (숫자)" },
{ value: "<", label: "보다 작음", description: "값이 더 작을 때 (숫자)" },
{ value: "in", label: "포함됨", description: "여러 값 중 하나일 때" },
{ value: "notIn", label: "포함 안됨", description: "여러 값 중 아무것도 아닐 때" },
{ value: "isEmpty", label: "비어있음", description: "값이 없을 때" },
{ value: "isNotEmpty", label: "값이 있음", description: "값이 있을 때" },
];
// 동작 옵션
const ACTIONS: Array<{ value: ConditionalConfig["action"]; label: string; description: string }> = [
{ value: "show", label: "표시", description: "조건 만족 시 이 필드를 표시" },
{ value: "hide", label: "숨김", description: "조건 만족 시 이 필드를 숨김" },
{ value: "enable", label: "활성화", description: "조건 만족 시 이 필드를 활성화" },
{ value: "disable", label: "비활성화", description: "조건 만족 시 이 필드를 비활성화" },
];
// ===== 컴포넌트 =====
export function ConditionalConfigPanel({
config,
onChange,
availableFields,
currentComponentId,
}: ConditionalConfigPanelProps) {
// 로컬 상태
const [enabled, setEnabled] = useState(config?.enabled ?? false);
const [field, setField] = useState(config?.field ?? "");
const [operator, setOperator] = useState<ConditionalConfig["operator"]>(config?.operator ?? "=");
const [value, setValue] = useState<string>(String(config?.value ?? ""));
const [action, setAction] = useState<ConditionalConfig["action"]>(config?.action ?? "show");
// 자기 자신을 제외한 필드 목록
const selectableFields = useMemo(() => {
return availableFields.filter((f) => f.id !== currentComponentId);
}, [availableFields, currentComponentId]);
// 선택된 필드 정보
const selectedField = useMemo(() => {
return selectableFields.find((f) => f.id === field);
}, [selectableFields, field]);
// 동적 옵션 로드 상태
const [dynamicOptions, setDynamicOptions] = useState<Array<{ value: string; label: string }>>([]);
const [loadingOptions, setLoadingOptions] = useState(false);
// Combobox 열림 상태
const [comboboxOpen, setComboboxOpen] = useState(false);
// 엔티티/공통코드 필드 선택 시 동적으로 옵션 로드
useEffect(() => {
const loadDynamicOptions = async () => {
if (!selectedField) {
setDynamicOptions([]);
return;
}
// 정적 옵션이 있으면 사용
if (selectedField.options && selectedField.options.length > 0) {
setDynamicOptions([]);
return;
}
// 엔티티 타입 (타입이 entity이거나, entityTable이 있으면 엔티티로 간주)
if (selectedField.entityTable) {
setLoadingOptions(true);
try {
const { apiClient } = await import("@/lib/api/client");
const valueCol = selectedField.entityValueColumn || "id";
const labelCol = selectedField.entityLabelColumn || "name";
const response = await apiClient.get(`/entity/${selectedField.entityTable}/options`, {
params: { value: valueCol, label: labelCol },
});
if (response.data.success && response.data.data) {
setDynamicOptions(response.data.data);
}
} catch (error) {
console.error("엔티티 옵션 로드 실패:", error);
setDynamicOptions([]);
} finally {
setLoadingOptions(false);
}
return;
}
// 공통코드 타입 (타입이 code이거나, codeGroup이 있으면 공통코드로 간주)
if (selectedField.codeGroup) {
setLoadingOptions(true);
try {
const { apiClient } = await import("@/lib/api/client");
// 올바른 API 경로: /common-codes/categories/:categoryCode/options
const response = await apiClient.get(`/common-codes/categories/${selectedField.codeGroup}/options`);
if (response.data.success && response.data.data) {
setDynamicOptions(
response.data.data.map((item: { value: string; label: string }) => ({
value: item.value,
label: item.label,
}))
);
}
} catch (error) {
console.error("공통코드 옵션 로드 실패:", error);
setDynamicOptions([]);
} finally {
setLoadingOptions(false);
}
return;
}
setDynamicOptions([]);
};
loadDynamicOptions();
}, [selectedField?.id, selectedField?.entityTable, selectedField?.entityValueColumn, selectedField?.entityLabelColumn, selectedField?.codeGroup]);
// 최종 옵션 (정적 + 동적)
const fieldOptions = useMemo(() => {
if (selectedField?.options && selectedField.options.length > 0) {
return selectedField.options;
}
return dynamicOptions;
}, [selectedField?.options, dynamicOptions]);
// config prop 변경 시 로컬 상태 동기화
useEffect(() => {
setEnabled(config?.enabled ?? false);
setField(config?.field ?? "");
setOperator(config?.operator ?? "=");
setValue(String(config?.value ?? ""));
setAction(config?.action ?? "show");
}, [config]);
// 설정 변경 시 부모에게 알림
const updateConfig = (updates: Partial<ConditionalConfig>) => {
const newConfig: ConditionalConfig = {
enabled: updates.enabled ?? enabled,
field: updates.field ?? field,
operator: updates.operator ?? operator,
value: updates.value ?? value,
action: updates.action ?? action,
};
// enabled가 false이면 undefined 반환 (설정 제거)
if (!newConfig.enabled) {
onChange(undefined);
} else {
onChange(newConfig);
}
};
// 활성화 토글
const handleEnabledChange = (checked: boolean) => {
setEnabled(checked);
updateConfig({ enabled: checked });
};
// 조건 필드 변경
const handleFieldChange = (newField: string) => {
setField(newField);
setValue(""); // 필드 변경 시 값 초기화
updateConfig({ field: newField, value: "" });
};
// 연산자 변경
const handleOperatorChange = (newOperator: ConditionalConfig["operator"]) => {
setOperator(newOperator);
// 비어있음/값이있음 연산자는 value 필요 없음
if (newOperator === "isEmpty" || newOperator === "isNotEmpty") {
setValue("");
updateConfig({ operator: newOperator, value: "" });
} else {
updateConfig({ operator: newOperator });
}
};
// 값 변경
const handleValueChange = (newValue: string) => {
setValue(newValue);
// 타입에 따라 적절한 값으로 변환
let parsedValue: unknown = newValue;
if (selectedField?.type === "number") {
parsedValue = Number(newValue);
} else if (newValue === "true") {
parsedValue = true;
} else if (newValue === "false") {
parsedValue = false;
}
updateConfig({ value: parsedValue });
};
// 동작 변경
const handleActionChange = (newAction: ConditionalConfig["action"]) => {
setAction(newAction);
updateConfig({ action: newAction });
};
// 값 입력 필드 렌더링 (필드 타입에 따라 다르게)
const renderValueInput = () => {
// 비어있음/값이있음은 값 입력 불필요
if (operator === "isEmpty" || operator === "isNotEmpty") {
return (
<div className="text-xs text-muted-foreground italic">
( )
</div>
);
}
// 옵션 로딩 중
if (loadingOptions) {
return (
<div className="text-xs text-muted-foreground italic">
...
</div>
);
}
// 옵션이 있으면 검색 가능한 Combobox로 표시
if (fieldOptions.length > 0) {
const selectedOption = fieldOptions.find((opt) => opt.value === value);
return (
<Popover open={comboboxOpen} onOpenChange={setComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={comboboxOpen}
className="h-8 w-full justify-between text-xs font-normal"
>
<span className="truncate">
{selectedOption ? selectedOption.label : "값 선택"}
</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
</CommandEmpty>
<CommandGroup>
{fieldOptions.map((opt) => (
<CommandItem
key={opt.value}
value={opt.label}
onSelect={() => {
handleValueChange(opt.value);
setComboboxOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
value === opt.value ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{opt.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 체크박스 타입이면 true/false Select
if (selectedField?.type === "checkbox" || selectedField?.type === "boolean") {
return (
<Select value={value} onValueChange={handleValueChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true" className="text-xs"></SelectItem>
<SelectItem value="false" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
);
}
// 숫자 타입
if (selectedField?.type === "number") {
return (
<Input
type="number"
value={value}
onChange={(e) => handleValueChange(e.target.value)}
placeholder="숫자 입력"
className="h-8 text-xs"
/>
);
}
// 기본: 텍스트 입력
return (
<Input
value={value}
onChange={(e) => handleValueChange(e.target.value)}
placeholder="값 입력"
className="h-8 text-xs"
/>
);
};
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-orange-500" />
<span className="text-sm font-medium"> </span>
<span
className="text-muted-foreground cursor-help"
title="다른 필드의 값에 따라 이 필드를 표시/숨김/활성화/비활성화할 수 있습니다."
>
<HelpCircle className="h-3 w-3" />
</span>
</div>
<Switch
checked={enabled}
onCheckedChange={handleEnabledChange}
aria-label="조건부 표시 활성화"
/>
</div>
{/* 조건 설정 영역 */}
{enabled && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
{/* 조건 필드 선택 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium"> </Label>
<Select value={field} onValueChange={handleFieldChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{selectableFields.length === 0 ? (
<div className="p-2 text-xs text-muted-foreground">
</div>
) : (
selectableFields.map((f) => (
<SelectItem key={f.id} value={f.id} className="text-xs">
{f.label || f.id}
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 연산자 선택 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium"></Label>
<Select value={operator} onValueChange={handleOperatorChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value} className="text-xs">
<div className="flex flex-col">
<span>{op.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 값 입력 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium"></Label>
{renderValueInput()}
</div>
{/* 동작 선택 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium"></Label>
<Select value={action} onValueChange={handleActionChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTIONS.map((act) => (
<SelectItem key={act.value} value={act.value} className="text-xs">
<div className="flex flex-col">
<span>{act.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
{ACTIONS.find(a => a.value === action)?.label}
</p>
</div>
{/* 미리보기 */}
{field && (
<div className="mt-3 rounded bg-slate-100 p-2">
<p className="text-[10px] font-medium text-slate-600"> :</p>
<p className="text-[11px] text-slate-800">
"{selectableFields.find(f => f.id === field)?.label || field}" {" "}
<span className="font-medium">
{operator === "isEmpty" ? "비어있으면" :
operator === "isNotEmpty" ? "값이 있으면" :
`"${value}"${operator === "=" ? "이면" :
operator === "!=" ? "이 아니면" :
operator === ">" ? "보다 크면" :
operator === "<" ? "보다 작으면" :
operator === "in" ? "에 포함되면" : "에 포함되지 않으면"}`}
</span>{" "}
{" "}
<span className="font-medium text-orange-600">
{action === "show" ? "표시" :
action === "hide" ? "숨김" :
action === "enable" ? "활성화" : "비활성화"}
</span>
</p>
</div>
)}
</div>
)}
</div>
);
}
export default ConditionalConfigPanel;

View File

@ -0,0 +1,372 @@
"use client";
/**
* DynamicConfigPanel
*
* JSON Schema UI를
* Unified
*/
import React, { useCallback, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown } from "lucide-react";
import { JSONSchemaProperty, UnifiedConfigSchema } from "@/types/unified-components";
import { cn } from "@/lib/utils";
interface DynamicConfigPanelProps {
schema: UnifiedConfigSchema;
config: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
className?: string;
}
/**
*
*/
function SchemaField({
name,
property,
value,
onChange,
path = [],
}: {
name: string;
property: JSONSchemaProperty;
value: unknown;
onChange: (key: string, value: unknown) => void;
path?: string[];
}) {
const fieldPath = [...path, name].join(".");
// 값 변경 핸들러
const handleChange = useCallback(
(newValue: unknown) => {
onChange(fieldPath, newValue);
},
[fieldPath, onChange]
);
// 타입에 따른 컴포넌트 렌더링
const renderField = () => {
// enum이 있으면 Select 렌더링
if (property.enum && property.enum.length > 0) {
return (
<Select
value={String(value ?? property.default ?? "")}
onValueChange={handleChange}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{property.enum.map((option) => (
<SelectItem key={option} value={option} className="text-xs">
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// 타입별 렌더링
switch (property.type) {
case "string":
return (
<Input
type="text"
value={String(value ?? property.default ?? "")}
onChange={(e) => handleChange(e.target.value)}
placeholder={property.description}
className="h-8 text-xs"
/>
);
case "number":
return (
<Input
type="number"
value={value !== undefined && value !== null ? Number(value) : ""}
onChange={(e) => handleChange(e.target.value ? Number(e.target.value) : undefined)}
placeholder={property.description}
className="h-8 text-xs"
/>
);
case "boolean":
return (
<Switch
checked={Boolean(value ?? property.default ?? false)}
onCheckedChange={handleChange}
/>
);
case "array":
// 배열은 간단한 텍스트 입력으로 처리 (쉼표 구분)
return (
<Textarea
value={Array.isArray(value) ? value.join(", ") : ""}
onChange={(e) => {
const arr = e.target.value.split(",").map((s) => s.trim()).filter(Boolean);
handleChange(arr);
}}
placeholder="쉼표로 구분하여 입력"
className="text-xs min-h-[60px]"
/>
);
case "object":
// 중첩 객체는 별도 섹션으로 렌더링
if (property.properties) {
return (
<div className="mt-2 pl-4 border-l-2 border-muted space-y-3">
{Object.entries(property.properties).map(([subName, subProp]) => (
<SchemaField
key={subName}
name={subName}
property={subProp}
value={(value as Record<string, unknown>)?.[subName]}
onChange={onChange}
path={[...path, name]}
/>
))}
</div>
);
}
return null;
default:
return (
<Input
type="text"
value={String(value ?? "")}
onChange={(e) => handleChange(e.target.value)}
className="h-8 text-xs"
/>
);
}
};
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">
{property.title || name}
</Label>
{property.type === "boolean" && renderField()}
</div>
{property.description && (
<p className="text-[10px] text-muted-foreground">{property.description}</p>
)}
{property.type !== "boolean" && renderField()}
</div>
);
}
/**
* DynamicConfigPanel
*/
export function DynamicConfigPanel({
schema,
config,
onChange,
className,
}: DynamicConfigPanelProps) {
// 속성들을 카테고리별로 그룹화
const groupedProperties = useMemo(() => {
const groups: Record<string, Array<[string, JSONSchemaProperty]>> = {
: [],
: [],
: [],
};
Object.entries(schema.properties).forEach(([name, property]) => {
// 이름 기반으로 그룹 분류
if (name.includes("style") || name.includes("Style")) {
groups["스타일"].push([name, property]);
} else if (
name.includes("cascade") ||
name.includes("mutual") ||
name.includes("conditional") ||
name.includes("autoFill")
) {
groups["고급"].push([name, property]);
} else {
groups["기본"].push([name, property]);
}
});
return groups;
}, [schema.properties]);
// 값 변경 핸들러 (중첩 경로 지원)
const handleChange = useCallback(
(path: string, value: unknown) => {
onChange(path, value);
},
[onChange]
);
return (
<div className={cn("space-y-4", className)}>
{Object.entries(groupedProperties).map(
([groupName, properties]) =>
properties.length > 0 && (
<Collapsible key={groupName} defaultOpen={groupName === "기본"}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center justify-between">
{groupName}
<ChevronDown className="h-4 w-4" />
</CardTitle>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0 space-y-4">
{properties.map(([name, property]) => (
<SchemaField
key={name}
name={name}
property={property}
value={config[name]}
onChange={handleChange}
/>
))}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
)}
</div>
);
}
/**
* ( )
*/
export const COMMON_SCHEMAS = {
// UnifiedInput 기본 스키마
UnifiedInput: {
type: "object" as const,
properties: {
type: {
type: "string" as const,
enum: ["text", "number", "password", "slider", "color", "button"],
default: "text",
title: "입력 타입",
},
format: {
type: "string" as const,
enum: ["none", "email", "tel", "url", "currency", "biz_no"],
default: "none",
title: "형식",
},
placeholder: {
type: "string" as const,
title: "플레이스홀더",
},
min: {
type: "number" as const,
title: "최소값",
description: "숫자 타입 전용",
},
max: {
type: "number" as const,
title: "최대값",
description: "숫자 타입 전용",
},
step: {
type: "number" as const,
title: "증가 단위",
},
},
},
// UnifiedSelect 기본 스키마
UnifiedSelect: {
type: "object" as const,
properties: {
mode: {
type: "string" as const,
enum: ["dropdown", "radio", "check", "tag", "toggle", "swap"],
default: "dropdown",
title: "표시 모드",
},
source: {
type: "string" as const,
enum: ["static", "code", "db", "api", "entity"],
default: "static",
title: "데이터 소스",
},
codeGroup: {
type: "string" as const,
title: "코드 그룹",
description: "source가 code일 때 사용",
},
searchable: {
type: "boolean" as const,
default: false,
title: "검색 가능",
},
multiple: {
type: "boolean" as const,
default: false,
title: "다중 선택",
},
maxSelect: {
type: "number" as const,
title: "최대 선택 수",
},
cascading: {
type: "object" as const,
title: "연쇄 관계",
properties: {
parentField: { type: "string" as const, title: "부모 필드" },
filterColumn: { type: "string" as const, title: "필터 컬럼" },
clearOnChange: { type: "boolean" as const, default: true, title: "부모 변경시 초기화" },
},
},
},
},
// UnifiedDate 기본 스키마
UnifiedDate: {
type: "object" as const,
properties: {
type: {
type: "string" as const,
enum: ["date", "time", "datetime"],
default: "date",
title: "타입",
},
format: {
type: "string" as const,
default: "YYYY-MM-DD",
title: "날짜 형식",
},
range: {
type: "boolean" as const,
default: false,
title: "범위 선택",
},
showToday: {
type: "boolean" as const,
default: true,
title: "오늘 버튼",
},
},
},
} satisfies Record<string, UnifiedConfigSchema>;
export default DynamicConfigPanel;

View File

@ -0,0 +1,349 @@
"use client";
/**
* UnifiedBiz
*
*
* - flow: 플로우/
* - rack:
* - map: /
* - numbering: 채번
* - category: 카테고리
* - mapping: 데이터
* - related-buttons: 관련
*/
import React, { forwardRef } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { UnifiedBizProps } from "@/types/unified-components";
import {
GitBranch,
LayoutGrid,
MapPin,
Hash,
FolderTree,
Link2,
FileText,
ArrowRight
} from "lucide-react";
/**
* ()
* FlowWidget과
*/
const FlowBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
return (
<Card ref={ref} className={className}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<GitBranch className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 border-2 border-dashed rounded-lg flex items-center justify-center text-muted-foreground">
<div className="text-center">
<GitBranch className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm"> </p>
<p className="text-xs"> FlowWidget과 </p>
</div>
</div>
</CardContent>
</Card>
);
});
FlowBiz.displayName = "FlowBiz";
/**
* ()
* RackStructure와
*/
const RackBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
return (
<Card ref={ref} className={className}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 border-2 border-dashed rounded-lg flex items-center justify-center text-muted-foreground">
<div className="text-center">
<LayoutGrid className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm"> </p>
<p className="text-xs"> RackStructure와 </p>
</div>
</div>
</CardContent>
</Card>
);
});
RackBiz.displayName = "RackBiz";
/**
* ()
*/
const MapBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
return (
<Card ref={ref} className={className}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<MapPin className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 border-2 border-dashed rounded-lg flex items-center justify-center text-muted-foreground">
<div className="text-center">
<MapPin className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm"> </p>
<p className="text-xs"> </p>
</div>
</div>
</CardContent>
</Card>
);
});
MapBiz.displayName = "MapBiz";
/**
* ()
* NumberingRuleComponent와
*/
const NumberingBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
return (
<Card ref={ref} className={className}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Hash className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div>
<p className="font-medium text-sm"> </p>
<p className="text-xs text-muted-foreground"> </p>
</div>
<div className="font-mono text-sm bg-background px-2 py-1 rounded border">
PO-2024-0001
</div>
</div>
<p className="text-xs text-muted-foreground text-center">
NumberingRuleComponent와
</p>
</div>
</CardContent>
</Card>
);
});
NumberingBiz.displayName = "NumberingBiz";
/**
* ()
* CategoryManager와
*/
const CategoryBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
return (
<Card ref={ref} className={className}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<FolderTree className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="pl-0 py-1 px-2 bg-muted/50 rounded">
<span className="text-sm"></span>
</div>
<div className="pl-4 py-1 px-2 text-sm text-muted-foreground">
</div>
<div className="pl-8 py-1 px-2 text-sm text-muted-foreground">
</div>
<p className="text-xs text-muted-foreground text-center mt-3">
CategoryManager와
</p>
</div>
</CardContent>
</Card>
);
});
CategoryBiz.displayName = "CategoryBiz";
/**
* ()
*/
const MappingBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
return (
<Card ref={ref} className={className}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Link2 className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 justify-center p-4">
<div className="text-center">
<div className="w-20 h-20 border-2 rounded-lg flex items-center justify-center mb-2">
<FileText className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
<ArrowRight className="h-6 w-6 text-muted-foreground" />
<div className="text-center">
<div className="w-20 h-20 border-2 rounded-lg flex items-center justify-center mb-2">
<FileText className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
</CardContent>
</Card>
);
});
MappingBiz.displayName = "MappingBiz";
/**
* ()
*/
const RelatedButtonsBiz = forwardRef<HTMLDivElement, {
config?: Record<string, unknown>;
className?: string;
}>(({ config, className }, ref) => {
const buttons = (config?.buttons as Array<{ label: string; icon?: string }>) || [
{ label: "관련 주문" },
{ label: "관련 출고" },
{ label: "이력 보기" },
];
return (
<div ref={ref} className={cn("flex flex-wrap gap-2", className)}>
{buttons.map((button, index) => (
<Button key={index} variant="outline" size="sm">
{button.label}
</Button>
))}
</div>
);
});
RelatedButtonsBiz.displayName = "RelatedButtonsBiz";
/**
* UnifiedBiz
*/
export const UnifiedBiz = forwardRef<HTMLDivElement, UnifiedBizProps>(
(props, ref) => {
const {
id,
label,
style,
size,
config: configProp,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "flow" as const };
// 타입별 비즈니스 컴포넌트 렌더링
const renderBiz = () => {
const bizConfig = config.config || {};
const bizType = config.type || "flow";
switch (bizType) {
case "flow":
return <FlowBiz config={bizConfig} />;
case "rack":
return <RackBiz config={bizConfig} />;
case "map":
return <MapBiz config={bizConfig} />;
case "numbering":
return <NumberingBiz config={bizConfig} />;
case "category":
return <CategoryBiz config={bizConfig} />;
case "mapping":
return <MappingBiz config={bizConfig} />;
case "related-buttons":
return <RelatedButtonsBiz config={bizConfig} />;
default:
return (
<div className="p-4 border rounded text-center text-muted-foreground">
: {config.type}
</div>
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="text-sm font-medium flex-shrink-0"
>
{label}
</Label>
)}
<div className="flex-1 min-h-0">
{renderBiz()}
</div>
</div>
);
}
);
UnifiedBiz.displayName = "UnifiedBiz";
export default UnifiedBiz;

View File

@ -0,0 +1,111 @@
"use client";
/**
* UnifiedComponentRenderer
*
* Unified
* props.unifiedType에
*/
import React, { forwardRef, useMemo } from "react";
import {
UnifiedComponentProps,
isUnifiedInput,
isUnifiedSelect,
isUnifiedDate,
isUnifiedText,
isUnifiedMedia,
isUnifiedList,
isUnifiedLayout,
isUnifiedGroup,
isUnifiedBiz,
isUnifiedHierarchy,
} from "@/types/unified-components";
import { UnifiedInput } from "./UnifiedInput";
import { UnifiedSelect } from "./UnifiedSelect";
import { UnifiedDate } from "./UnifiedDate";
import { UnifiedList } from "./UnifiedList";
import { UnifiedLayout } from "./UnifiedLayout";
import { UnifiedGroup } from "./UnifiedGroup";
import { UnifiedMedia } from "./UnifiedMedia";
import { UnifiedBiz } from "./UnifiedBiz";
import { UnifiedHierarchy } from "./UnifiedHierarchy";
interface UnifiedComponentRendererProps {
props: UnifiedComponentProps;
className?: string;
}
/**
* Unified
*/
export const UnifiedComponentRenderer = forwardRef<HTMLDivElement, UnifiedComponentRendererProps>(
({ props, className }, ref) => {
const component = useMemo(() => {
// 타입 가드를 사용하여 적절한 컴포넌트 렌더링
if (isUnifiedInput(props)) {
return <UnifiedInput {...props} />;
}
if (isUnifiedSelect(props)) {
return <UnifiedSelect {...props} />;
}
if (isUnifiedDate(props)) {
return <UnifiedDate {...props} />;
}
if (isUnifiedText(props)) {
// UnifiedText는 UnifiedInput의 textarea 모드로 대체
// 필요시 별도 구현
return (
<div className="p-2 border rounded text-sm text-muted-foreground">
UnifiedText (UnifiedInput textarea )
</div>
);
}
if (isUnifiedMedia(props)) {
return <UnifiedMedia {...props} />;
}
if (isUnifiedList(props)) {
return <UnifiedList {...props} />;
}
if (isUnifiedLayout(props)) {
return <UnifiedLayout {...props} />;
}
if (isUnifiedGroup(props)) {
return <UnifiedGroup {...props} />;
}
if (isUnifiedBiz(props)) {
return <UnifiedBiz {...props} />;
}
if (isUnifiedHierarchy(props)) {
return <UnifiedHierarchy {...props} />;
}
// 알 수 없는 타입
return (
<div className="p-2 border border-destructive rounded text-sm text-destructive">
: {(props as { unifiedType?: string }).unifiedType}
</div>
);
}, [props]);
return (
<div ref={ref} className={className}>
{component}
</div>
);
}
);
UnifiedComponentRenderer.displayName = "UnifiedComponentRenderer";
export default UnifiedComponentRenderer;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,693 @@
"use client";
/**
* UnifiedFormContext
*
* Unified
* , // Context
*
* .
*/
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from "react";
import { ConditionalConfig, CascadingConfig } from "@/types/unified-components";
import { ValidationRule } from "@/types/unified-core";
import type {
FormStatus,
FieldError,
FieldState,
SubmitConfig,
SubmitResult,
ValidationResult,
FormEventDetail,
} from "@/types/unified-form";
// ===== 레거시 타입 호환 (기존 코드와 호환) =====
export interface FormFieldState {
value: unknown;
disabled?: boolean;
visible?: boolean;
error?: string;
}
export interface FormState {
[fieldId: string]: FormFieldState;
}
// ===== 확장된 Context 타입 =====
export interface UnifiedFormContextValue {
// === 기존 기능 (하위 호환) ===
formData: Record<string, unknown>;
fieldStates: FormState;
getValue: (fieldId: string) => unknown;
setValue: (fieldId: string, value: unknown) => void;
setValues: (values: Record<string, unknown>) => void;
evaluateCondition: (fieldId: string, config?: ConditionalConfig) => {
visible: boolean;
disabled: boolean;
};
getCascadingFilter: (config?: CascadingConfig) => unknown;
registerField: (fieldId: string, initialValue?: unknown) => void;
unregisterField: (fieldId: string) => void;
// === 새로운 기능 ===
// 원본 데이터 (수정 모드)
originalData: Record<string, unknown>;
// 폼 상태
status: FormStatus;
errors: FieldError[];
// 폼 액션
submit: (config?: Partial<SubmitConfig>) => Promise<SubmitResult>;
reset: () => void;
validate: (fieldIds?: string[]) => Promise<ValidationResult>;
clear: () => void;
// 초기 데이터 설정 (수정 모드 진입)
setInitialData: (data: Record<string, unknown>) => void;
// 에러 관리
setFieldError: (fieldId: string, error: string, type?: FieldError["type"]) => void;
clearFieldError: (fieldId: string) => void;
clearAllErrors: () => void;
// dirty 체크
getChangedFields: () => string[];
hasChanges: () => boolean;
// 리피터 데이터 관리
getRepeaterData: (fieldName: string) => unknown[];
setRepeaterData: (fieldName: string, data: unknown[]) => void;
addRepeaterRow: (fieldName: string, row: Record<string, unknown>) => void;
updateRepeaterRow: (fieldName: string, index: number, row: Record<string, unknown>) => void;
deleteRepeaterRow: (fieldName: string, index: number) => void;
}
// ===== Context 생성 =====
const UnifiedFormContext = createContext<UnifiedFormContextValue | null>(null);
// ===== 조건 평가 함수 =====
function evaluateOperator(
fieldValue: unknown,
operator: ConditionalConfig["operator"],
conditionValue: unknown
): boolean {
switch (operator) {
case "=":
return fieldValue === conditionValue;
case "!=":
return fieldValue !== conditionValue;
case ">":
return Number(fieldValue) > Number(conditionValue);
case "<":
return Number(fieldValue) < Number(conditionValue);
case "in":
if (Array.isArray(conditionValue)) {
return conditionValue.includes(fieldValue);
}
return false;
case "notIn":
if (Array.isArray(conditionValue)) {
return !conditionValue.includes(fieldValue);
}
return true;
case "isEmpty":
return fieldValue === null || fieldValue === undefined || fieldValue === "" ||
(Array.isArray(fieldValue) && fieldValue.length === 0);
case "isNotEmpty":
return fieldValue !== null && fieldValue !== undefined && fieldValue !== "" &&
!(Array.isArray(fieldValue) && fieldValue.length === 0);
default:
return true;
}
}
// ===== 초기 상태 =====
const initialFormStatus: FormStatus = {
isSubmitting: false,
isValidating: false,
isDirty: false,
isValid: true,
isLoading: false,
submitCount: 0,
};
// ===== Provider Props =====
interface UnifiedFormProviderProps {
children: React.ReactNode;
initialValues?: Record<string, unknown>;
onChange?: (formData: Record<string, unknown>) => void;
// 새로운 Props
submitConfig?: SubmitConfig;
onSubmit?: (data: Record<string, unknown>, config: SubmitConfig) => Promise<SubmitResult>;
onError?: (errors: FieldError[]) => void;
onReset?: () => void;
// 레거시 호환성
emitLegacyEvents?: boolean; // beforeFormSave 등 레거시 이벤트 발생 여부 (기본: true)
}
// ===== Provider 컴포넌트 =====
export function UnifiedFormProvider({
children,
initialValues = {},
onChange,
submitConfig: defaultSubmitConfig,
onSubmit,
onError,
onReset,
emitLegacyEvents = true,
}: UnifiedFormProviderProps) {
// 기존 상태
const [formData, setFormData] = useState<Record<string, unknown>>(initialValues);
const [fieldStates, setFieldStates] = useState<FormState>({});
// 새로운 상태
const [originalData, setOriginalData] = useState<Record<string, unknown>>(initialValues);
const [status, setStatus] = useState<FormStatus>(initialFormStatus);
const [errors, setErrors] = useState<FieldError[]>([]);
// 필드별 검증 규칙 저장
const validationRulesRef = useRef<Map<string, ValidationRule[]>>(new Map());
// ===== 기존 기능 =====
const getValue = useCallback((fieldId: string): unknown => {
return formData[fieldId];
}, [formData]);
const setValue = useCallback((fieldId: string, value: unknown) => {
setFormData(prev => {
const newData = { ...prev, [fieldId]: value };
// dirty 상태 업데이트
setStatus(s => ({ ...s, isDirty: true }));
onChange?.(newData);
return newData;
});
}, [onChange]);
const setValues = useCallback((values: Record<string, unknown>) => {
setFormData(prev => {
const newData = { ...prev, ...values };
setStatus(s => ({ ...s, isDirty: true }));
onChange?.(newData);
return newData;
});
}, [onChange]);
const evaluateCondition = useCallback((
fieldId: string,
config?: ConditionalConfig
): { visible: boolean; disabled: boolean } => {
if (!config || !config.enabled) {
return { visible: true, disabled: false };
}
const { field, operator, value, action } = config;
const fieldValue = formData[field];
const conditionMet = evaluateOperator(fieldValue, operator, value);
switch (action) {
case "show":
return { visible: conditionMet, disabled: false };
case "hide":
return { visible: !conditionMet, disabled: false };
case "enable":
return { visible: true, disabled: !conditionMet };
case "disable":
return { visible: true, disabled: conditionMet };
default:
return { visible: true, disabled: false };
}
}, [formData]);
const getCascadingFilter = useCallback((config?: CascadingConfig): unknown => {
if (!config) return undefined;
return formData[config.parentField];
}, [formData]);
const registerField = useCallback((fieldId: string, initialValue?: unknown) => {
if (initialValue !== undefined && formData[fieldId] === undefined) {
setFormData(prev => ({ ...prev, [fieldId]: initialValue }));
}
setFieldStates(prev => ({
...prev,
[fieldId]: { value: initialValue, visible: true, disabled: false },
}));
}, [formData]);
const unregisterField = useCallback((fieldId: string) => {
setFieldStates(prev => {
const next = { ...prev };
delete next[fieldId];
return next;
});
validationRulesRef.current.delete(fieldId);
}, []);
// ===== 새로운 기능: 폼 액션 =====
// 검증
const validate = useCallback(async (fieldIds?: string[]): Promise<ValidationResult> => {
setStatus(s => ({ ...s, isValidating: true }));
const newErrors: FieldError[] = [];
const fieldsToValidate = fieldIds || Array.from(validationRulesRef.current.keys());
for (const fieldId of fieldsToValidate) {
const rules = validationRulesRef.current.get(fieldId);
if (!rules) continue;
const value = formData[fieldId];
for (const rule of rules) {
let isValid = true;
switch (rule.type) {
case "required":
isValid = value !== null && value !== undefined && value !== "";
break;
case "minLength":
isValid = typeof value === "string" && value.length >= (rule.value as number);
break;
case "maxLength":
isValid = typeof value === "string" && value.length <= (rule.value as number);
break;
case "min":
isValid = typeof value === "number" && value >= (rule.value as number);
break;
case "max":
isValid = typeof value === "number" && value <= (rule.value as number);
break;
case "pattern":
isValid = typeof value === "string" && new RegExp(rule.value as string).test(value);
break;
case "email":
isValid = typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
break;
case "url":
isValid = typeof value === "string" && /^https?:\/\/.+/.test(value);
break;
}
if (!isValid) {
newErrors.push({
fieldId,
message: rule.message,
type: rule.type === "required" ? "required" : "format",
});
break; // 첫 번째 에러만 기록
}
}
}
setErrors(newErrors);
setStatus(s => ({
...s,
isValidating: false,
isValid: newErrors.length === 0
}));
return { valid: newErrors.length === 0, errors: newErrors };
}, [formData]);
// 저장
const submit = useCallback(async (config?: Partial<SubmitConfig>): Promise<SubmitResult> => {
const finalConfig = { ...defaultSubmitConfig, ...config } as SubmitConfig;
setStatus(s => ({ ...s, isSubmitting: true, submitCount: s.submitCount + 1 }));
try {
// 1. 검증
if (finalConfig.validateBeforeSubmit !== false) {
const validation = await validate();
if (!validation.valid) {
onError?.(validation.errors);
return { success: false, error: "검증 실패", errors: validation.errors };
}
}
// 2. 레거시 이벤트 발생 (리피터 데이터 수집)
let collectedData = { ...formData };
if (emitLegacyEvents && typeof window !== "undefined") {
const eventDetail: FormEventDetail = { formData: {} };
const legacyEvent = new CustomEvent("beforeFormSave", { detail: eventDetail });
window.dispatchEvent(legacyEvent);
// 이벤트에서 수집된 데이터 병합 (리피터 등)
collectedData = { ...collectedData, ...eventDetail.formData };
}
// 3. beforeSubmit 콜백
if (finalConfig.onBeforeSubmit) {
collectedData = await finalConfig.onBeforeSubmit(collectedData);
}
// 4. 추가 데이터 병합
if (finalConfig.additionalData) {
collectedData = { ...collectedData, ...finalConfig.additionalData };
}
// 5. 저장 실행
let result: SubmitResult;
if (onSubmit) {
result = await onSubmit(collectedData, finalConfig);
} else {
// 기본 저장 로직 (API 호출)
// 실제 구현은 외부에서 onSubmit으로 제공
result = { success: true, data: collectedData };
}
// 6. 성공 시 처리
if (result.success) {
setOriginalData({ ...formData });
setStatus(s => ({ ...s, isDirty: false }));
// afterFormSave 이벤트 발생
if (emitLegacyEvents && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("afterFormSave", {
detail: { success: true, data: result.data }
}));
}
// afterSubmit 콜백
finalConfig.onAfterSubmit?.(result);
}
return result;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "저장 중 오류 발생";
return { success: false, error: errorMessage };
} finally {
setStatus(s => ({ ...s, isSubmitting: false }));
}
}, [formData, defaultSubmitConfig, validate, onSubmit, onError, emitLegacyEvents]);
// 초기화 (원본 데이터로 복원)
const reset = useCallback(() => {
setFormData({ ...originalData });
setErrors([]);
setStatus(s => ({ ...s, isDirty: false, isValid: true }));
onReset?.();
}, [originalData, onReset]);
// 비우기
const clear = useCallback(() => {
setFormData({});
setErrors([]);
setStatus(s => ({ ...s, isDirty: true, isValid: true }));
}, []);
// 초기 데이터 설정 (수정 모드 진입)
const setInitialData = useCallback((data: Record<string, unknown>) => {
setFormData(data);
setOriginalData(data);
setStatus(s => ({ ...s, isDirty: false }));
}, []);
// ===== 에러 관리 =====
const setFieldError = useCallback((fieldId: string, message: string, type: FieldError["type"] = "custom") => {
setErrors(prev => {
const filtered = prev.filter(e => e.fieldId !== fieldId);
return [...filtered, { fieldId, message, type }];
});
setStatus(s => ({ ...s, isValid: false }));
}, []);
const clearFieldError = useCallback((fieldId: string) => {
setErrors(prev => {
const filtered = prev.filter(e => e.fieldId !== fieldId);
return filtered;
});
}, []);
const clearAllErrors = useCallback(() => {
setErrors([]);
setStatus(s => ({ ...s, isValid: true }));
}, []);
// ===== dirty 체크 =====
const getChangedFields = useCallback((): string[] => {
const changed: string[] = [];
const allKeys = new Set([...Object.keys(formData), ...Object.keys(originalData)]);
for (const key of allKeys) {
if (JSON.stringify(formData[key]) !== JSON.stringify(originalData[key])) {
changed.push(key);
}
}
return changed;
}, [formData, originalData]);
const hasChanges = useCallback((): boolean => {
return JSON.stringify(formData) !== JSON.stringify(originalData);
}, [formData, originalData]);
// ===== 리피터 데이터 관리 =====
const getRepeaterData = useCallback((fieldName: string): unknown[] => {
const data = formData[fieldName];
if (Array.isArray(data)) return data;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch {
return [];
}
}
return [];
}, [formData]);
const setRepeaterData = useCallback((fieldName: string, data: unknown[]) => {
setValue(fieldName, data);
}, [setValue]);
const addRepeaterRow = useCallback((fieldName: string, row: Record<string, unknown>) => {
const current = getRepeaterData(fieldName);
setValue(fieldName, [...current, row]);
}, [getRepeaterData, setValue]);
const updateRepeaterRow = useCallback((fieldName: string, index: number, row: Record<string, unknown>) => {
const current = getRepeaterData(fieldName) as Record<string, unknown>[];
const updated = [...current];
updated[index] = { ...updated[index], ...row };
setValue(fieldName, updated);
}, [getRepeaterData, setValue]);
const deleteRepeaterRow = useCallback((fieldName: string, index: number) => {
const current = getRepeaterData(fieldName);
const updated = current.filter((_, i) => i !== index);
setValue(fieldName, updated);
}, [getRepeaterData, setValue]);
// ===== Context 값 =====
const contextValue = useMemo<UnifiedFormContextValue>(() => ({
// 기존 기능
formData,
fieldStates,
getValue,
setValue,
setValues,
evaluateCondition,
getCascadingFilter,
registerField,
unregisterField,
// 새로운 기능
originalData,
status,
errors,
submit,
reset,
validate,
clear,
setInitialData,
setFieldError,
clearFieldError,
clearAllErrors,
getChangedFields,
hasChanges,
getRepeaterData,
setRepeaterData,
addRepeaterRow,
updateRepeaterRow,
deleteRepeaterRow,
}), [
formData,
fieldStates,
getValue,
setValue,
setValues,
evaluateCondition,
getCascadingFilter,
registerField,
unregisterField,
originalData,
status,
errors,
submit,
reset,
validate,
clear,
setInitialData,
setFieldError,
clearFieldError,
clearAllErrors,
getChangedFields,
hasChanges,
getRepeaterData,
setRepeaterData,
addRepeaterRow,
updateRepeaterRow,
deleteRepeaterRow,
]);
return (
<UnifiedFormContext.Provider value={contextValue}>
{children}
</UnifiedFormContext.Provider>
);
}
// ===== 커스텀 훅 =====
/**
* UnifiedForm (Context가 )
*/
export function useUnifiedForm(): UnifiedFormContextValue {
const context = useContext(UnifiedFormContext);
if (!context) {
throw new Error("useUnifiedForm must be used within UnifiedFormProvider");
}
return context;
}
/**
* UnifiedForm (Context가 , null )
*
*/
export function useUnifiedFormOptional(): UnifiedFormContextValue | null {
return useContext(UnifiedFormContext);
}
/**
* -
*/
export function useUnifiedField(
fieldId: string,
conditional?: ConditionalConfig
): {
value: unknown;
setValue: (value: unknown) => void;
visible: boolean;
disabled: boolean;
error?: FieldError;
} {
const { getValue, setValue, evaluateCondition, errors } = useUnifiedForm();
const value = getValue(fieldId);
const { visible, disabled } = evaluateCondition(fieldId, conditional);
const error = errors.find(e => e.fieldId === fieldId);
const handleSetValue = useCallback((newValue: unknown) => {
setValue(fieldId, newValue);
}, [fieldId, setValue]);
return {
value,
setValue: handleSetValue,
visible,
disabled,
error,
};
}
/**
* -
*/
export function useCascadingOptions<T extends { parentValue?: unknown }>(
options: T[],
cascading?: CascadingConfig
): T[] {
const { getCascadingFilter } = useUnifiedForm();
if (!cascading) return options;
const parentValue = getCascadingFilter(cascading);
if (parentValue === undefined || parentValue === null || parentValue === "") {
return [];
}
return options.filter(opt => opt.parentValue === parentValue);
}
/**
* - //
*/
export function useFormActions() {
const { submit, reset, validate, clear, hasChanges, status, errors } = useUnifiedForm();
return {
submit,
reset,
validate,
clear,
hasChanges,
isSubmitting: status.isSubmitting,
isValidating: status.isValidating,
isDirty: status.isDirty,
isValid: status.isValid,
errors,
};
}
/**
* -
*/
export function useRepeaterField<T extends Record<string, unknown> = Record<string, unknown>>(
fieldName: string
) {
const {
getRepeaterData,
setRepeaterData,
addRepeaterRow,
updateRepeaterRow,
deleteRepeaterRow
} = useUnifiedForm();
const data = getRepeaterData(fieldName) as T[];
return {
data,
setData: (newData: T[]) => setRepeaterData(fieldName, newData),
addRow: (row: T) => addRepeaterRow(fieldName, row),
updateRow: (index: number, row: Partial<T>) => updateRepeaterRow(fieldName, index, row as Record<string, unknown>),
deleteRow: (index: number) => deleteRepeaterRow(fieldName, index),
count: data.length,
};
}
export default UnifiedFormContext;

View File

@ -0,0 +1,456 @@
"use client";
/**
* UnifiedGroup
*
*
* - tabs:
* - accordion: 아코디언
* - section: 섹션
* - card-section: 카드
* - modal: 모달
* - form-modal:
*/
import React, { forwardRef, useState, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { UnifiedGroupProps, TabItem } from "@/types/unified-components";
import { ChevronDown, ChevronRight, X } from "lucide-react";
/**
*
*/
const TabsGroup = forwardRef<HTMLDivElement, {
tabs?: TabItem[];
activeTab?: string;
onTabChange?: (tabId: string) => void;
children?: React.ReactNode;
className?: string;
}>(({ tabs = [], activeTab, onTabChange, children, className }, ref) => {
const [internalActiveTab, setInternalActiveTab] = useState(activeTab || tabs[0]?.id || "");
const currentTab = activeTab || internalActiveTab;
const handleTabChange = useCallback((tabId: string) => {
setInternalActiveTab(tabId);
onTabChange?.(tabId);
}, [onTabChange]);
// 탭 정보가 있으면 탭 사용, 없으면 children 그대로 렌더링
if (tabs.length === 0) {
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
return (
<Tabs
ref={ref}
value={currentTab}
onValueChange={handleTabChange}
className={className}
>
<TabsList className="grid w-full" style={{ gridTemplateColumns: `repeat(${tabs.length}, 1fr)` }}>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.title}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="mt-4">
{tab.content || children}
</TabsContent>
))}
</Tabs>
);
});
TabsGroup.displayName = "TabsGroup";
/**
*
*/
const AccordionGroup = forwardRef<HTMLDivElement, {
title?: string;
collapsible?: boolean;
defaultExpanded?: boolean;
children?: React.ReactNode;
className?: string;
}>(({ title, collapsible = true, defaultExpanded = true, children, className }, ref) => {
const [isOpen, setIsOpen] = useState(defaultExpanded);
if (!collapsible) {
return (
<div ref={ref} className={cn("border rounded-lg", className)}>
{title && (
<div className="p-4 border-b bg-muted/50">
<h3 className="font-medium">{title}</h3>
</div>
)}
<div className="p-4">{children}</div>
</div>
);
}
return (
<Collapsible ref={ref} open={isOpen} onOpenChange={setIsOpen} className={cn("border rounded-lg", className)}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 cursor-pointer hover:bg-muted/50">
<h3 className="font-medium">{title || "그룹"}</h3>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-4 pt-0 border-t">{children}</div>
</CollapsibleContent>
</Collapsible>
);
});
AccordionGroup.displayName = "AccordionGroup";
/**
*
*/
const SectionGroup = forwardRef<HTMLDivElement, {
title?: string;
description?: string;
collapsible?: boolean;
defaultExpanded?: boolean;
children?: React.ReactNode;
className?: string;
}>(({ title, description, collapsible = false, defaultExpanded = true, children, className }, ref) => {
const [isOpen, setIsOpen] = useState(defaultExpanded);
if (collapsible) {
return (
<Collapsible ref={ref} open={isOpen} onOpenChange={setIsOpen} className={cn("space-y-2", className)}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer">
<div>
{title && <h3 className="text-lg font-semibold">{title}</h3>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pt-2">{children}</div>
</CollapsibleContent>
</Collapsible>
);
}
return (
<div ref={ref} className={cn("space-y-2", className)}>
{(title || description) && (
<div>
{title && <h3 className="text-lg font-semibold">{title}</h3>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
});
SectionGroup.displayName = "SectionGroup";
/**
*
*/
const CardSectionGroup = forwardRef<HTMLDivElement, {
title?: string;
description?: string;
collapsible?: boolean;
defaultExpanded?: boolean;
children?: React.ReactNode;
className?: string;
}>(({ title, description, collapsible = false, defaultExpanded = true, children, className }, ref) => {
const [isOpen, setIsOpen] = useState(defaultExpanded);
if (collapsible) {
return (
<Card ref={ref} className={className}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</div>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0">{children}</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
return (
<Card ref={ref} className={className}>
{(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
)}
<CardContent className={title || description ? "pt-0" : ""}>{children}</CardContent>
</Card>
);
});
CardSectionGroup.displayName = "CardSectionGroup";
/**
*
*/
const ModalGroup = forwardRef<HTMLDivElement, {
title?: string;
description?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
modalSize?: "sm" | "md" | "lg" | "xl";
children?: React.ReactNode;
className?: string;
}>(({ title, description, open = false, onOpenChange, modalSize = "md", children, className }, ref) => {
const sizeClasses = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
{(title || description) && (
<DialogHeader>
{title && <DialogTitle>{title}</DialogTitle>}
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
)}
{children}
</DialogContent>
</Dialog>
);
});
ModalGroup.displayName = "ModalGroup";
/**
*
*/
const FormModalGroup = forwardRef<HTMLDivElement, {
title?: string;
description?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
modalSize?: "sm" | "md" | "lg" | "xl";
onSubmit?: () => void;
onCancel?: () => void;
submitLabel?: string;
cancelLabel?: string;
children?: React.ReactNode;
className?: string;
}>(({
title,
description,
open = false,
onOpenChange,
modalSize = "md",
onSubmit,
onCancel,
submitLabel = "저장",
cancelLabel = "취소",
children,
className
}, ref) => {
const sizeClasses = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
};
const handleCancel = useCallback(() => {
onCancel?.();
onOpenChange?.(false);
}, [onCancel, onOpenChange]);
const handleSubmit = useCallback(() => {
onSubmit?.();
}, [onSubmit]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
{(title || description) && (
<DialogHeader>
{title && <DialogTitle>{title}</DialogTitle>}
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
)}
<div className="py-4">{children}</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={handleCancel}>
{cancelLabel}
</Button>
<Button onClick={handleSubmit}>
{submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});
FormModalGroup.displayName = "FormModalGroup";
/**
* UnifiedGroup
*/
export const UnifiedGroup = forwardRef<HTMLDivElement, UnifiedGroupProps>(
(props, ref) => {
const {
id,
style,
size,
config: configProp,
children,
open,
onOpenChange,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "section" as const, tabs: [] };
// 타입별 그룹 렌더링
const renderGroup = () => {
const groupType = config.type || "section";
switch (groupType) {
case "tabs":
return (
<TabsGroup
tabs={config.tabs}
activeTab={config.activeTab}
>
{children}
</TabsGroup>
);
case "accordion":
return (
<AccordionGroup
title={config.title}
collapsible={config.collapsible}
defaultExpanded={config.defaultExpanded}
>
{children}
</AccordionGroup>
);
case "section":
return (
<SectionGroup
title={config.title}
collapsible={config.collapsible}
defaultExpanded={config.defaultExpanded}
>
{children}
</SectionGroup>
);
case "card-section":
return (
<CardSectionGroup
title={config.title}
collapsible={config.collapsible}
defaultExpanded={config.defaultExpanded}
>
{children}
</CardSectionGroup>
);
case "modal":
return (
<ModalGroup
title={config.title}
open={open}
onOpenChange={onOpenChange}
modalSize={config.modalSize}
>
{children}
</ModalGroup>
);
case "form-modal":
return (
<FormModalGroup
title={config.title}
open={open}
onOpenChange={onOpenChange}
modalSize={config.modalSize}
>
{children}
</FormModalGroup>
);
default:
return (
<SectionGroup title={config.title}>
{children}
</SectionGroup>
);
}
};
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
style={{
width: componentWidth,
height: componentHeight,
}}
>
{renderGroup()}
</div>
);
}
);
UnifiedGroup.displayName = "UnifiedGroup";
export default UnifiedGroup;

View File

@ -0,0 +1,501 @@
"use client";
/**
* UnifiedHierarchy
*
*
* - tree: 트리
* - org: 조직도
* - bom: BOM
* - cascading: 연쇄
*/
import React, { forwardRef, useCallback, useState } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { UnifiedHierarchyProps, HierarchyNode } from "@/types/unified-components";
import {
ChevronRight,
ChevronDown,
Folder,
FolderOpen,
File,
Plus,
Minus,
GripVertical,
User,
Users,
Building
} from "lucide-react";
/**
*
*/
const TreeNode = forwardRef<HTMLDivElement, {
node: HierarchyNode;
level: number;
maxLevel?: number;
selectedNode?: HierarchyNode;
onSelect?: (node: HierarchyNode) => void;
editable?: boolean;
draggable?: boolean;
showQty?: boolean;
className?: string;
}>(({
node,
level,
maxLevel,
selectedNode,
onSelect,
editable,
draggable,
showQty,
className
}, ref) => {
const [isOpen, setIsOpen] = useState(level < 2);
const hasChildren = node.children && node.children.length > 0;
const isSelected = selectedNode?.id === node.id;
// 최대 레벨 제한
if (maxLevel && level >= maxLevel) {
return null;
}
return (
<div ref={ref} className={className}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div
className={cn(
"flex items-center gap-1 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
isSelected && "bg-primary/10 text-primary"
)}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => onSelect?.(node)}
>
{/* 드래그 핸들 */}
{draggable && (
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
)}
{/* 확장/축소 아이콘 */}
{hasChildren ? (
<CollapsibleTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-5 w-5 p-0">
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
) : (
<span className="w-5" />
)}
{/* 폴더/파일 아이콘 */}
{hasChildren ? (
isOpen ? (
<FolderOpen className="h-4 w-4 text-amber-500" />
) : (
<Folder className="h-4 w-4 text-amber-500" />
)
) : (
<File className="h-4 w-4 text-muted-foreground" />
)}
{/* 라벨 */}
<span className="flex-1 text-sm truncate">{node.label}</span>
{/* 수량 (BOM용) */}
{showQty && node.data?.qty && (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
x{String(node.data.qty)}
</span>
)}
{/* 편집 버튼 */}
{editable && (
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); }}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
</div>
{/* 자식 노드 */}
{hasChildren && (
<CollapsibleContent>
{node.children!.map((child) => (
<TreeNode
key={child.id}
node={child}
level={level + 1}
maxLevel={maxLevel}
selectedNode={selectedNode}
onSelect={onSelect}
editable={editable}
draggable={draggable}
showQty={showQty}
/>
))}
</CollapsibleContent>
)}
</Collapsible>
</div>
);
});
TreeNode.displayName = "TreeNode";
/**
*
*/
const TreeView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
editable?: boolean;
draggable?: boolean;
maxLevel?: number;
className?: string;
}>(({ data, selectedNode, onNodeSelect, editable, draggable, maxLevel, className }, ref) => {
return (
<div ref={ref} className={cn("border rounded-lg p-2", className)}>
{data.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
</div>
) : (
data.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
maxLevel={maxLevel}
selectedNode={selectedNode}
onSelect={onNodeSelect}
editable={editable}
draggable={draggable}
/>
))
)}
</div>
);
});
TreeView.displayName = "TreeView";
/**
*
*/
const OrgView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
className?: string;
}>(({ data, selectedNode, onNodeSelect, className }, ref) => {
const renderOrgNode = (node: HierarchyNode, isRoot = false) => {
const isSelected = selectedNode?.id === node.id;
const hasChildren = node.children && node.children.length > 0;
return (
<div key={node.id} className="flex flex-col items-center">
{/* 노드 카드 */}
<div
className={cn(
"flex flex-col items-center p-3 border rounded-lg cursor-pointer hover:border-primary transition-colors",
isSelected && "border-primary bg-primary/5",
isRoot && "bg-primary/10"
)}
onClick={() => onNodeSelect?.(node)}
>
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center mb-2",
isRoot ? "bg-primary text-primary-foreground" : "bg-muted"
)}>
{isRoot ? (
<Building className="h-5 w-5" />
) : hasChildren ? (
<Users className="h-5 w-5" />
) : (
<User className="h-5 w-5" />
)}
</div>
<div className="text-center">
<div className="font-medium text-sm">{node.label}</div>
{node.data?.title && (
<div className="text-xs text-muted-foreground">{String(node.data.title)}</div>
)}
</div>
</div>
{/* 자식 노드 */}
{hasChildren && (
<>
{/* 연결선 */}
<div className="w-px h-4 bg-border" />
<div className="flex gap-4">
{node.children!.map((child, index) => (
<React.Fragment key={child.id}>
{index > 0 && <div className="w-4" />}
{renderOrgNode(child)}
</React.Fragment>
))}
</div>
</>
)}
</div>
);
};
return (
<div ref={ref} className={cn("overflow-auto p-4", className)}>
{data.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
</div>
) : (
<div className="flex flex-col items-center gap-4">
{data.map((node) => renderOrgNode(node, true))}
</div>
)}
</div>
);
});
OrgView.displayName = "OrgView";
/**
* BOM ( )
*/
const BomView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
editable?: boolean;
className?: string;
}>(({ data, selectedNode, onNodeSelect, editable, className }, ref) => {
return (
<div ref={ref} className={cn("border rounded-lg p-2", className)}>
{data.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
BOM
</div>
) : (
data.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
selectedNode={selectedNode}
onSelect={onNodeSelect}
editable={editable}
showQty={true}
/>
))
)}
</div>
);
});
BomView.displayName = "BomView";
/**
*
*/
const CascadingView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
maxLevel?: number;
className?: string;
}>(({ data, selectedNode, onNodeSelect, maxLevel = 3, className }, ref) => {
const [selections, setSelections] = useState<string[]>([]);
// 레벨별 옵션 가져오기
const getOptionsForLevel = (level: number): HierarchyNode[] => {
if (level === 0) return data;
let currentNodes = data;
for (let i = 0; i < level; i++) {
const selectedId = selections[i];
if (!selectedId) return [];
const selectedNode = currentNodes.find((n) => n.id === selectedId);
if (!selectedNode?.children) return [];
currentNodes = selectedNode.children;
}
return currentNodes;
};
// 선택 핸들러
const handleSelect = (level: number, nodeId: string) => {
const newSelections = [...selections.slice(0, level), nodeId];
setSelections(newSelections);
// 마지막 선택된 노드 찾기
let node = data.find((n) => n.id === newSelections[0]);
for (let i = 1; i < newSelections.length; i++) {
node = node?.children?.find((n) => n.id === newSelections[i]);
}
if (node) {
onNodeSelect?.(node);
}
};
return (
<div ref={ref} className={cn("flex gap-2", className)}>
{Array.from({ length: maxLevel }, (_, level) => {
const options = getOptionsForLevel(level);
const isDisabled = level > 0 && !selections[level - 1];
return (
<Select
key={level}
value={selections[level] || ""}
onValueChange={(value) => handleSelect(level, value)}
disabled={isDisabled || options.length === 0}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder={`${level + 1}단계 선택`} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
})}
</div>
);
});
CascadingView.displayName = "CascadingView";
/**
* UnifiedHierarchy
*/
export const UnifiedHierarchy = forwardRef<HTMLDivElement, UnifiedHierarchyProps>(
(props, ref) => {
const {
id,
label,
required,
style,
size,
config: configProp,
data = [],
selectedNode,
onNodeSelect,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "tree" as const, viewMode: "tree" as const, dataSource: "static" as const };
// 뷰모드별 렌더링
const renderHierarchy = () => {
const viewMode = config.viewMode || config.type || "tree";
switch (viewMode) {
case "tree":
return (
<TreeView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
editable={config.editable}
draggable={config.draggable}
maxLevel={config.maxLevel}
/>
);
case "org":
return (
<OrgView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
/>
);
case "bom":
return (
<BomView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
editable={config.editable}
/>
);
case "dropdown":
case "cascading":
return (
<CascadingView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
maxLevel={config.maxLevel}
/>
);
default:
return (
<TreeView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
/>
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="text-sm font-medium flex-shrink-0"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div className="flex-1 min-h-0">
{renderHierarchy()}
</div>
</div>
);
}
);
UnifiedHierarchy.displayName = "UnifiedHierarchy";
export default UnifiedHierarchy;

View File

@ -0,0 +1,577 @@
"use client";
/**
* UnifiedInput
*
*
* - text: 텍스트
* - number:
* - password: 비밀번호
* - slider: 슬라이더
* - color: 색상
* - button: 버튼 ( )
*/
import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { UnifiedInputProps, UnifiedInputConfig, UnifiedInputFormat } from "@/types/unified-components";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
import { AutoGenerationConfig } from "@/types/screen";
// 형식별 입력 마스크 및 검증 패턴
const FORMAT_PATTERNS: Record<UnifiedInputFormat, { pattern: RegExp; placeholder: string }> = {
none: { pattern: /.*/, placeholder: "" },
email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com" },
tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678" },
url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com" },
currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000" },
biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890" },
};
// 통화 형식 변환
function formatCurrency(value: string | number): string {
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
if (isNaN(num)) return "";
return num.toLocaleString("ko-KR");
}
// 사업자번호 형식 변환
function formatBizNo(value: string): string {
const digits = value.replace(/\D/g, "");
if (digits.length <= 3) return digits;
if (digits.length <= 5) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5, 10)}`;
}
// 전화번호 형식 변환
function formatTel(value: string): string {
const digits = value.replace(/\D/g, "");
if (digits.length <= 3) return digits;
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
}
/**
*
*/
const TextInput = forwardRef<
HTMLInputElement,
{
value?: string | number;
onChange?: (value: string) => void;
format?: UnifiedInputFormat;
mask?: string;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
className?: string;
}
>(({ value, onChange, format = "none", placeholder, readonly, disabled, className }, ref) => {
// 형식에 따른 값 포맷팅
const formatValue = useCallback(
(val: string): string => {
switch (format) {
case "currency":
return formatCurrency(val);
case "biz_no":
return formatBizNo(val);
case "tel":
return formatTel(val);
default:
return val;
}
},
[format],
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
let newValue = e.target.value;
// 형식에 따른 자동 포맷팅
if (format === "currency") {
// 숫자와 쉼표만 허용
newValue = newValue.replace(/[^\d,]/g, "");
newValue = formatCurrency(newValue);
} else if (format === "biz_no") {
newValue = formatBizNo(newValue);
} else if (format === "tel") {
newValue = formatTel(newValue);
}
onChange?.(newValue);
},
[format, onChange],
);
const displayValue = useMemo(() => {
if (value === undefined || value === null) return "";
return formatValue(String(value));
}, [value, formatValue]);
const inputPlaceholder = placeholder || FORMAT_PATTERNS[format].placeholder;
return (
<Input
ref={ref}
type="text"
value={displayValue}
onChange={handleChange}
placeholder={inputPlaceholder}
readOnly={readonly}
disabled={disabled}
className={cn("h-full w-full", className)}
/>
);
});
TextInput.displayName = "TextInput";
/**
*
*/
const NumberInput = forwardRef<
HTMLInputElement,
{
value?: number;
onChange?: (value: number | undefined) => void;
min?: number;
max?: number;
step?: number;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
className?: string;
}
>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className }, ref) => {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (val === "") {
onChange?.(undefined);
return;
}
let num = parseFloat(val);
// 범위 제한
if (min !== undefined && num < min) num = min;
if (max !== undefined && num > max) num = max;
onChange?.(num);
},
[min, max, onChange],
);
return (
<Input
ref={ref}
type="number"
value={value ?? ""}
onChange={handleChange}
min={min}
max={max}
step={step}
placeholder={placeholder || "숫자 입력"}
readOnly={readonly}
disabled={disabled}
className={cn("h-full w-full", className)}
/>
);
});
NumberInput.displayName = "NumberInput";
/**
*
*/
const PasswordInput = forwardRef<
HTMLInputElement,
{
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
className?: string;
}
>(({ value, onChange, placeholder, readonly, disabled, className }, ref) => {
const [showPassword, setShowPassword] = useState(false);
return (
<div className="relative">
<Input
ref={ref}
type={showPassword ? "text" : "password"}
value={value ?? ""}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder || "비밀번호 입력"}
readOnly={readonly}
disabled={disabled}
className={cn("h-full w-full pr-10", className)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2 text-xs"
>
{showPassword ? "숨김" : "보기"}
</button>
</div>
);
});
PasswordInput.displayName = "PasswordInput";
/**
*
*/
const SliderInput = forwardRef<
HTMLDivElement,
{
value?: number;
onChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
className?: string;
}
>(({ value, onChange, min = 0, max = 100, step = 1, disabled, className }, ref) => {
return (
<div ref={ref} className={cn("flex items-center gap-4", className)}>
<Slider
value={[value ?? min]}
onValueChange={(values) => onChange?.(values[0])}
min={min}
max={max}
step={step}
disabled={disabled}
className="flex-1"
/>
<span className="w-12 text-right text-sm font-medium">{value ?? min}</span>
</div>
);
});
SliderInput.displayName = "SliderInput";
/**
*
*/
const ColorInput = forwardRef<
HTMLInputElement,
{
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}
>(({ value, onChange, disabled, className }, ref) => {
return (
<div className={cn("flex items-center gap-2", className)}>
<Input
ref={ref}
type="color"
value={value || "#000000"}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
className="h-full w-12 cursor-pointer p-1"
/>
<Input
type="text"
value={value || "#000000"}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
className="h-full flex-1 uppercase"
maxLength={7}
/>
</div>
);
});
ColorInput.displayName = "ColorInput";
/**
*
*/
const TextareaInput = forwardRef<
HTMLTextAreaElement,
{
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
rows?: number;
readonly?: boolean;
disabled?: boolean;
className?: string;
}
>(({ value = "", onChange, placeholder, rows = 3, readonly, disabled, className }, ref) => {
return (
<textarea
ref={ref}
value={value}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
rows={rows}
readOnly={readonly}
disabled={disabled}
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
/>
);
});
TextareaInput.displayName = "TextareaInput";
/**
* UnifiedInput
*/
export const UnifiedInput = forwardRef<HTMLDivElement, UnifiedInputProps>((props, ref) => {
const { id, label, required, readonly, disabled, style, size, config: configProp, value, onChange } = props;
// formData 추출 (채번규칙 날짜 컬럼 기준 생성 시 사용)
const formData = (props as any).formData || {};
const columnName = (props as any).columnName;
// config가 없으면 기본값 사용
const config = (configProp || { type: "text" }) as UnifiedInputConfig & {
inputType?: string;
rows?: number;
autoGeneration?: AutoGenerationConfig;
};
// 자동생성 설정 추출
const autoGeneration: AutoGenerationConfig = (props as any).autoGeneration ||
(config as any).autoGeneration || {
type: "none",
enabled: false,
};
// 자동생성 상태 관리
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string | null>(null);
const isGeneratingRef = useRef(false);
const hasGeneratedRef = useRef(false);
const lastFormDataRef = useRef<string>(""); // 마지막 formData 추적 (채번 규칙용)
// 수정 모드 여부 확인
const originalData = (props as any).originalData || (props as any)._originalData;
const isEditMode = originalData && Object.keys(originalData).length > 0;
// 채번 규칙인 경우 formData 변경 감지 (자기 자신 필드 제외)
const formDataForNumbering = useMemo(() => {
if (autoGeneration.type !== "numbering_rule") return "";
// 자기 자신의 값은 제외 (무한 루프 방지)
const { [columnName]: _, ...rest } = formData;
return JSON.stringify(rest);
}, [autoGeneration.type, formData, columnName]);
// 자동생성 로직
useEffect(() => {
const generateValue = async () => {
// 자동생성 비활성화 또는 생성 중
if (!autoGeneration.enabled || isGeneratingRef.current) {
return;
}
// 수정 모드에서는 자동생성 안함
if (isEditMode) {
return;
}
// 채번 규칙인 경우: formData가 변경되었는지 확인
const isNumberingRule = autoGeneration.type === "numbering_rule";
const formDataChanged =
isNumberingRule && formDataForNumbering !== lastFormDataRef.current && lastFormDataRef.current !== "";
// 이미 생성되었고, formData 변경이 아닌 경우 스킵
if (hasGeneratedRef.current && !formDataChanged) {
return;
}
// 첫 생성 시: 값이 이미 있으면 스킵 (formData 변경 시에는 강제 재생성)
if (!formDataChanged && value !== undefined && value !== null && value !== "") {
return;
}
isGeneratingRef.current = true;
try {
// formData를 전달하여 날짜 컬럼 기준 생성 지원
const generatedValue = await AutoGenerationUtils.generateValue(autoGeneration, columnName, formData);
if (generatedValue !== null && generatedValue !== undefined) {
setAutoGeneratedValue(generatedValue);
onChange?.(generatedValue);
hasGeneratedRef.current = true;
// formData 기록
if (isNumberingRule) {
lastFormDataRef.current = formDataForNumbering;
}
}
} catch (error) {
console.error("자동생성 실패:", error);
} finally {
isGeneratingRef.current = false;
}
};
generateValue();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoGeneration.enabled, autoGeneration.type, isEditMode, formDataForNumbering]);
// 실제 표시할 값 (자동생성 값 또는 props value)
const displayValue = autoGeneratedValue ?? value;
// 조건부 렌더링 체크
// TODO: conditional 처리 로직 추가
// 타입별 입력 컴포넌트 렌더링
const renderInput = () => {
const inputType = config.inputType || config.type || "text";
switch (inputType) {
case "text":
return (
<TextInput
value={displayValue}
onChange={(v) => {
setAutoGeneratedValue(null); // 사용자 입력 시 자동생성 값 초기화
onChange?.(v);
}}
format={config.format}
mask={config.mask}
placeholder={config.placeholder}
readonly={readonly || (autoGeneration.enabled && hasGeneratedRef.current)}
disabled={disabled}
/>
);
case "number":
return (
<NumberInput
value={typeof displayValue === "number" ? displayValue : undefined}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v ?? 0);
}}
min={config.min}
max={config.max}
step={config.step}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
/>
);
case "password":
return (
<PasswordInput
value={typeof displayValue === "string" ? displayValue : ""}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
/>
);
case "slider":
return (
<SliderInput
value={typeof displayValue === "number" ? displayValue : (config.min ?? 0)}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
min={config.min}
max={config.max}
step={config.step}
disabled={disabled}
/>
);
case "color":
return (
<ColorInput
value={typeof displayValue === "string" ? displayValue : "#000000"}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
disabled={disabled}
/>
);
case "textarea":
return (
<TextareaInput
value={displayValue as string}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
placeholder={config.placeholder}
rows={config.rows}
readonly={readonly}
disabled={disabled}
/>
);
default:
return (
<TextInput
value={displayValue}
onChange={(v) => {
setAutoGeneratedValue(null);
onChange?.(v);
}}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
/>
);
}
};
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
const showLabel = label && style?.labelDisplay !== false;
// size에서 우선 가져오고, 없으면 style에서 가져옴
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="flex-shrink-0 text-sm font-medium"
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
<div className="min-h-0 flex-1">{renderInput()}</div>
</div>
);
});
UnifiedInput.displayName = "UnifiedInput";
export default UnifiedInput;

View File

@ -0,0 +1,399 @@
"use client";
/**
* UnifiedLayout
*
*
* - grid: 그리드
* - split: 분할
* - flex: 플렉스
* - divider: 구분선
* - screen-embed: 화면
*/
import React, { forwardRef, useCallback, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { UnifiedLayoutProps } from "@/types/unified-components";
import { GripVertical, GripHorizontal } from "lucide-react";
/**
* (12 )
*
* :
* - columns: 컬럼 ( 12, )
* - colSpan: 자식 span
* - Tailwind의 grid-cols-12
*/
const GridLayout = forwardRef<HTMLDivElement, {
columns?: number; // 12컬럼 시스템에서 몇 컬럼으로 나눌지 (1-12)
gap?: string;
children?: React.ReactNode;
className?: string;
use12Column?: boolean; // 12컬럼 시스템 사용 여부
}>(({ columns = 12, gap = "16px", children, className, use12Column = true }, ref) => {
// 12컬럼 그리드 클래스 매핑
const gridColsClass: Record<number, string> = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
9: "grid-cols-9",
10: "grid-cols-10",
11: "grid-cols-11",
12: "grid-cols-12",
};
// 12컬럼 시스템 사용 시
if (use12Column) {
return (
<div
ref={ref}
className={cn(
"grid",
gridColsClass[columns] || "grid-cols-12",
className
)}
style={{ gap }}
>
{children}
</div>
);
}
// 기존 방식 (동적 컬럼 수)
return (
<div
ref={ref}
className={cn("grid", className)}
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap,
}}
>
{children}
</div>
);
});
GridLayout.displayName = "GridLayout";
/**
* ( )
*/
const SplitLayout = forwardRef<HTMLDivElement, {
direction?: "horizontal" | "vertical";
splitRatio?: number[];
gap?: string;
children?: React.ReactNode;
className?: string;
}>(({ direction = "horizontal", splitRatio = [50, 50], gap = "8px", children, className }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const [ratio, setRatio] = useState(splitRatio);
const [isDragging, setIsDragging] = useState(false);
const childArray = React.Children.toArray(children);
const isHorizontal = direction === "horizontal";
// 리사이저 드래그 시작
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
const startPos = isHorizontal ? e.clientX : e.clientY;
const startRatio = [...ratio];
const container = containerRef.current;
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!container) return;
const containerSize = isHorizontal ? container.offsetWidth : container.offsetHeight;
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
const delta = currentPos - startPos;
const deltaPercent = (delta / containerSize) * 100;
const newFirst = Math.max(10, Math.min(90, startRatio[0] + deltaPercent));
const newSecond = 100 - newFirst;
setRatio([newFirst, newSecond]);
};
const handleMouseUp = () => {
setIsDragging(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}, [isHorizontal, ratio]);
return (
<div
ref={(node) => {
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
className={cn(
"flex",
isHorizontal ? "flex-row" : "flex-col",
className
)}
style={{ gap }}
>
{/* 첫 번째 패널 */}
<div
className="overflow-auto"
style={{
[isHorizontal ? "width" : "height"]: `${ratio[0]}%`,
flexShrink: 0,
}}
>
{childArray[0]}
</div>
{/* 리사이저 */}
<div
className={cn(
"flex items-center justify-center bg-border hover:bg-primary/20 transition-colors",
isHorizontal ? "w-2 cursor-col-resize" : "h-2 cursor-row-resize",
isDragging && "bg-primary/30"
)}
onMouseDown={handleMouseDown}
>
{isHorizontal ? (
<GripVertical className="h-4 w-4 text-muted-foreground" />
) : (
<GripHorizontal className="h-4 w-4 text-muted-foreground" />
)}
</div>
{/* 두 번째 패널 */}
<div
className="overflow-auto flex-1"
style={{
[isHorizontal ? "width" : "height"]: `${ratio[1]}%`,
}}
>
{childArray[1]}
</div>
</div>
);
});
SplitLayout.displayName = "SplitLayout";
/**
*
*/
const FlexLayout = forwardRef<HTMLDivElement, {
direction?: "horizontal" | "vertical";
gap?: string;
wrap?: boolean;
justify?: "start" | "center" | "end" | "between" | "around";
align?: "start" | "center" | "end" | "stretch";
children?: React.ReactNode;
className?: string;
}>(({
direction = "horizontal",
gap = "16px",
wrap = false,
justify = "start",
align = "stretch",
children,
className
}, ref) => {
const justifyMap = {
start: "flex-start",
center: "center",
end: "flex-end",
between: "space-between",
around: "space-around",
};
const alignMap = {
start: "flex-start",
center: "center",
end: "flex-end",
stretch: "stretch",
};
return (
<div
ref={ref}
className={cn("flex", className)}
style={{
flexDirection: direction === "horizontal" ? "row" : "column",
flexWrap: wrap ? "wrap" : "nowrap",
justifyContent: justifyMap[justify],
alignItems: alignMap[align],
gap,
}}
>
{children}
</div>
);
});
FlexLayout.displayName = "FlexLayout";
/**
*
*/
const DividerLayout = forwardRef<HTMLDivElement, {
direction?: "horizontal" | "vertical";
className?: string;
}>(({ direction = "horizontal", className }, ref) => {
return (
<div
ref={ref}
className={cn(
"bg-border",
direction === "horizontal" ? "h-px w-full my-4" : "w-px h-full mx-4",
className
)}
/>
);
});
DividerLayout.displayName = "DividerLayout";
/**
*
*/
const ScreenEmbedLayout = forwardRef<HTMLDivElement, {
screenId?: number;
className?: string;
}>(({ screenId, className }, ref) => {
if (!screenId) {
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center h-32 border-2 border-dashed rounded-lg text-muted-foreground",
className
)}
>
</div>
);
}
// TODO: 실제 화면 임베딩 로직 구현
// InteractiveScreenViewer와 연동 필요
return (
<div
ref={ref}
className={cn(
"border rounded-lg p-4",
className
)}
>
<div className="text-sm text-muted-foreground mb-2">
(ID: {screenId})
</div>
<div className="h-48 bg-muted/30 rounded flex items-center justify-center">
{/* 여기에 InteractiveScreenViewer 렌더링 */}
</div>
</div>
);
});
ScreenEmbedLayout.displayName = "ScreenEmbedLayout";
/**
* UnifiedLayout
*/
export const UnifiedLayout = forwardRef<HTMLDivElement, UnifiedLayoutProps>(
(props, ref) => {
const {
id,
style,
size,
config: configProp,
children,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "grid" as const, columns: 2 };
// 타입별 레이아웃 렌더링
const renderLayout = () => {
const layoutType = config.type || "grid";
switch (layoutType) {
case "grid":
return (
<GridLayout
columns={config.columns}
gap={config.gap}
>
{children}
</GridLayout>
);
case "split":
return (
<SplitLayout
direction={config.direction}
splitRatio={config.splitRatio}
gap={config.gap}
>
{children}
</SplitLayout>
);
case "flex":
return (
<FlexLayout
direction={config.direction}
gap={config.gap}
>
{children}
</FlexLayout>
);
case "divider":
return (
<DividerLayout
direction={config.direction}
/>
);
case "screen-embed":
return (
<ScreenEmbedLayout
screenId={config.screenId}
/>
);
default:
return (
<GridLayout columns={config.columns} gap={config.gap}>
{children}
</GridLayout>
);
}
};
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
style={{
width: componentWidth,
height: componentHeight,
}}
>
{renderLayout()}
</div>
);
}
);
UnifiedLayout.displayName = "UnifiedLayout";
export default UnifiedLayout;

View File

@ -0,0 +1,176 @@
"use client";
/**
* UnifiedList
*
*
* TableListComponent를
*/
import React, { forwardRef, useMemo } from "react";
import { TableListComponent } from "@/lib/registry/components/table-list/TableListComponent";
import { UnifiedListProps } from "@/types/unified-components";
/**
* UnifiedList
* TableListComponent의
*/
export const UnifiedList = forwardRef<HTMLDivElement, UnifiedListProps>((props, ref) => {
const { id, style, size, config: configProp, onRowSelect } = props;
// config가 없으면 기본값 사용
const config = configProp || {
viewMode: "table" as const,
source: "static" as const,
columns: [],
};
// 테이블명 추출 (여러 가능한 경로에서 시도)
const tableName = config.dataSource?.table || (config as any).tableName || (props as any).tableName;
// columns 형식 변환 (UnifiedListConfigPanel 형식 -> TableListComponent 형식)
const tableColumns = useMemo(
() =>
(config.columns || []).map((col: any, index: number) => ({
columnName: col.key || col.field || "",
displayName: col.title || col.header || col.key || col.field || "",
width: col.width ? parseInt(col.width, 10) : undefined,
visible: true,
sortable: true,
searchable: true,
align: "left" as const,
order: index,
isEntityJoin: col.isJoinColumn || false,
thousandSeparator: col.thousandSeparator !== false, // 천단위 구분자 (기본: true)
})),
[config.columns],
);
// TableListComponent에 전달할 component 객체 생성
const componentObj = useMemo(
() => ({
id: id || "unified-list",
type: "table-list",
config: {
selectedTable: tableName,
tableName: tableName,
columns: tableColumns,
displayMode: config.viewMode === "card" ? "card" : "table",
cardConfig: {
idColumn: config.cardConfig?.titleColumn || tableColumns[0]?.columnName || "id",
titleColumn: config.cardConfig?.titleColumn || tableColumns[0]?.columnName || "",
subtitleColumn: config.cardConfig?.subtitleColumn || undefined,
descriptionColumn: config.cardConfig?.descriptionColumn || undefined,
imageColumn: config.cardConfig?.imageColumn || undefined,
cardsPerRow: config.cardConfig?.cardsPerRow || 3,
cardSpacing: 16,
showActions: false,
},
showHeader: config.viewMode !== "card", // 카드 모드에서는 테이블 헤더 숨김
showFooter: false,
checkbox: {
enabled: true, // 항상 체크박스 활성화 (modalDataStore에 자동 저장)
position: "left" as const,
showHeader: true,
},
height: "auto" as const, // auto로 변경하여 스크롤 가능하게
autoWidth: true,
stickyHeader: true,
autoLoad: true,
horizontalScroll: {
enabled: true,
minColumnWidth: 100,
maxColumnWidth: 300,
},
pagination: {
enabled: config.pagination !== false,
pageSize: config.pageSize || 10,
position: "bottom" as const,
showPageSize: true, // 사용자가 실제 화면에서 페이지 크기 변경 가능
pageSizeOptions: [5, 10, 20, 50, 100],
},
filter: {
enabled: false, // 필터 비활성화 (필요시 활성화)
position: "top" as const,
searchPlaceholder: "검색...",
},
actions: {
enabled: false,
items: [],
},
tableStyle: {
striped: false,
bordered: true,
hover: true,
compact: false,
},
toolbar: {
showRefresh: true,
showExport: false,
showColumnToggle: false,
},
},
style: {},
gridColumns: 1,
}),
[
id,
tableName,
tableColumns,
config.viewMode,
config.pagination,
config.pageSize,
config.cardConfig,
onRowSelect,
],
);
// 테이블이 없으면 안내 메시지
if (!tableName) {
return (
<div
ref={ref}
id={id}
className="bg-muted/20 flex items-center justify-center rounded-lg border p-8"
style={{
width: size?.width || style?.width || "100%",
height: size?.height || style?.height || 400,
}}
>
<p className="text-muted-foreground text-sm"> .</p>
</div>
);
}
return (
<div
ref={ref}
id={id}
className="flex flex-col overflow-auto"
style={{
width: size?.width || style?.width || "100%",
height: size?.height || style?.height || 400,
}}
>
<TableListComponent
component={componentObj}
tableName={tableName}
style={{
width: "100%",
minHeight: "100%",
display: "flex",
flexDirection: "column",
}}
onSelectedRowsChange={
onRowSelect
? (_, selectedData) => {
onRowSelect(selectedData);
}
: undefined
}
/>
</div>
);
});
UnifiedList.displayName = "UnifiedList";

View File

@ -0,0 +1,575 @@
"use client";
/**
* UnifiedMedia
*
*
* - file: 파일
* - image: 이미지 /
* - video: 비디오
* - audio: 오디오
*/
import React, { forwardRef, useCallback, useRef, useState } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { UnifiedMediaProps } from "@/types/unified-components";
import { Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2 } from "lucide-react";
/**
*
*/
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
/**
*
*/
function getFileIcon(type: string) {
if (type.startsWith("image/")) return ImageIcon;
if (type.startsWith("video/")) return Video;
if (type.startsWith("audio/")) return Music;
return File;
}
/**
*
*/
const FileUploader = forwardRef<HTMLDivElement, {
value?: string | string[];
onChange?: (value: string | string[]) => void;
multiple?: boolean;
accept?: string;
maxSize?: number;
disabled?: boolean;
uploadEndpoint?: string;
className?: string;
}>(({
value,
onChange,
multiple = false,
accept = "*",
maxSize = 10485760, // 10MB
disabled,
uploadEndpoint = "/api/upload",
className
}, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const files = Array.isArray(value) ? value : value ? [value] : [];
// 파일 선택 핸들러
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
if (!selectedFiles || selectedFiles.length === 0) return;
setError(null);
const fileArray = Array.from(selectedFiles);
// 크기 검증
for (const file of fileArray) {
if (file.size > maxSize) {
setError(`파일 크기가 ${formatFileSize(maxSize)}를 초과합니다: ${file.name}`);
return;
}
}
setIsUploading(true);
try {
const uploadedUrls: string[] = [];
for (const file of fileArray) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(uploadEndpoint, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`업로드 실패: ${file.name}`);
}
const data = await response.json();
if (data.success && data.url) {
uploadedUrls.push(data.url);
} else if (data.filePath) {
uploadedUrls.push(data.filePath);
}
}
if (multiple) {
onChange?.([...files, ...uploadedUrls]);
} else {
onChange?.(uploadedUrls[0] || "");
}
} catch (err) {
setError(err instanceof Error ? err.message : "업로드 중 오류가 발생했습니다");
} finally {
setIsUploading(false);
}
}, [files, multiple, maxSize, uploadEndpoint, onChange]);
// 드래그 앤 드롭 핸들러
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
handleFileSelect(e.dataTransfer.files);
}, [handleFileSelect]);
// 파일 삭제 핸들러
const handleRemove = useCallback((index: number) => {
const newFiles = files.filter((_, i) => i !== index);
onChange?.(multiple ? newFiles : "");
}, [files, multiple, onChange]);
return (
<div ref={ref} className={cn("space-y-3", className)}>
{/* 업로드 영역 */}
<div
className={cn(
"border-2 border-dashed rounded-lg p-6 text-center transition-colors",
isDragging && "border-primary bg-primary/5",
disabled && "opacity-50 cursor-not-allowed",
!disabled && "cursor-pointer hover:border-primary/50"
)}
onClick={() => !disabled && inputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
{isUploading ? (
<div className="flex flex-col items-center gap-2">
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<div className="text-sm">
<span className="font-medium text-primary"></span>
<span className="text-muted-foreground"> </span>
</div>
<div className="text-xs text-muted-foreground">
{formatFileSize(maxSize)}
{accept !== "*" && ` (${accept})`}
</div>
</div>
)}
</div>
{/* 에러 메시지 */}
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
{/* 업로드된 파일 목록 */}
{files.length > 0 && (
<div className="space-y-2">
{files.map((file, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 bg-muted/50 rounded-md"
>
<File className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 text-sm truncate">{file.split("/").pop()}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleRemove(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
);
});
FileUploader.displayName = "FileUploader";
/**
* /
*/
const ImageUploader = forwardRef<HTMLDivElement, {
value?: string | string[];
onChange?: (value: string | string[]) => void;
multiple?: boolean;
accept?: string;
maxSize?: number;
preview?: boolean;
disabled?: boolean;
uploadEndpoint?: string;
className?: string;
}>(({
value,
onChange,
multiple = false,
accept = "image/*",
maxSize = 10485760,
preview = true,
disabled,
uploadEndpoint = "/api/upload",
className
}, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const images = Array.isArray(value) ? value : value ? [value] : [];
// 파일 선택 핸들러
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
if (!selectedFiles || selectedFiles.length === 0) return;
setIsUploading(true);
try {
const fileArray = Array.from(selectedFiles);
const uploadedUrls: string[] = [];
for (const file of fileArray) {
// 미리보기 생성
if (preview) {
const reader = new FileReader();
reader.onload = () => setPreviewUrl(reader.result as string);
reader.readAsDataURL(file);
}
const formData = new FormData();
formData.append("file", file);
const response = await fetch(uploadEndpoint, {
method: "POST",
body: formData,
});
if (response.ok) {
const data = await response.json();
if (data.success && data.url) {
uploadedUrls.push(data.url);
} else if (data.filePath) {
uploadedUrls.push(data.filePath);
}
}
}
if (multiple) {
onChange?.([...images, ...uploadedUrls]);
} else {
onChange?.(uploadedUrls[0] || "");
}
} finally {
setIsUploading(false);
setPreviewUrl(null);
}
}, [images, multiple, preview, uploadEndpoint, onChange]);
// 이미지 삭제 핸들러
const handleRemove = useCallback((index: number) => {
const newImages = images.filter((_, i) => i !== index);
onChange?.(multiple ? newImages : "");
}, [images, multiple, onChange]);
return (
<div ref={ref} className={cn("space-y-3", className)}>
{/* 이미지 미리보기 */}
{preview && images.length > 0 && (
<div className={cn(
"grid gap-2",
multiple ? "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4" : "grid-cols-1"
)}>
{images.map((src, index) => (
<div key={index} className="relative group aspect-square rounded-lg overflow-hidden border">
<img
src={src}
alt={`이미지 ${index + 1}`}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
onClick={() => window.open(src, "_blank")}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="icon"
className="h-8 w-8"
onClick={() => handleRemove(index)}
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{/* 업로드 버튼 */}
{(!images.length || multiple) && (
<div
className={cn(
"border-2 border-dashed rounded-lg p-4 text-center transition-colors",
isDragging && "border-primary bg-primary/5",
disabled && "opacity-50 cursor-not-allowed",
!disabled && "cursor-pointer hover:border-primary/50"
)}
onClick={() => !disabled && inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFileSelect(e.dataTransfer.files); }}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
{isUploading ? (
<div className="flex items-center justify-center gap-2">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<ImageIcon className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{multiple ? "추가" : "선택"}
</span>
</div>
)}
</div>
)}
</div>
);
});
ImageUploader.displayName = "ImageUploader";
/**
*
*/
const VideoPlayer = forwardRef<HTMLDivElement, {
value?: string;
className?: string;
}>(({ value, className }, ref) => {
if (!value) {
return (
<div
ref={ref}
className={cn(
"aspect-video flex items-center justify-center border rounded-lg bg-muted/50",
className
)}
>
<Video className="h-8 w-8 text-muted-foreground" />
</div>
);
}
return (
<div ref={ref} className={cn("aspect-video rounded-lg overflow-hidden", className)}>
<video
src={value}
controls
className="w-full h-full object-cover"
/>
</div>
);
});
VideoPlayer.displayName = "VideoPlayer";
/**
*
*/
const AudioPlayer = forwardRef<HTMLDivElement, {
value?: string;
className?: string;
}>(({ value, className }, ref) => {
if (!value) {
return (
<div
ref={ref}
className={cn(
"h-12 flex items-center justify-center border rounded-lg bg-muted/50",
className
)}
>
<Music className="h-5 w-5 text-muted-foreground" />
</div>
);
}
return (
<div ref={ref} className={cn("", className)}>
<audio
src={value}
controls
className="w-full"
/>
</div>
);
});
AudioPlayer.displayName = "AudioPlayer";
/**
* UnifiedMedia
*/
export const UnifiedMedia = forwardRef<HTMLDivElement, UnifiedMediaProps>(
(props, ref) => {
const {
id,
label,
required,
readonly,
disabled,
style,
size,
config: configProp,
value,
onChange,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "image" as const };
// 타입별 미디어 컴포넌트 렌더링
const renderMedia = () => {
const isDisabled = disabled || readonly;
const mediaType = config.type || "image";
switch (mediaType) {
case "file":
return (
<FileUploader
value={value}
onChange={onChange}
multiple={config.multiple}
accept={config.accept}
maxSize={config.maxSize}
disabled={isDisabled}
uploadEndpoint={config.uploadEndpoint}
/>
);
case "image":
return (
<ImageUploader
value={value}
onChange={onChange}
multiple={config.multiple}
accept={config.accept || "image/*"}
maxSize={config.maxSize}
preview={config.preview}
disabled={isDisabled}
uploadEndpoint={config.uploadEndpoint}
/>
);
case "video":
return (
<VideoPlayer
value={typeof value === "string" ? value : value?.[0]}
/>
);
case "audio":
return (
<AudioPlayer
value={typeof value === "string" ? value : value?.[0]}
/>
);
default:
return (
<FileUploader
value={value}
onChange={onChange}
disabled={isDisabled}
/>
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="text-sm font-medium flex-shrink-0"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div className="flex-1 min-h-0">
{renderMedia()}
</div>
</div>
);
}
);
UnifiedMedia.displayName = "UnifiedMedia";
export default UnifiedMedia;

View File

@ -0,0 +1,828 @@
"use client";
/**
* UnifiedRepeater
*
* :
* - inline: 현재
* - modal: 엔티티 (FK ) +
*
* RepeaterTable ItemSelectionModal
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import {
UnifiedRepeaterConfig,
UnifiedRepeaterProps,
RepeaterColumnConfig as UnifiedColumnConfig,
DEFAULT_REPEATER_CONFIG,
} from "@/types/unified-repeater";
import { apiClient } from "@/lib/api/client";
import { allocateNumberingCode } from "@/lib/api/numberingRule";
// modal-repeater-table 컴포넌트 재사용
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
import { ItemSelectionModal } from "@/lib/registry/components/modal-repeater-table/ItemSelectionModal";
import { RepeaterColumnConfig } from "@/lib/registry/components/modal-repeater-table/types";
// 전역 UnifiedRepeater 등록 (buttonActions에서 사용)
declare global {
interface Window {
__unifiedRepeaterInstances?: Set<string>;
}
}
export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
config: propConfig,
parentId,
data: initialData,
onDataChange,
onRowClick,
className,
}) => {
// 설정 병합
const config: UnifiedRepeaterConfig = useMemo(
() => ({
...DEFAULT_REPEATER_CONFIG,
...propConfig,
dataSource: { ...DEFAULT_REPEATER_CONFIG.dataSource, ...propConfig.dataSource },
features: { ...DEFAULT_REPEATER_CONFIG.features, ...propConfig.features },
modal: { ...DEFAULT_REPEATER_CONFIG.modal, ...propConfig.modal },
}),
[propConfig],
);
// 상태
const [data, setData] = useState<any[]>(initialData || []);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [modalOpen, setModalOpen] = useState(false);
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
// 소스 테이블 컬럼 라벨 매핑
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
// 🆕 소스 테이블의 카테고리 타입 컬럼 목록
const [sourceCategoryColumns, setSourceCategoryColumns] = useState<string[]>([]);
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
// 현재 테이블 컬럼 정보 (inputType 매핑용)
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
// 동적 데이터 소스 상태
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
// 🆕 최신 엔티티 참조 정보 (column_labels에서 조회)
const [resolvedSourceTable, setResolvedSourceTable] = useState<string>("");
const [resolvedReferenceKey, setResolvedReferenceKey] = useState<string>("id");
const isModalMode = config.renderMode === "modal";
// 전역 리피터 등록
useEffect(() => {
const tableName = config.dataSource?.tableName;
if (tableName) {
if (!window.__unifiedRepeaterInstances) {
window.__unifiedRepeaterInstances = new Set();
}
window.__unifiedRepeaterInstances.add(tableName);
}
return () => {
if (tableName && window.__unifiedRepeaterInstances) {
window.__unifiedRepeaterInstances.delete(tableName);
}
};
}, [config.dataSource?.tableName]);
// 저장 이벤트 리스너
useEffect(() => {
const handleSaveEvent = async (event: CustomEvent) => {
// 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
const tableName = config.useCustomTable && config.mainTableName
? config.mainTableName
: config.dataSource?.tableName;
const eventParentId = event.detail?.parentId;
const mainFormData = event.detail?.mainFormData;
// 🆕 마스터 테이블에서 생성된 ID (FK 연결용)
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
if (!tableName || data.length === 0) {
console.log("📋 UnifiedRepeater 저장 스킵:", { tableName, dataLength: data.length });
return;
}
console.log("📋 UnifiedRepeater 저장 시작:", {
tableName,
useCustomTable: config.useCustomTable,
mainTableName: config.mainTableName,
foreignKeyColumn: config.foreignKeyColumn,
masterRecordId,
dataLength: data.length
});
try {
// 테이블 유효 컬럼 조회
let validColumns: Set<string> = new Set();
try {
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const columns =
columnsResponse.data?.data?.columns || columnsResponse.data?.columns || columnsResponse.data || [];
validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name));
} catch {
console.warn("테이블 컬럼 정보 조회 실패");
}
for (let i = 0; i < data.length; i++) {
const row = data[i];
// 내부 필드 제거
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
let mergedData: Record<string, any>;
if (config.useCustomTable && config.mainTableName) {
// 커스텀 테이블: 리피터 데이터만 저장
mergedData = { ...cleanRow };
// 🆕 FK 자동 연결
if (config.foreignKeyColumn && masterRecordId) {
mergedData[config.foreignKeyColumn] = masterRecordId;
console.log(`📎 FK 자동 연결: ${config.foreignKeyColumn} = ${masterRecordId}`);
}
} else {
// 기존 방식: 메인 폼 데이터 병합
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
mergedData = {
...mainFormDataWithoutId,
...cleanRow,
};
}
// 유효하지 않은 컬럼 제거
const filteredData: Record<string, any> = {};
for (const [key, value] of Object.entries(mergedData)) {
if (validColumns.size === 0 || validColumns.has(key)) {
filteredData[key] = value;
}
}
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
}
console.log("✅ UnifiedRepeater 저장 완료:", data.length, "건 →", tableName);
} catch (error) {
console.error("❌ UnifiedRepeater 저장 실패:", error);
throw error;
}
};
window.addEventListener("repeaterSave" as any, handleSaveEvent);
return () => {
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
};
}, [data, config.dataSource?.tableName, config.useCustomTable, config.mainTableName, config.foreignKeyColumn, parentId]);
// 현재 테이블 컬럼 정보 로드
useEffect(() => {
const loadCurrentTableColumnInfo = async () => {
const tableName = config.dataSource?.tableName;
if (!tableName) return;
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
const columnMap: Record<string, any> = {};
columns.forEach((col: any) => {
const name = col.columnName || col.column_name || col.name;
columnMap[name] = {
inputType: col.inputType || col.input_type || col.webType || "text",
displayName: col.displayName || col.display_name || col.label || name,
detailSettings: col.detailSettings || col.detail_settings,
};
});
setCurrentTableColumnInfo(columnMap);
} catch (error) {
console.error("컬럼 정보 로드 실패:", error);
}
};
loadCurrentTableColumnInfo();
}, [config.dataSource?.tableName]);
// 🆕 FK 컬럼 기반으로 최신 참조 테이블 정보 조회 (column_labels에서)
useEffect(() => {
const resolveEntityReference = async () => {
const tableName = config.dataSource?.tableName;
const foreignKey = config.dataSource?.foreignKey;
if (!isModalMode || !tableName || !foreignKey) {
// config에 저장된 값을 기본값으로 사용
setResolvedSourceTable(config.dataSource?.sourceTable || "");
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
return;
}
try {
// 현재 테이블의 컬럼 정보에서 FK 컬럼의 참조 테이블 조회
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
const fkColumn = columns.find((col: any) => (col.columnName || col.column_name || col.name) === foreignKey);
if (fkColumn) {
// column_labels의 reference_table 사용 (항상 최신값)
const refTable =
fkColumn.detailSettings?.referenceTable ||
fkColumn.reference_table ||
fkColumn.referenceTable ||
config.dataSource?.sourceTable ||
"";
const refKey =
fkColumn.detailSettings?.referenceColumn ||
fkColumn.reference_column ||
fkColumn.referenceColumn ||
config.dataSource?.referenceKey ||
"id";
console.log("🔄 [UnifiedRepeater] 엔티티 참조 정보 조회:", {
foreignKey,
resolvedSourceTable: refTable,
resolvedReferenceKey: refKey,
configSourceTable: config.dataSource?.sourceTable,
});
setResolvedSourceTable(refTable);
setResolvedReferenceKey(refKey);
} else {
// FK 컬럼을 찾지 못한 경우 config 값 사용
setResolvedSourceTable(config.dataSource?.sourceTable || "");
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
}
} catch (error) {
console.error("엔티티 참조 정보 조회 실패:", error);
// 오류 시 config 값 사용
setResolvedSourceTable(config.dataSource?.sourceTable || "");
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
}
};
resolveEntityReference();
}, [
config.dataSource?.tableName,
config.dataSource?.foreignKey,
config.dataSource?.sourceTable,
config.dataSource?.referenceKey,
isModalMode,
]);
// 소스 테이블 컬럼 라벨 로드 (modal 모드) - resolvedSourceTable 사용
// 🆕 카테고리 타입 컬럼도 함께 감지
useEffect(() => {
const loadSourceColumnLabels = async () => {
if (!isModalMode || !resolvedSourceTable) return;
try {
const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`);
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
const labels: Record<string, string> = {};
const categoryCols: string[] = [];
columns.forEach((col: any) => {
const name = col.columnName || col.column_name || col.name;
labels[name] = col.displayName || col.display_name || col.label || name;
// 🆕 카테고리 타입 컬럼 감지
const inputType = col.inputType || col.input_type || "";
if (inputType === "category") {
categoryCols.push(name);
}
});
setSourceColumnLabels(labels);
setSourceCategoryColumns(categoryCols);
} catch (error) {
console.error("소스 컬럼 라벨 로드 실패:", error);
}
};
loadSourceColumnLabels();
}, [resolvedSourceTable, isModalMode]);
// UnifiedColumnConfig → RepeaterColumnConfig 변환
// 🆕 모든 컬럼을 columns 배열의 순서대로 처리 (isSourceDisplay 플래그로 구분)
const repeaterColumns: RepeaterColumnConfig[] = useMemo(() => {
return config.columns
.filter((col: UnifiedColumnConfig) => col.visible !== false)
.map((col: UnifiedColumnConfig): RepeaterColumnConfig => {
const colInfo = currentTableColumnInfo[col.key];
const inputType = col.inputType || colInfo?.inputType || "text";
// 소스 표시 컬럼인 경우 (모달 모드에서 읽기 전용)
if (col.isSourceDisplay) {
const label = col.title || sourceColumnLabels[col.key] || col.key;
return {
field: `_display_${col.key}`,
label,
type: "text",
editable: false,
calculated: true,
width: col.width === "auto" ? undefined : col.width,
};
}
// 일반 입력 컬럼
let type: "text" | "number" | "date" | "select" | "category" = "text";
if (inputType === "number" || inputType === "decimal") type = "number";
else if (inputType === "date" || inputType === "datetime") type = "date";
else if (inputType === "code") type = "select";
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
// 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식)
// category 타입인 경우 현재 테이블명과 컬럼명을 조합
let categoryRef: string | undefined;
if (inputType === "category") {
// 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
if (tableName) {
categoryRef = `${tableName}.${col.key}`;
}
}
return {
field: col.key,
label: col.title || colInfo?.displayName || col.key,
type,
editable: col.editable !== false,
width: col.width === "auto" ? undefined : col.width,
required: false,
categoryRef, // 🆕 카테고리 참조 ID 전달
hidden: col.hidden, // 🆕 히든 처리
autoFill: col.autoFill, // 🆕 자동 입력 설정
};
});
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
// 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
useEffect(() => {
const loadCategoryLabels = async () => {
if (sourceCategoryColumns.length === 0 || data.length === 0) {
return;
}
// 데이터에서 카테고리 컬럼의 모든 고유 코드 수집
const allCodes = new Set<string>();
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;
}
try {
const response = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(allCodes),
});
if (response.data?.success && response.data.data) {
setCategoryLabelMap((prev) => ({
...prev,
...response.data.data,
}));
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
};
loadCategoryLabels();
}, [data, sourceCategoryColumns]);
// 데이터 변경 핸들러
const handleDataChange = useCallback(
(newData: any[]) => {
setData(newData);
onDataChange?.(newData);
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
setAutoWidthTrigger((prev) => prev + 1);
},
[onDataChange],
);
// 행 변경 핸들러
const handleRowChange = useCallback(
(index: number, newRow: any) => {
const newData = [...data];
newData[index] = newRow;
// 🆕 handleDataChange 대신 직접 호출 (행 변경마다 너비 조정은 불필요)
setData(newData);
onDataChange?.(newData);
},
[data, onDataChange],
);
// 행 삭제 핸들러
const handleRowDelete = useCallback(
(index: number) => {
const newData = data.filter((_, i) => i !== index);
handleDataChange(newData); // 🆕 handleDataChange 사용
// 선택 상태 업데이트
const newSelected = new Set<number>();
selectedRows.forEach((i) => {
if (i < index) newSelected.add(i);
else if (i > index) newSelected.add(i - 1);
});
setSelectedRows(newSelected);
},
[data, selectedRows, handleDataChange],
);
// 일괄 삭제 핸들러
const handleBulkDelete = useCallback(() => {
const newData = data.filter((_, index) => !selectedRows.has(index));
handleDataChange(newData); // 🆕 handleDataChange 사용
setSelectedRows(new Set());
}, [data, selectedRows, handleDataChange]);
// 행 추가 (inline 모드)
// 🆕 자동 입력 값 생성 함수 (동기 - 채번 제외)
const generateAutoFillValueSync = useCallback(
(col: any, rowIndex: number, mainFormData?: Record<string, unknown>) => {
if (!col.autoFill || col.autoFill.type === "none") return undefined;
const now = new Date();
switch (col.autoFill.type) {
case "currentDate":
return now.toISOString().split("T")[0]; // YYYY-MM-DD
case "currentDateTime":
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
case "sequence":
return rowIndex + 1; // 1부터 시작하는 순번
case "numbering":
// 채번은 별도 비동기 처리 필요
return null; // null 반환하여 비동기 처리 필요함을 표시
case "fromMainForm":
if (col.autoFill.sourceField && mainFormData) {
return mainFormData[col.autoFill.sourceField];
}
return "";
case "fixed":
return col.autoFill.fixedValue ?? "";
default:
return undefined;
}
},
[],
);
// 🆕 채번 API 호출 (비동기)
const generateNumberingCode = useCallback(async (ruleId: string): Promise<string> => {
try {
const result = await allocateNumberingCode(ruleId);
if (result.success && result.data?.generatedCode) {
return result.data.generatedCode;
}
console.error("채번 실패:", result.error);
return "";
} catch (error) {
console.error("채번 API 호출 실패:", error);
return "";
}
}, []);
// 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
const handleAddRow = useCallback(async () => {
if (isModalMode) {
setModalOpen(true);
} else {
const newRow: any = { _id: `new_${Date.now()}` };
const currentRowCount = data.length;
// 먼저 동기적 자동 입력 값 적용
for (const col of config.columns) {
const autoValue = generateAutoFillValueSync(col, currentRowCount);
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
// 채번 규칙: 즉시 API 호출
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
} else if (autoValue !== undefined) {
newRow[col.key] = autoValue;
} else {
newRow[col.key] = "";
}
}
const newData = [...data, newRow];
handleDataChange(newData);
}
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode]);
// 모달에서 항목 선택 - 비동기로 변경
const handleSelectItems = useCallback(
async (items: Record<string, unknown>[]) => {
const fkColumn = config.dataSource?.foreignKey;
const currentRowCount = data.length;
// 채번이 필요한 컬럼 찾기
const numberingColumns = config.columns.filter(
(col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId
);
const newRows = await Promise.all(
items.map(async (item, index) => {
const row: any = { _id: `new_${Date.now()}_${Math.random()}` };
// FK 값 저장 (resolvedReferenceKey 사용)
if (fkColumn && item[resolvedReferenceKey]) {
row[fkColumn] = item[resolvedReferenceKey];
}
// 모든 컬럼 처리 (순서대로)
for (const col of config.columns) {
if (col.isSourceDisplay) {
// 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용)
row[`_display_${col.key}`] = item[col.key] || "";
} else {
// 자동 입력 값 적용
const autoValue = generateAutoFillValueSync(col, currentRowCount + index);
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
// 채번 규칙: 즉시 API 호출
row[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
} else if (autoValue !== undefined) {
row[col.key] = autoValue;
} else if (row[col.key] === undefined) {
// 입력 컬럼: 빈 값으로 초기화
row[col.key] = "";
}
}
}
return row;
})
);
const newData = [...data, ...newRows];
handleDataChange(newData);
setModalOpen(false);
},
[config.dataSource?.foreignKey, resolvedReferenceKey, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode],
);
// 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링
const sourceColumns = useMemo(() => {
return config.columns
.filter((col) => col.isSourceDisplay && col.visible !== false)
.map((col) => col.key)
.filter((key) => key && key !== "none");
}, [config.columns]);
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
const dataRef = useRef(data);
dataRef.current = data;
useEffect(() => {
const handleBeforeFormSave = async (event: Event) => {
const customEvent = event as CustomEvent;
const formData = customEvent.detail?.formData;
if (!formData || !dataRef.current.length) return;
// 채번 placeholder가 있는 행들을 찾아서 실제 값으로 변환
const processedData = await Promise.all(
dataRef.current.map(async (row) => {
const newRow = { ...row };
for (const key of Object.keys(newRow)) {
const value = newRow[key];
if (typeof value === "string" && value.startsWith("__NUMBERING_RULE__")) {
// __NUMBERING_RULE__ruleId__ 형식에서 ruleId 추출
const match = value.match(/__NUMBERING_RULE__(.+)__/);
if (match) {
const ruleId = match[1];
try {
const result = await allocateNumberingCode(ruleId);
if (result.success && result.data?.generatedCode) {
newRow[key] = result.data.generatedCode;
} else {
console.error("채번 실패:", result.error);
newRow[key] = ""; // 채번 실패 시 빈 값
}
} catch (error) {
console.error("채번 API 호출 실패:", error);
newRow[key] = "";
}
}
}
}
return newRow;
}),
);
// 처리된 데이터를 formData에 추가
const fieldName = config.fieldName || "repeaterData";
formData[fieldName] = processedData;
};
window.addEventListener("beforeFormSave", handleBeforeFormSave);
return () => {
window.removeEventListener("beforeFormSave", handleBeforeFormSave);
};
}, [config.fieldName]);
// 🆕 데이터 전달 이벤트 리스너 (transferData 버튼 액션용)
useEffect(() => {
// componentDataTransfer: 특정 컴포넌트 ID로 데이터 전달
const handleComponentDataTransfer = async (event: Event) => {
const customEvent = event as CustomEvent;
const { targetComponentId, data: transferData, mappingRules, mode } = customEvent.detail || {};
// 이 컴포넌트가 대상인지 확인
if (targetComponentId !== parentId && targetComponentId !== config.fieldName) {
return;
}
console.log("📥 [UnifiedRepeater] componentDataTransfer 수신:", {
targetComponentId,
dataCount: transferData?.length,
mode,
myId: parentId,
});
if (!transferData || transferData.length === 0) {
return;
}
// 데이터 매핑 처리
const mappedData = transferData.map((item: any, index: number) => {
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
if (mappingRules && mappingRules.length > 0) {
// 매핑 규칙이 있으면 적용
mappingRules.forEach((rule: any) => {
newRow[rule.targetField] = item[rule.sourceField];
});
} else {
// 매핑 규칙 없으면 그대로 복사
Object.assign(newRow, item);
}
return newRow;
});
// mode에 따라 데이터 처리
if (mode === "replace") {
handleDataChange(mappedData);
} else if (mode === "merge") {
// 중복 제거 후 병합 (id 기준)
const existingIds = new Set(data.map((row) => row.id || row._id));
const newItems = mappedData.filter((row: any) => !existingIds.has(row.id || row._id));
handleDataChange([...data, ...newItems]);
} else {
// 기본: append
handleDataChange([...data, ...mappedData]);
}
};
// splitPanelDataTransfer: 분할 패널에서 전역 이벤트로 전달
const handleSplitPanelDataTransfer = async (event: Event) => {
const customEvent = event as CustomEvent;
const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {};
console.log("📥 [UnifiedRepeater] splitPanelDataTransfer 수신:", {
dataCount: transferData?.length,
mode,
sourcePosition,
});
if (!transferData || transferData.length === 0) {
return;
}
// 데이터 매핑 처리
const mappedData = transferData.map((item: any, index: number) => {
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
if (mappingRules && mappingRules.length > 0) {
mappingRules.forEach((rule: any) => {
newRow[rule.targetField] = item[rule.sourceField];
});
} else {
Object.assign(newRow, item);
}
return newRow;
});
// mode에 따라 데이터 처리
if (mode === "replace") {
handleDataChange(mappedData);
} else {
handleDataChange([...data, ...mappedData]);
}
};
window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
return () => {
window.removeEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
};
}, [parentId, config.fieldName, data, handleDataChange]);
return (
<div className={cn("space-y-4", className)}>
{/* 헤더 영역 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
{data.length > 0 && `${data.length}개 항목`}
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
</span>
</div>
<div className="flex gap-2">
{selectedRows.size > 0 && (
<Button variant="destructive" onClick={handleBulkDelete} className="h-8 text-xs sm:h-10 sm:text-sm">
({selectedRows.size})
</Button>
)}
<Button onClick={handleAddRow} className="h-8 text-xs sm:h-10 sm:text-sm">
<Plus className="mr-2 h-4 w-4" />
{isModalMode ? config.modal?.buttonText || "검색" : "추가"}
</Button>
</div>
</div>
{/* Repeater 테이블 */}
<RepeaterTable
columns={repeaterColumns}
data={data}
onDataChange={handleDataChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
activeDataSources={activeDataSources}
onDataSourceChange={(field, optionId) => {
setActiveDataSources((prev) => ({ ...prev, [field]: optionId }));
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={autoWidthTrigger}
categoryColumns={sourceCategoryColumns}
categoryLabelMap={categoryLabelMap}
/>
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */}
{isModalMode && (
<ItemSelectionModal
open={modalOpen}
onOpenChange={setModalOpen}
sourceTable={resolvedSourceTable}
sourceColumns={sourceColumns}
sourceSearchFields={sourceColumns}
multiSelect={config.features?.multiSelect ?? true}
modalTitle={config.modal?.title || "항목 검색"}
alreadySelected={data}
uniqueField={resolvedReferenceKey}
onSelect={handleSelectItems}
columnLabels={sourceColumnLabels}
categoryColumns={sourceCategoryColumns}
/>
)}
</div>
);
};
UnifiedRepeater.displayName = "UnifiedRepeater";
export default UnifiedRepeater;

View File

@ -0,0 +1,737 @@
"use client";
/**
* UnifiedSelect
*
*
* - dropdown: 드롭다운
* - radio: 라디오
* - check: 체크박스
* - tag: 태그
* - toggle: 토글
* - swap: 스왑 ( )
*/
import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { UnifiedSelectProps, SelectOption } from "@/types/unified-components";
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
import { apiClient } from "@/lib/api/client";
import UnifiedFormContext from "./UnifiedFormContext";
/**
*
*/
const DropdownSelect = forwardRef<HTMLButtonElement, {
options: SelectOption[];
value?: string | string[];
onChange?: (value: string | string[]) => void;
placeholder?: string;
searchable?: boolean;
multiple?: boolean;
maxSelect?: number;
allowClear?: boolean;
disabled?: boolean;
className?: string;
}>(({
options,
value,
onChange,
placeholder = "선택",
searchable,
multiple,
maxSelect,
allowClear = true,
disabled,
className
}, ref) => {
const [open, setOpen] = useState(false);
// 단일 선택 + 검색 불가능 → 기본 Select 사용
if (!searchable && !multiple) {
return (
<Select
value={typeof value === "string" ? value : value?.[0] ?? ""}
onValueChange={(v) => onChange?.(v)}
disabled={disabled}
>
<SelectTrigger ref={ref} className={cn("h-10", className)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// 검색 가능 또는 다중 선택 → Combobox 사용
const selectedValues = useMemo(() => {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}, [value]);
const selectedLabels = useMemo(() => {
return selectedValues
.map((v) => options.find((o) => o.value === v)?.label)
.filter(Boolean) as string[];
}, [selectedValues, options]);
const handleSelect = useCallback((selectedValue: string) => {
if (multiple) {
const newValues = selectedValues.includes(selectedValue)
? selectedValues.filter((v) => v !== selectedValue)
: maxSelect && selectedValues.length >= maxSelect
? selectedValues
: [...selectedValues, selectedValue];
onChange?.(newValues);
} else {
onChange?.(selectedValue);
setOpen(false);
}
}, [multiple, selectedValues, maxSelect, onChange]);
const handleClear = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onChange?.(multiple ? [] : "");
}, [multiple, onChange]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={ref}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("h-10 w-full justify-between font-normal", className)}
>
<span className="truncate flex-1 text-left">
{selectedLabels.length > 0
? multiple
? `${selectedLabels.length}개 선택됨`
: selectedLabels[0]
: placeholder}
</span>
<div className="flex items-center gap-1 ml-2">
{allowClear && selectedValues.length > 0 && (
<X
className="h-4 w-4 opacity-50 hover:opacity-100"
onClick={handleClear}
/>
)}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command
filter={(value, search) => {
// value는 CommandItem의 value (라벨)
// search는 검색어
if (!search) return 1;
const normalizedValue = value.toLowerCase();
const normalizedSearch = search.toLowerCase();
if (normalizedValue.includes(normalizedSearch)) return 1;
return 0;
}}
>
{searchable && <CommandInput placeholder="검색..." className="h-9" />}
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const displayLabel = option.label || option.value || "(빈 값)";
return (
<CommandItem
key={option.value}
value={displayLabel}
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedValues.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
{displayLabel}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
});
DropdownSelect.displayName = "DropdownSelect";
/**
*
*/
const RadioSelect = forwardRef<HTMLDivElement, {
options: SelectOption[];
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}>(({ options, value, onChange, disabled, className }, ref) => {
return (
<RadioGroup
ref={ref}
value={value ?? ""}
onValueChange={onChange}
disabled={disabled}
className={cn("flex flex-wrap gap-4", className)}
>
{options.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`radio-${option.value}`} />
<Label htmlFor={`radio-${option.value}`} className="text-sm cursor-pointer">
{option.label}
</Label>
</div>
))}
</RadioGroup>
);
});
RadioSelect.displayName = "RadioSelect";
/**
*
*/
const CheckSelect = forwardRef<HTMLDivElement, {
options: SelectOption[];
value?: string[];
onChange?: (value: string[]) => void;
maxSelect?: number;
disabled?: boolean;
className?: string;
}>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => {
const handleChange = useCallback((optionValue: string, checked: boolean) => {
if (checked) {
if (maxSelect && value.length >= maxSelect) return;
onChange?.([...value, optionValue]);
} else {
onChange?.(value.filter((v) => v !== optionValue));
}
}, [value, maxSelect, onChange]);
return (
<div ref={ref} className={cn("flex flex-wrap gap-4", className)}>
{options.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<Checkbox
id={`check-${option.value}`}
checked={value.includes(option.value)}
onCheckedChange={(checked) => handleChange(option.value, checked as boolean)}
disabled={disabled || (maxSelect && value.length >= maxSelect && !value.includes(option.value))}
/>
<Label htmlFor={`check-${option.value}`} className="text-sm cursor-pointer">
{option.label}
</Label>
</div>
))}
</div>
);
});
CheckSelect.displayName = "CheckSelect";
/**
*
*/
const TagSelect = forwardRef<HTMLDivElement, {
options: SelectOption[];
value?: string[];
onChange?: (value: string[]) => void;
maxSelect?: number;
disabled?: boolean;
className?: string;
}>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => {
const handleToggle = useCallback((optionValue: string) => {
const isSelected = value.includes(optionValue);
if (isSelected) {
onChange?.(value.filter((v) => v !== optionValue));
} else {
if (maxSelect && value.length >= maxSelect) return;
onChange?.([...value, optionValue]);
}
}, [value, maxSelect, onChange]);
return (
<div ref={ref} className={cn("flex flex-wrap gap-2", className)}>
{options.map((option) => {
const isSelected = value.includes(option.value);
return (
<Badge
key={option.value}
variant={isSelected ? "default" : "outline"}
className={cn(
"cursor-pointer transition-colors",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => !disabled && handleToggle(option.value)}
>
{option.label}
{isSelected && <X className="ml-1 h-3 w-3" />}
</Badge>
);
})}
</div>
);
});
TagSelect.displayName = "TagSelect";
/**
* (Boolean용)
*/
const ToggleSelect = forwardRef<HTMLDivElement, {
options: SelectOption[];
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}>(({ options, value, onChange, disabled, className }, ref) => {
// 토글은 2개 옵션만 지원
const [offOption, onOption] = options.length >= 2
? [options[0], options[1]]
: [{ value: "false", label: "아니오" }, { value: "true", label: "예" }];
const isOn = value === onOption.value;
return (
<div ref={ref} className={cn("flex items-center gap-3", className)}>
<span className={cn("text-sm", !isOn && "font-medium")}>{offOption.label}</span>
<Switch
checked={isOn}
onCheckedChange={(checked) => onChange?.(checked ? onOption.value : offOption.value)}
disabled={disabled}
/>
<span className={cn("text-sm", isOn && "font-medium")}>{onOption.label}</span>
</div>
);
});
ToggleSelect.displayName = "ToggleSelect";
/**
* ( )
*/
const SwapSelect = forwardRef<HTMLDivElement, {
options: SelectOption[];
value?: string[];
onChange?: (value: string[]) => void;
maxSelect?: number;
disabled?: boolean;
className?: string;
}>(({ options, value = [], onChange, disabled, className }, ref) => {
const available = useMemo(() =>
options.filter((o) => !value.includes(o.value)),
[options, value]
);
const selected = useMemo(() =>
options.filter((o) => value.includes(o.value)),
[options, value]
);
const handleMoveRight = useCallback((optionValue: string) => {
onChange?.([...value, optionValue]);
}, [value, onChange]);
const handleMoveLeft = useCallback((optionValue: string) => {
onChange?.(value.filter((v) => v !== optionValue));
}, [value, onChange]);
const handleMoveAllRight = useCallback(() => {
onChange?.(options.map((o) => o.value));
}, [options, onChange]);
const handleMoveAllLeft = useCallback(() => {
onChange?.([]);
}, [onChange]);
return (
<div ref={ref} className={cn("flex gap-2 items-stretch", className)}>
{/* 왼쪽: 선택 가능 */}
<div className="flex-1 border rounded-md">
<div className="p-2 bg-muted text-xs font-medium border-b"> </div>
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
{available.map((option) => (
<div
key={option.value}
className={cn(
"p-2 text-sm rounded cursor-pointer hover:bg-accent",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => !disabled && handleMoveRight(option.value)}
>
{option.label}
</div>
))}
{available.length === 0 && (
<div className="text-xs text-muted-foreground p-2"> </div>
)}
</div>
</div>
{/* 중앙: 이동 버튼 */}
<div className="flex flex-col gap-1 justify-center">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={handleMoveAllRight}
disabled={disabled || available.length === 0}
>
<ArrowLeftRight className="h-4 w-4 rotate-180" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={handleMoveAllLeft}
disabled={disabled || selected.length === 0}
>
<ArrowLeftRight className="h-4 w-4" />
</Button>
</div>
{/* 오른쪽: 선택됨 */}
<div className="flex-1 border rounded-md">
<div className="p-2 bg-primary/10 text-xs font-medium border-b"></div>
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
{selected.map((option) => (
<div
key={option.value}
className={cn(
"p-2 text-sm rounded cursor-pointer hover:bg-accent flex justify-between items-center",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => !disabled && handleMoveLeft(option.value)}
>
<span>{option.label}</span>
<X className="h-3 w-3 opacity-50" />
</div>
))}
{selected.length === 0 && (
<div className="text-xs text-muted-foreground p-2"> </div>
)}
</div>
</div>
</div>
);
});
SwapSelect.displayName = "SwapSelect";
/**
* UnifiedSelect
*/
export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
(props, ref) => {
const {
id,
label,
required,
readonly,
disabled,
style,
size,
config: configProp,
value,
onChange,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
const [options, setOptions] = useState<SelectOption[]>(config.options || []);
const [loading, setLoading] = useState(false);
const [optionsLoaded, setOptionsLoaded] = useState(false);
// 옵션 로딩에 필요한 값들만 추출 (객체 참조 대신 원시값 사용)
// category 소스는 code로 자동 변환 (카테고리 → 공통코드 통합)
const rawSource = config.source;
const categoryTable = (config as any).categoryTable;
const categoryColumn = (config as any).categoryColumn;
// category 소스인 경우 code로 변환하고 codeGroup을 자동 생성
const source = rawSource === "category" ? "code" : rawSource;
const codeGroup = rawSource === "category" && categoryTable && categoryColumn
? `${categoryTable.toUpperCase()}_${categoryColumn.toUpperCase()}`
: config.codeGroup;
const entityTable = config.entityTable;
const entityValueColumn = config.entityValueColumn || config.entityValueField;
const entityLabelColumn = config.entityLabelColumn || config.entityLabelField;
const table = config.table;
const valueColumn = config.valueColumn;
const labelColumn = config.labelColumn;
const apiEndpoint = config.apiEndpoint;
const staticOptions = config.options;
// 계층 코드 연쇄 선택 관련
const hierarchical = config.hierarchical;
const parentField = config.parentField;
// FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null)
const formContext = useContext(UnifiedFormContext);
// 부모 필드의 값 계산
const parentValue = useMemo(() => {
if (!hierarchical || !parentField) return null;
// FormContext가 있으면 거기서 값 가져오기
if (formContext) {
const val = formContext.getValue(parentField);
return val as string | null;
}
return null;
}, [hierarchical, parentField, formContext]);
// 데이터 소스에 따른 옵션 로딩 (원시값 의존성만 사용)
useEffect(() => {
// 계층 구조인 경우 부모 값이 변경되면 다시 로드
if (hierarchical && source === "code") {
setOptionsLoaded(false);
}
}, [parentValue, hierarchical, source]);
useEffect(() => {
// 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외)
if (optionsLoaded && source !== "static") {
return;
}
const loadOptions = async () => {
if (source === "static") {
setOptions(staticOptions || []);
setOptionsLoaded(true);
return;
}
setLoading(true);
try {
let fetchedOptions: SelectOption[] = [];
if (source === "code" && codeGroup) {
// 계층 구조 사용 시 자식 코드만 로드
if (hierarchical) {
const params = new URLSearchParams();
if (parentValue) {
params.append("parentCodeValue", parentValue);
}
const queryString = params.toString();
const url = `/common-codes/categories/${codeGroup}/children${queryString ? `?${queryString}` : ""}`;
const response = await apiClient.get(url);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string; hasChildren: boolean }) => ({
value: item.value,
label: item.label,
}));
}
} else {
// 일반 공통코드에서 로드 (올바른 API 경로: /common-codes/categories/:categoryCode/options)
const response = await apiClient.get(`/common-codes/categories/${codeGroup}/options`);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
value: item.value,
label: item.label,
}));
}
}
} else if (source === "db" && table) {
// DB 테이블에서 로드
const response = await apiClient.get(`/entity/${table}/options`, {
params: {
value: valueColumn || "id",
label: labelColumn || "name",
},
});
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data;
}
} else if (source === "entity" && entityTable) {
// 엔티티(참조 테이블)에서 로드
const valueCol = entityValueColumn || "id";
const labelCol = entityLabelColumn || "name";
const response = await apiClient.get(`/entity/${entityTable}/options`, {
params: {
value: valueCol,
label: labelCol,
},
});
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data;
}
} else if (source === "api" && apiEndpoint) {
// 외부 API에서 로드
const response = await apiClient.get(apiEndpoint);
const data = response.data;
if (Array.isArray(data)) {
fetchedOptions = data;
}
}
setOptions(fetchedOptions);
setOptionsLoaded(true);
} catch (error) {
console.error("옵션 로딩 실패:", error);
setOptions([]);
} finally {
setLoading(false);
}
};
loadOptions();
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]);
// 모드별 컴포넌트 렌더링
const renderSelect = () => {
if (loading) {
return <div className="h-10 flex items-center text-sm text-muted-foreground"> ...</div>;
}
const isDisabled = disabled || readonly;
switch (config.mode) {
case "dropdown":
return (
<DropdownSelect
options={options}
value={value}
onChange={onChange}
placeholder="선택"
searchable={config.searchable}
multiple={config.multiple}
maxSelect={config.maxSelect}
allowClear={config.allowClear}
disabled={isDisabled}
/>
);
case "radio":
return (
<RadioSelect
options={options}
value={typeof value === "string" ? value : value?.[0]}
onChange={(v) => onChange?.(v)}
disabled={isDisabled}
/>
);
case "check":
return (
<CheckSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
);
case "tag":
return (
<TagSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
);
case "toggle":
return (
<ToggleSelect
options={options}
value={typeof value === "string" ? value : value?.[0]}
onChange={(v) => onChange?.(v)}
disabled={isDisabled}
/>
);
case "swap":
return (
<SwapSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
);
default:
return (
<DropdownSelect
options={options}
value={value}
onChange={onChange}
disabled={isDisabled}
/>
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="text-sm font-medium flex-shrink-0"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div className="flex-1 min-h-0">
{renderSelect()}
</div>
</div>
);
}
);
UnifiedSelect.displayName = "UnifiedSelect";
export default UnifiedSelect;

View File

@ -0,0 +1,458 @@
"use client";
/**
* UnifiedBiz
* .
*/
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { tableTypeApi } from "@/lib/api/screen";
interface UnifiedBizConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
interface TableOption {
tableName: string;
displayName: string;
}
interface ColumnOption {
columnName: string;
displayName: string;
}
export const UnifiedBizConfigPanel: React.FC<UnifiedBizConfigPanelProps> = ({
config,
onChange,
}) => {
// 테이블 목록
const [tables, setTables] = useState<TableOption[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
// 컬럼 목록 (소스/대상/관련 테이블용)
const [sourceColumns, setSourceColumns] = useState<ColumnOption[]>([]);
const [targetColumns, setTargetColumns] = useState<ColumnOption[]>([]);
const [relatedColumns, setRelatedColumns] = useState<ColumnOption[]>([]);
const [categoryColumns, setCategoryColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const data = await tableTypeApi.getTables();
setTables(data.map(t => ({
tableName: t.tableName,
displayName: t.displayName || t.tableName
})));
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
// 소스 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.sourceTable) {
setSourceColumns([]);
return;
}
try {
const data = await tableTypeApi.getColumns(config.sourceTable);
setSourceColumns(data.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnName || c.column_name
})));
} catch (error) {
console.error("소스 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [config.sourceTable]);
// 대상 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.targetTable) {
setTargetColumns([]);
return;
}
try {
const data = await tableTypeApi.getColumns(config.targetTable);
setTargetColumns(data.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnName || c.column_name
})));
} catch (error) {
console.error("대상 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [config.targetTable]);
// 관련 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.relatedTable) {
setRelatedColumns([]);
return;
}
try {
const data = await tableTypeApi.getColumns(config.relatedTable);
setRelatedColumns(data.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnName || c.column_name
})));
} catch (error) {
console.error("관련 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [config.relatedTable]);
// 카테고리 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.tableName) {
setCategoryColumns([]);
return;
}
setLoadingColumns(true);
try {
const data = await tableTypeApi.getColumns(config.tableName);
setCategoryColumns(data.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnName || c.column_name
})));
} catch (error) {
console.error("카테고리 컬럼 로드 실패:", error);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [config.tableName]);
return (
<div className="space-y-4">
{/* 비즈니스 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.bizType || config.type || "flow"}
onValueChange={(value) => updateConfig("bizType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="flow"></SelectItem>
<SelectItem value="rack"> </SelectItem>
<SelectItem value="map"></SelectItem>
<SelectItem value="numbering"> </SelectItem>
<SelectItem value="category"></SelectItem>
<SelectItem value="data-mapping"> </SelectItem>
<SelectItem value="related-data"> </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 플로우 설정 */}
{config.bizType === "flow" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> ID</Label>
<Input
type="number"
value={config.flowId || ""}
onChange={(e) => updateConfig("flowId", e.target.value ? Number(e.target.value) : undefined)}
placeholder="플로우 ID"
className="h-8 text-xs"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="editable"
checked={config.editable || false}
onCheckedChange={(checked) => updateConfig("editable", checked)}
/>
<label htmlFor="editable" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showMinimap"
checked={config.showMinimap || false}
onCheckedChange={(checked) => updateConfig("showMinimap", checked)}
/>
<label htmlFor="showMinimap" className="text-xs"> </label>
</div>
</div>
)}
{/* 랙 구조 설정 */}
{config.bizType === "rack" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="number"
value={config.rows || ""}
onChange={(e) => updateConfig("rows", e.target.value ? Number(e.target.value) : undefined)}
placeholder="5"
min="1"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="number"
value={config.columns || ""}
onChange={(e) => updateConfig("columns", e.target.value ? Number(e.target.value) : undefined)}
placeholder="10"
min="1"
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showLabels"
checked={config.showLabels !== false}
onCheckedChange={(checked) => updateConfig("showLabels", checked)}
/>
<label htmlFor="showLabels" className="text-xs"> </label>
</div>
</div>
)}
{/* 채번 규칙 설정 */}
{config.bizType === "numbering" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> ID</Label>
<Input
type="number"
value={config.ruleId || ""}
onChange={(e) => updateConfig("ruleId", e.target.value ? Number(e.target.value) : undefined)}
placeholder="규칙 ID"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
value={config.prefix || ""}
onChange={(e) => updateConfig("prefix", e.target.value)}
placeholder="예: INV-"
className="h-8 text-xs"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="autoGenerate"
checked={config.autoGenerate !== false}
onCheckedChange={(checked) => updateConfig("autoGenerate", checked)}
/>
<label htmlFor="autoGenerate" className="text-xs"> </label>
</div>
</div>
)}
{/* 카테고리 설정 */}
{config.bizType === "category" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.tableName || ""}
onValueChange={(value) => {
updateConfig("tableName", value);
updateConfig("columnName", "");
}}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{config.tableName && (
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Select
value={config.columnName || ""}
onValueChange={(value) => updateConfig("columnName", value)}
disabled={loadingColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "컬럼 선택"} />
</SelectTrigger>
<SelectContent>
{categoryColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{/* 데이터 매핑 설정 */}
{config.bizType === "data-mapping" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.sourceTable || ""}
onValueChange={(value) => updateConfig("sourceTable", value)}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.targetTable || ""}
onValueChange={(value) => updateConfig("targetTable", value)}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* 관련 데이터 설정 */}
{config.bizType === "related-data" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.relatedTable || ""}
onValueChange={(value) => {
updateConfig("relatedTable", value);
updateConfig("linkColumn", "");
}}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{config.relatedTable && (
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.linkColumn || ""}
onValueChange={(value) => updateConfig("linkColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{relatedColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
value={config.buttonText || ""}
onChange={(e) => updateConfig("buttonText", e.target.value)}
placeholder="관련 데이터 보기"
className="h-8 text-xs"
/>
</div>
</div>
)}
</div>
);
};
UnifiedBizConfigPanel.displayName = "UnifiedBizConfigPanel";
export default UnifiedBizConfigPanel;

View File

@ -0,0 +1,149 @@
"use client";
/**
* UnifiedDate
* .
*/
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
interface UnifiedDateConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export const UnifiedDateConfigPanel: React.FC<UnifiedDateConfigPanelProps> = ({
config,
onChange,
}) => {
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
return (
<div className="space-y-4">
{/* 날짜 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.dateType || config.type || "date"}
onValueChange={(value) => updateConfig("dateType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date"></SelectItem>
<SelectItem value="time"></SelectItem>
<SelectItem value="datetime">+</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 표시 형식 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.format || "YYYY-MM-DD"}
onValueChange={(value) => updateConfig("format", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="형식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="YYYY-MM-DD">YYYY-MM-DD</SelectItem>
<SelectItem value="YYYY/MM/DD">YYYY/MM/DD</SelectItem>
<SelectItem value="DD/MM/YYYY">DD/MM/YYYY</SelectItem>
<SelectItem value="MM/DD/YYYY">MM/DD/YYYY</SelectItem>
<SelectItem value="YYYY년 MM월 DD일">YYYY년 MM월 DD일</SelectItem>
{(config.dateType === "time" || config.dateType === "datetime") && (
<>
<SelectItem value="HH:mm">HH:mm</SelectItem>
<SelectItem value="HH:mm:ss">HH:mm:ss</SelectItem>
<SelectItem value="YYYY-MM-DD HH:mm">YYYY-MM-DD HH:mm</SelectItem>
<SelectItem value="YYYY-MM-DD HH:mm:ss">YYYY-MM-DD HH:mm:ss</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
<Separator />
{/* 날짜 범위 제한 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="date"
value={config.minDate || ""}
onChange={(e) => updateConfig("minDate", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="date"
value={config.maxDate || ""}
onChange={(e) => updateConfig("maxDate", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
</div>
<Separator />
{/* 추가 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="range"
checked={config.range || false}
onCheckedChange={(checked) => updateConfig("range", checked)}
/>
<label htmlFor="range" className="text-xs"> (~)</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showToday"
checked={config.showToday !== false}
onCheckedChange={(checked) => updateConfig("showToday", checked)}
/>
<label htmlFor="showToday" className="text-xs"> </label>
</div>
{(config.dateType === "datetime" || config.dateType === "time") && (
<div className="flex items-center space-x-2">
<Checkbox
id="showSeconds"
checked={config.showSeconds || false}
onCheckedChange={(checked) => updateConfig("showSeconds", checked)}
/>
<label htmlFor="showSeconds" className="text-xs"> </label>
</div>
)}
</div>
</div>
);
};
UnifiedDateConfigPanel.displayName = "UnifiedDateConfigPanel";
export default UnifiedDateConfigPanel;

View File

@ -0,0 +1,222 @@
"use client";
/**
* UnifiedGroup
* .
*/
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
interface UnifiedGroupConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export const UnifiedGroupConfigPanel: React.FC<UnifiedGroupConfigPanelProps> = ({
config,
onChange,
}) => {
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 탭 관리
const tabs = config.tabs || [];
const addTab = () => {
const newTabs = [...tabs, { id: `tab-${Date.now()}`, label: "새 탭", content: "" }];
updateConfig("tabs", newTabs);
};
const updateTab = (index: number, field: string, value: string) => {
const newTabs = [...tabs];
newTabs[index] = { ...newTabs[index], [field]: value };
updateConfig("tabs", newTabs);
};
const removeTab = (index: number) => {
const newTabs = tabs.filter((_: any, i: number) => i !== index);
updateConfig("tabs", newTabs);
};
return (
<div className="space-y-4">
{/* 그룹 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.groupType || config.type || "section"}
onValueChange={(value) => updateConfig("groupType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="section"></SelectItem>
<SelectItem value="tabs"></SelectItem>
<SelectItem value="accordion"></SelectItem>
<SelectItem value="card"> </SelectItem>
<SelectItem value="modal"></SelectItem>
<SelectItem value="form-modal"> </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 제목 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
value={config.title || ""}
onChange={(e) => updateConfig("title", e.target.value)}
placeholder="그룹 제목"
className="h-8 text-xs"
/>
</div>
{/* 탭 설정 */}
{config.groupType === "tabs" && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addTab}
className="h-6 px-2 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{tabs.map((tab: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<Input
value={tab.id || ""}
onChange={(e) => updateTab(index, "id", e.target.value)}
placeholder="ID"
className="h-7 text-xs flex-1"
/>
<Input
value={tab.label || ""}
onChange={(e) => updateTab(index, "label", e.target.value)}
placeholder="라벨"
className="h-7 text-xs flex-1"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeTab(index)}
className="h-7 w-7 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{tabs.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
</p>
)}
</div>
</div>
)}
{/* 섹션/아코디언 옵션 */}
{(config.groupType === "section" || config.groupType === "accordion" || !config.groupType) && (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="collapsible"
checked={config.collapsible || false}
onCheckedChange={(checked) => updateConfig("collapsible", checked)}
/>
<label htmlFor="collapsible" className="text-xs">/ </label>
</div>
{config.collapsible && (
<div className="flex items-center space-x-2">
<Checkbox
id="defaultOpen"
checked={config.defaultOpen !== false}
onCheckedChange={(checked) => updateConfig("defaultOpen", checked)}
/>
<label htmlFor="defaultOpen" className="text-xs"> </label>
</div>
)}
</div>
)}
{/* 모달 옵션 */}
{(config.groupType === "modal" || config.groupType === "form-modal") && (
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.modalSize || "md"}
onValueChange={(value) => updateConfig("modalSize", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (400px)</SelectItem>
<SelectItem value="md"> (600px)</SelectItem>
<SelectItem value="lg"> (800px)</SelectItem>
<SelectItem value="xl"> (1000px)</SelectItem>
<SelectItem value="full"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="closeable"
checked={config.closeable !== false}
onCheckedChange={(checked) => updateConfig("closeable", checked)}
/>
<label htmlFor="closeable" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="backdrop"
checked={config.backdrop !== false}
onCheckedChange={(checked) => updateConfig("backdrop", checked)}
/>
<label htmlFor="backdrop" className="text-xs"> </label>
</div>
</div>
)}
{/* 헤더 표시 여부 */}
<Separator />
<div className="flex items-center space-x-2">
<Checkbox
id="showHeader"
checked={config.showHeader !== false}
onCheckedChange={(checked) => updateConfig("showHeader", checked)}
/>
<label htmlFor="showHeader" className="text-xs"> </label>
</div>
</div>
);
};
UnifiedGroupConfigPanel.displayName = "UnifiedGroupConfigPanel";
export default UnifiedGroupConfigPanel;

View File

@ -0,0 +1,410 @@
"use client";
/**
* UnifiedHierarchy
* .
*/
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { tableTypeApi } from "@/lib/api/screen";
interface UnifiedHierarchyConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
interface TableOption {
tableName: string;
displayName: string;
}
interface ColumnOption {
columnName: string;
displayName: string;
}
export const UnifiedHierarchyConfigPanel: React.FC<UnifiedHierarchyConfigPanelProps> = ({
config,
onChange,
}) => {
// 테이블 목록
const [tables, setTables] = useState<TableOption[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
// 컬럼 목록
const [columns, setColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const data = await tableTypeApi.getTables();
setTables(data.map(t => ({
tableName: t.tableName,
displayName: t.displayName || t.tableName
})));
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
// 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.tableName) {
setColumns([]);
return;
}
setLoadingColumns(true);
try {
const data = await tableTypeApi.getColumns(config.tableName);
setColumns(data.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnName || c.column_name
})));
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [config.tableName]);
return (
<div className="space-y-4">
{/* 계층 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.hierarchyType || config.type || "tree"}
onValueChange={(value) => updateConfig("hierarchyType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="tree"></SelectItem>
<SelectItem value="org-chart"></SelectItem>
<SelectItem value="bom">BOM (Bill of Materials)</SelectItem>
<SelectItem value="cascading"> </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 뷰 모드 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.viewMode || "tree"}
onValueChange={(value) => updateConfig("viewMode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="방식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="tree"></SelectItem>
<SelectItem value="table"></SelectItem>
<SelectItem value="chart"></SelectItem>
<SelectItem value="cascading"> </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 데이터 소스 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.dataSource || "static"}
onValueChange={(value) => updateConfig("dataSource", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> </SelectItem>
<SelectItem value="db"></SelectItem>
<SelectItem value="api">API</SelectItem>
</SelectContent>
</Select>
</div>
{/* DB 설정 */}
{config.dataSource === "db" && (
<div className="space-y-3">
{/* 테이블 선택 */}
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Select
value={config.tableName || ""}
onValueChange={(value) => {
updateConfig("tableName", value);
// 테이블 변경 시 컬럼 초기화
updateConfig("idColumn", "");
updateConfig("parentIdColumn", "");
updateConfig("labelColumn", "");
}}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 컬럼 선택 */}
{config.tableName && (
<>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground">ID </Label>
<Select
value={config.idColumn || ""}
onValueChange={(value) => updateConfig("idColumn", value)}
disabled={loadingColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> ID </Label>
<Select
value={config.parentIdColumn || ""}
onValueChange={(value) => updateConfig("parentIdColumn", value)}
disabled={loadingColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.labelColumn || ""}
onValueChange={(value) => updateConfig("labelColumn", value)}
disabled={loadingColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</div>
)}
{/* API 설정 */}
{config.dataSource === "api" && (
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground">API </Label>
<Input
value={config.apiEndpoint || ""}
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
placeholder="/api/hierarchy"
className="h-8 text-xs"
/>
</div>
)}
<Separator />
{/* 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
type="number"
value={config.maxLevel || ""}
onChange={(e) => updateConfig("maxLevel", e.target.value ? Number(e.target.value) : undefined)}
placeholder="제한 없음"
min="1"
className="h-8 text-xs"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="draggable"
checked={config.draggable || false}
onCheckedChange={(checked) => updateConfig("draggable", checked)}
/>
<label htmlFor="draggable" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="selectable"
checked={config.selectable !== false}
onCheckedChange={(checked) => updateConfig("selectable", checked)}
/>
<label htmlFor="selectable" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="multiSelect"
checked={config.multiSelect || false}
onCheckedChange={(checked) => updateConfig("multiSelect", checked)}
/>
<label htmlFor="multiSelect" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showCheckbox"
checked={config.showCheckbox || false}
onCheckedChange={(checked) => updateConfig("showCheckbox", checked)}
/>
<label htmlFor="showCheckbox" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="expandAll"
checked={config.expandAll || false}
onCheckedChange={(checked) => updateConfig("expandAll", checked)}
/>
<label htmlFor="expandAll" className="text-xs"> </label>
</div>
</div>
{/* BOM 전용 설정 */}
{config.hierarchyType === "bom" && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium">BOM </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="showQuantity"
checked={config.showQuantity !== false}
onCheckedChange={(checked) => updateConfig("showQuantity", checked)}
/>
<label htmlFor="showQuantity" className="text-xs"> </label>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.quantityColumn || ""}
onValueChange={(value) => updateConfig("quantityColumn", value)}
disabled={loadingColumns || !config.tableName}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</>
)}
{/* 연쇄 선택박스 전용 설정 */}
{config.hierarchyType === "cascading" && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.parentField || ""}
onValueChange={(value) => updateConfig("parentField", value)}
disabled={loadingColumns || !config.tableName}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="clearOnParentChange"
checked={config.clearOnParentChange !== false}
onCheckedChange={(checked) => updateConfig("clearOnParentChange", checked)}
/>
<label htmlFor="clearOnParentChange" className="text-xs"> </label>
</div>
</div>
</>
)}
</div>
);
};
UnifiedHierarchyConfigPanel.displayName = "UnifiedHierarchyConfigPanel";
export default UnifiedHierarchyConfigPanel;

View File

@ -5,23 +5,99 @@
* .
*/
import React from "react";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
interface UnifiedInputConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
menuObjid?: number; // 메뉴 OBJID (채번 규칙 필터링용)
}
export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = ({ config, onChange }) => {
export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = ({ config, onChange, menuObjid }) => {
// 채번 규칙 목록 상태
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
// 부모 메뉴 목록 상태 (채번규칙 사용을 위한 선택)
const [parentMenus, setParentMenus] = useState<any[]>([]);
const [loadingMenus, setLoadingMenus] = useState(false);
// 선택된 메뉴 OBJID
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(() => {
return config.autoGeneration?.selectedMenuObjid || menuObjid;
});
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 부모 메뉴 목록 로드 (사용자 메뉴의 레벨 2만)
useEffect(() => {
const loadMenus = async () => {
setLoadingMenus(true);
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get("/admin/menus");
if (response.data.success && response.data.data) {
const allMenus = response.data.data;
// 사용자 메뉴(menu_type='1')의 레벨 2만 필터링
const level2UserMenus = allMenus.filter((menu: any) =>
menu.menu_type === '1' && menu.lev === 2
);
setParentMenus(level2UserMenus);
}
} catch (error) {
console.error("부모 메뉴 로드 실패:", error);
} finally {
setLoadingMenus(false);
}
};
loadMenus();
}, []);
// 채번 규칙 목록 로드 (선택된 메뉴 기준)
useEffect(() => {
const loadRules = async () => {
if (config.autoGeneration?.type !== "numbering_rule") {
return;
}
if (!selectedMenuObjid) {
setNumberingRules([]);
return;
}
setLoadingRules(true);
try {
const response = await getAvailableNumberingRules(selectedMenuObjid);
if (response.success && response.data) {
setNumberingRules(response.data);
}
} catch (error) {
console.error("채번 규칙 목록 로드 실패:", error);
setNumberingRules([]);
} finally {
setLoadingRules(false);
}
};
loadRules();
}, [selectedMenuObjid, config.autoGeneration?.type]);
return (
<div className="space-y-4">
{/* 입력 타입 */}
@ -143,6 +219,229 @@ export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = (
/>
<p className="text-muted-foreground text-[10px]"># = , A = , * = </p>
</div>
<Separator />
{/* 자동생성 기능 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="autoGenerationEnabled"
checked={config.autoGeneration?.enabled || false}
onCheckedChange={(checked) => {
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
updateConfig("autoGeneration", {
...currentConfig,
enabled: checked as boolean,
});
}}
/>
<Label htmlFor="autoGenerationEnabled" className="text-xs font-medium cursor-pointer">
</Label>
</div>
{/* 자동생성 타입 선택 */}
{config.autoGeneration?.enabled && (
<div className="space-y-3 pl-6">
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.autoGeneration?.type || "none"}
onValueChange={(value: AutoGenerationType) => {
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
updateConfig("autoGeneration", {
...currentConfig,
type: value,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="자동생성 타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="uuid">UUID </SelectItem>
<SelectItem value="current_user"> ID</SelectItem>
<SelectItem value="current_time"> </SelectItem>
<SelectItem value="sequence"> </SelectItem>
<SelectItem value="numbering_rule"> </SelectItem>
<SelectItem value="random_string"> </SelectItem>
<SelectItem value="random_number"> </SelectItem>
<SelectItem value="company_code"> </SelectItem>
<SelectItem value="department"> </SelectItem>
</SelectContent>
</Select>
{/* 선택된 타입 설명 */}
{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (
<p className="text-muted-foreground text-[10px]">
{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}
</p>
)}
</div>
{/* 채번 규칙 선택 */}
{config.autoGeneration?.type === "numbering_rule" && (
<>
{/* 부모 메뉴 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium">
<span className="text-destructive">*</span>
</Label>
<Select
value={selectedMenuObjid?.toString() || ""}
onValueChange={(value) => {
const menuId = parseInt(value);
setSelectedMenuObjid(menuId);
updateConfig("autoGeneration", {
...config.autoGeneration,
selectedMenuObjid: menuId,
});
}}
disabled={loadingMenus}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingMenus ? "메뉴 로딩 중..." : "채번규칙을 사용할 메뉴 선택"} />
</SelectTrigger>
<SelectContent>
{parentMenus.length === 0 ? (
<SelectItem value="no-menus" disabled>
</SelectItem>
) : (
parentMenus.map((menu) => (
<SelectItem key={menu.objid} value={menu.objid.toString()}>
{menu.menu_name_kor}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* 채번 규칙 선택 */}
{selectedMenuObjid ? (
<div className="space-y-2">
<Label className="text-xs font-medium">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.autoGeneration?.options?.numberingRuleId || ""}
onValueChange={(value) => {
updateConfig("autoGeneration", {
...config.autoGeneration,
options: {
...config.autoGeneration?.options,
numberingRuleId: value,
},
});
}}
disabled={loadingRules}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingRules ? "규칙 로딩 중..." : "채번 규칙 선택"} />
</SelectTrigger>
<SelectContent>
{numberingRules.length === 0 ? (
<SelectItem value="no-rules" disabled>
</SelectItem>
) : (
numberingRules.map((rule) => (
<SelectItem key={rule.ruleId} value={rule.ruleId}>
{rule.ruleName}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
) : (
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
</div>
)}
</>
)}
{/* 자동생성 옵션 (랜덤/순차용) */}
{config.autoGeneration?.type &&
["random_string", "random_number", "sequence"].includes(config.autoGeneration.type) && (
<div className="space-y-2">
{/* 길이 설정 */}
{["random_string", "random_number"].includes(config.autoGeneration.type) && (
<div className="space-y-1">
<Label className="text-xs font-medium"></Label>
<Input
type="number"
min="1"
max="50"
value={config.autoGeneration?.options?.length || 8}
onChange={(e) => {
updateConfig("autoGeneration", {
...config.autoGeneration,
options: {
...config.autoGeneration?.options,
length: parseInt(e.target.value) || 8,
},
});
}}
className="h-8 text-xs"
/>
</div>
)}
{/* 접두사 */}
<div className="space-y-1">
<Label className="text-xs font-medium"></Label>
<Input
value={config.autoGeneration?.options?.prefix || ""}
onChange={(e) => {
updateConfig("autoGeneration", {
...config.autoGeneration,
options: {
...config.autoGeneration?.options,
prefix: e.target.value,
},
});
}}
placeholder="예: INV-"
className="h-8 text-xs"
/>
</div>
{/* 접미사 */}
<div className="space-y-1">
<Label className="text-xs font-medium"></Label>
<Input
value={config.autoGeneration?.options?.suffix || ""}
onChange={(e) => {
updateConfig("autoGeneration", {
...config.autoGeneration,
options: {
...config.autoGeneration?.options,
suffix: e.target.value,
},
});
}}
className="h-8 text-xs"
/>
</div>
{/* 미리보기 */}
<div className="space-y-1">
<Label className="text-xs font-medium"></Label>
<div className="rounded border bg-muted p-2 text-xs font-mono">
{AutoGenerationUtils.generatePreviewValue(config.autoGeneration)}
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,256 @@
"use client";
/**
* UnifiedLayout
* .
*/
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
interface UnifiedLayoutConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export const UnifiedLayoutConfigPanel: React.FC<UnifiedLayoutConfigPanelProps> = ({
config,
onChange,
}) => {
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
return (
<div className="space-y-4">
{/* 레이아웃 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.layoutType || config.type || "grid"}
onValueChange={(value) => updateConfig("layoutType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="grid"></SelectItem>
<SelectItem value="split"> </SelectItem>
<SelectItem value="flex"></SelectItem>
<SelectItem value="divider"></SelectItem>
<SelectItem value="screen-embed"> </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 그리드 설정 */}
{(config.layoutType === "grid" || !config.layoutType) && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="use12Column"
checked={config.use12Column !== false}
onCheckedChange={(checked) => updateConfig("use12Column", checked)}
/>
<label htmlFor="use12Column" className="text-xs">12 </label>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={String(config.columns || 12)}
onValueChange={(value) => updateConfig("columns", Number(value))}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="6">6</SelectItem>
<SelectItem value="12">12</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (px)</Label>
<Input
value={config.gap || "16"}
onChange={(e) => updateConfig("gap", e.target.value)}
placeholder="16"
className="h-8 text-xs"
/>
</div>
</div>
</div>
)}
{/* 분할 패널 설정 */}
{config.layoutType === "split" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.direction || "horizontal"}
onValueChange={(value) => updateConfig("direction", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"></SelectItem>
<SelectItem value="vertical"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (%)</Label>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
value={config.splitRatio?.[0] || 50}
onChange={(e) => updateConfig("splitRatio", [Number(e.target.value), 100 - Number(e.target.value)])}
placeholder="50"
min="10"
max="90"
className="h-8 text-xs"
/>
<Input
type="number"
value={config.splitRatio?.[1] || 50}
disabled
className="h-8 text-xs bg-muted"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="resizable"
checked={config.resizable !== false}
onCheckedChange={(checked) => updateConfig("resizable", checked)}
/>
<label htmlFor="resizable" className="text-xs"> </label>
</div>
</div>
)}
{/* 플렉스 설정 */}
{config.layoutType === "flex" && (
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Select
value={config.direction || "row"}
onValueChange={(value) => updateConfig("direction", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="row"></SelectItem>
<SelectItem value="column"></SelectItem>
<SelectItem value="row-reverse"> ()</SelectItem>
<SelectItem value="column-reverse"> ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Select
value={config.justifyContent || "flex-start"}
onValueChange={(value) => updateConfig("justifyContent", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="flex-start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="flex-end"></SelectItem>
<SelectItem value="space-between"> </SelectItem>
<SelectItem value="space-around"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.alignItems || "stretch"}
onValueChange={(value) => updateConfig("alignItems", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="flex-start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="flex-end"></SelectItem>
<SelectItem value="stretch"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (px)</Label>
<Input
value={config.gap || "16"}
onChange={(e) => updateConfig("gap", e.target.value)}
placeholder="16"
className="h-8 text-xs"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="wrap"
checked={config.wrap || false}
onCheckedChange={(checked) => updateConfig("wrap", checked)}
/>
<label htmlFor="wrap" className="text-xs"> </label>
</div>
</div>
)}
{/* 화면 임베드 설정 */}
{config.layoutType === "screen-embed" && (
<div className="space-y-2">
<Label className="text-xs font-medium"> ID</Label>
<Input
type="number"
value={config.screenId || ""}
onChange={(e) => updateConfig("screenId", e.target.value ? Number(e.target.value) : undefined)}
placeholder="화면 ID"
className="h-8 text-xs"
/>
</div>
)}
</div>
);
};
UnifiedLayoutConfigPanel.displayName = "UnifiedLayoutConfigPanel";
export default UnifiedLayoutConfigPanel;

View File

@ -0,0 +1,167 @@
"use client";
/**
* UnifiedList
* TableListConfigPanel을 .
* card-display .
*/
import React, { useMemo } from "react";
import { TableListConfigPanel } from "@/lib/registry/components/table-list/TableListConfigPanel";
import { TableListConfig } from "@/lib/registry/components/table-list/types";
interface UnifiedListConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
/** 현재 화면의 테이블명 */
currentTableName?: string;
}
/**
* UnifiedList
* TableListConfigPanel과
*/
export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
config,
onChange,
currentTableName,
}) => {
// UnifiedList config를 TableListConfig 형식으로 변환
const tableListConfig: TableListConfig = useMemo(() => {
// 컬럼 형식 변환: UnifiedList columns -> TableList columns
const columns = (config.columns || []).map((col: any, index: number) => ({
columnName: col.key || col.columnName || col.field || "",
displayName: col.title || col.header || col.displayName || col.key || col.columnName || col.field || "",
width: col.width ? parseInt(col.width, 10) : undefined,
visible: col.visible !== false,
sortable: col.sortable !== false,
searchable: col.searchable !== false,
align: col.align || "left",
order: index,
isEntityJoin: col.isJoinColumn || col.isEntityJoin || false,
thousandSeparator: col.thousandSeparator,
editable: col.editable,
entityDisplayConfig: col.entityDisplayConfig,
}));
return {
selectedTable: config.tableName || config.dataSource?.table || currentTableName,
tableName: config.tableName || config.dataSource?.table || currentTableName,
columns,
useCustomTable: config.useCustomTable,
customTableName: config.customTableName,
isReadOnly: config.isReadOnly !== false, // UnifiedList는 기본적으로 읽기 전용
displayMode: "table", // 테이블 모드 고정 (카드는 card-display 컴포넌트 사용)
pagination: config.pagination !== false ? {
enabled: true,
pageSize: config.pageSize || 10,
position: "bottom",
showPageSize: true,
pageSizeOptions: [5, 10, 20, 50, 100],
} : {
enabled: false,
pageSize: 10,
position: "bottom",
showPageSize: false,
pageSizeOptions: [10],
},
filter: config.filter,
dataFilter: config.dataFilter,
checkbox: {
enabled: true,
position: "left",
showHeader: true,
},
height: "auto",
autoWidth: true,
stickyHeader: true,
autoLoad: true,
horizontalScroll: {
enabled: true,
minColumnWidth: 100,
maxColumnWidth: 300,
},
};
}, [config, currentTableName]);
// TableListConfig 변경을 UnifiedList config 형식으로 변환
const handleConfigChange = (partialConfig: Partial<TableListConfig>) => {
const newConfig: Record<string, any> = { ...config };
// 테이블 설정 변환
if (partialConfig.selectedTable !== undefined) {
newConfig.tableName = partialConfig.selectedTable;
if (!newConfig.dataSource) {
newConfig.dataSource = {};
}
newConfig.dataSource.table = partialConfig.selectedTable;
}
if (partialConfig.tableName !== undefined) {
newConfig.tableName = partialConfig.tableName;
if (!newConfig.dataSource) {
newConfig.dataSource = {};
}
newConfig.dataSource.table = partialConfig.tableName;
}
if (partialConfig.useCustomTable !== undefined) {
newConfig.useCustomTable = partialConfig.useCustomTable;
}
if (partialConfig.customTableName !== undefined) {
newConfig.customTableName = partialConfig.customTableName;
}
if (partialConfig.isReadOnly !== undefined) {
newConfig.isReadOnly = partialConfig.isReadOnly;
}
// 컬럼 형식 변환: TableList columns -> UnifiedList columns
if (partialConfig.columns !== undefined) {
newConfig.columns = partialConfig.columns.map((col: any) => ({
key: col.columnName,
field: col.columnName,
title: col.displayName,
header: col.displayName,
width: col.width ? String(col.width) : undefined,
visible: col.visible,
sortable: col.sortable,
searchable: col.searchable,
align: col.align,
isJoinColumn: col.isEntityJoin,
isEntityJoin: col.isEntityJoin,
thousandSeparator: col.thousandSeparator,
editable: col.editable,
entityDisplayConfig: col.entityDisplayConfig,
}));
}
// 페이지네이션 변환
if (partialConfig.pagination !== undefined) {
newConfig.pagination = partialConfig.pagination?.enabled;
newConfig.pageSize = partialConfig.pagination?.pageSize || 10;
}
// 필터 변환
if (partialConfig.filter !== undefined) {
newConfig.filter = partialConfig.filter;
}
// 데이터 필터 변환
if (partialConfig.dataFilter !== undefined) {
newConfig.dataFilter = partialConfig.dataFilter;
}
console.log("⚙️ UnifiedListConfigPanel handleConfigChange:", { partialConfig, newConfig });
onChange(newConfig);
};
return (
<TableListConfigPanel
config={tableListConfig}
onChange={handleConfigChange}
screenTableName={currentTableName}
/>
);
};
UnifiedListConfigPanel.displayName = "UnifiedListConfigPanel";
export default UnifiedListConfigPanel;

View File

@ -0,0 +1,212 @@
"use client";
/**
* UnifiedMedia
* .
*/
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
interface UnifiedMediaConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export const UnifiedMediaConfigPanel: React.FC<UnifiedMediaConfigPanelProps> = ({
config,
onChange,
}) => {
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
return (
<div className="space-y-4">
{/* 미디어 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.mediaType || config.type || "image"}
onValueChange={(value) => updateConfig("mediaType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="file"></SelectItem>
<SelectItem value="image"></SelectItem>
<SelectItem value="video"></SelectItem>
<SelectItem value="audio"></SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 허용 파일 형식 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
value={config.accept || ""}
onChange={(e) => updateConfig("accept", e.target.value)}
placeholder="예: .jpg,.png,.pdf"
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
. : .jpg,.png,.gif image/*
</p>
</div>
{/* 최대 파일 크기 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> (MB)</Label>
<Input
type="number"
value={config.maxSize || ""}
onChange={(e) => updateConfig("maxSize", e.target.value ? Number(e.target.value) : undefined)}
placeholder="10"
min="1"
className="h-8 text-xs"
/>
</div>
{/* 최대 파일 수 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
type="number"
value={config.maxFiles || ""}
onChange={(e) => updateConfig("maxFiles", e.target.value ? Number(e.target.value) : undefined)}
placeholder="제한 없음"
min="1"
className="h-8 text-xs"
/>
</div>
<Separator />
{/* 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="flex items-center space-x-2">
<Checkbox
id="multiple"
checked={config.multiple || false}
onCheckedChange={(checked) => updateConfig("multiple", checked)}
/>
<label htmlFor="multiple" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="preview"
checked={config.preview !== false}
onCheckedChange={(checked) => updateConfig("preview", checked)}
/>
<label htmlFor="preview" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="dragDrop"
checked={config.dragDrop !== false}
onCheckedChange={(checked) => updateConfig("dragDrop", checked)}
/>
<label htmlFor="dragDrop" className="text-xs"> </label>
</div>
</div>
{/* 이미지 전용 설정 */}
{config.mediaType === "image" && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (px)</Label>
<Input
type="number"
value={config.maxWidth || ""}
onChange={(e) => updateConfig("maxWidth", e.target.value ? Number(e.target.value) : undefined)}
placeholder="자동"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground"> (px)</Label>
<Input
type="number"
value={config.maxHeight || ""}
onChange={(e) => updateConfig("maxHeight", e.target.value ? Number(e.target.value) : undefined)}
placeholder="자동"
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="crop"
checked={config.crop || false}
onCheckedChange={(checked) => updateConfig("crop", checked)}
/>
<label htmlFor="crop" className="text-xs"> </label>
</div>
</div>
</>
)}
{/* 비디오/오디오 전용 설정 */}
{(config.mediaType === "video" || config.mediaType === "audio") && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="autoplay"
checked={config.autoplay || false}
onCheckedChange={(checked) => updateConfig("autoplay", checked)}
/>
<label htmlFor="autoplay" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="controls"
checked={config.controls !== false}
onCheckedChange={(checked) => updateConfig("controls", checked)}
/>
<label htmlFor="controls" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="loop"
checked={config.loop || false}
onCheckedChange={(checked) => updateConfig("loop", checked)}
/>
<label htmlFor="loop" className="text-xs"> </label>
</div>
</div>
</>
)}
</div>
);
};
UnifiedMediaConfigPanel.displayName = "UnifiedMediaConfigPanel";
export default UnifiedMediaConfigPanel;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,363 @@
"use client";
/**
* UnifiedSelect
* .
*/
import React, { useState, useEffect, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Plus, Trash2, Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api/client";
interface ColumnOption {
columnName: string;
columnLabel: string;
}
interface UnifiedSelectConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
/** 컬럼의 inputType (entity 타입인 경우에만 엔티티 소스 표시) */
inputType?: string;
}
export const UnifiedSelectConfigPanel: React.FC<UnifiedSelectConfigPanelProps> = ({
config,
onChange,
inputType,
}) => {
// 엔티티 타입인지 확인
const isEntityType = inputType === "entity";
// 엔티티 테이블의 컬럼 목록
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 엔티티 테이블 변경 시 컬럼 목록 조회
const loadEntityColumns = useCallback(async (tableName: string) => {
if (!tableName) {
setEntityColumns([]);
return;
}
setLoadingColumns(true);
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=500`);
const data = response.data.data || response.data;
const columns = data.columns || data || [];
const columnOptions: ColumnOption[] = columns.map((col: any) => {
const name = col.columnName || col.column_name || col.name;
// displayName 우선 사용
const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name;
return {
columnName: name,
columnLabel: label,
};
});
setEntityColumns(columnOptions);
} catch (error) {
console.error("컬럼 목록 조회 실패:", error);
setEntityColumns([]);
} finally {
setLoadingColumns(false);
}
}, []);
// 엔티티 테이블이 변경되면 컬럼 목록 로드
useEffect(() => {
if (config.source === "entity" && config.entityTable) {
loadEntityColumns(config.entityTable);
}
}, [config.source, config.entityTable, loadEntityColumns]);
// 정적 옵션 관리
const options = config.options || [];
const addOption = () => {
const newOptions = [...options, { value: "", label: "" }];
updateConfig("options", newOptions);
};
const updateOption = (index: number, field: "value" | "label", value: string) => {
const newOptions = [...options];
newOptions[index] = { ...newOptions[index], [field]: value };
updateConfig("options", newOptions);
};
const removeOption = (index: number) => {
const newOptions = options.filter((_: any, i: number) => i !== index);
updateConfig("options", newOptions);
};
return (
<div className="space-y-4">
{/* 선택 모드 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.mode || "dropdown"}
onValueChange={(value) => updateConfig("mode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="dropdown"></SelectItem>
<SelectItem value="radio"> </SelectItem>
<SelectItem value="check"></SelectItem>
<SelectItem value="tag"> </SelectItem>
<SelectItem value="toggle"> </SelectItem>
<SelectItem value="swap"> </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* 데이터 소스 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.source || "static"}
onValueChange={(value) => updateConfig("source", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> </SelectItem>
<SelectItem value="code"> </SelectItem>
{/* 엔티티 타입일 때만 엔티티 옵션 표시 */}
{isEntityType && <SelectItem value="entity"></SelectItem>}
</SelectContent>
</Select>
</div>
{/* 정적 옵션 관리 */}
{config.source === "static" && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addOption}
className="h-6 px-2 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{options.map((option: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<Input
value={option.value || ""}
onChange={(e) => updateOption(index, "value", e.target.value)}
placeholder="값"
className="h-7 text-xs flex-1"
/>
<Input
value={option.label || ""}
onChange={(e) => updateOption(index, "label", e.target.value)}
placeholder="표시 텍스트"
className="h-7 text-xs flex-1"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeOption(index)}
className="h-7 w-7 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{options.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
</p>
)}
</div>
</div>
)}
{/* 공통 코드 설정 - 테이블 타입 관리에서 설정되므로 정보만 표시 */}
{config.source === "code" && (
<div className="space-y-1">
<Label className="text-xs font-medium"> </Label>
{config.codeGroup ? (
<p className="text-sm font-medium text-foreground">{config.codeGroup}</p>
) : (
<p className="text-xs text-amber-600">
</p>
)}
</div>
)}
{/* 엔티티(참조 테이블) 설정 */}
{config.source === "entity" && (
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
value={config.entityTable || ""}
readOnly
disabled
placeholder="테이블 타입 관리에서 설정"
className="h-8 text-xs bg-muted"
/>
<p className="text-[10px] text-muted-foreground">
( )
</p>
</div>
{/* 컬럼 로딩 중 표시 */}
{loadingColumns && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
)}
{/* 컬럼 선택 - 테이블이 설정되어 있고 컬럼 목록이 있는 경우 Select로 표시 */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-xs font-medium"> ()</Label>
{entityColumns.length > 0 ? (
<Select
value={config.entityValueColumn || ""}
onValueChange={(value) => updateConfig("entityValueColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{entityColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config.entityValueColumn || ""}
onChange={(e) => updateConfig("entityValueColumn", e.target.value)}
placeholder="id"
className="h-8 text-xs"
/>
)}
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{entityColumns.length > 0 ? (
<Select
value={config.entityLabelColumn || ""}
onValueChange={(value) => updateConfig("entityLabelColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{entityColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config.entityLabelColumn || ""}
onChange={(e) => updateConfig("entityLabelColumn", e.target.value)}
placeholder="name"
className="h-8 text-xs"
/>
)}
<p className="text-[10px] text-muted-foreground"> </p>
</div>
</div>
{/* 컬럼이 없는 경우 안내 */}
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
<p className="text-[10px] text-amber-600">
. .
</p>
)}
</div>
)}
<Separator />
{/* 추가 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="multiple"
checked={config.multiple || false}
onCheckedChange={(checked) => updateConfig("multiple", checked)}
/>
<label htmlFor="multiple" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="searchable"
checked={config.searchable || false}
onCheckedChange={(checked) => updateConfig("searchable", checked)}
/>
<label htmlFor="searchable" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="allowClear"
checked={config.allowClear !== false}
onCheckedChange={(checked) => updateConfig("allowClear", checked)}
/>
<label htmlFor="allowClear" className="text-xs"> </label>
</div>
</div>
{/* 다중 선택 시 최대 개수 */}
{config.multiple && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
type="number"
value={config.maxSelect ?? ""}
onChange={(e) => updateConfig("maxSelect", e.target.value ? Number(e.target.value) : undefined)}
placeholder="제한 없음"
min="1"
className="h-8 text-xs"
/>
</div>
)}
</div>
);
};
UnifiedSelectConfigPanel.displayName = "UnifiedSelectConfigPanel";
export default UnifiedSelectConfigPanel;

View File

@ -0,0 +1,15 @@
/**
* Unified
*/
export { UnifiedInputConfigPanel } from "./UnifiedInputConfigPanel";
export { UnifiedSelectConfigPanel } from "./UnifiedSelectConfigPanel";
export { UnifiedDateConfigPanel } from "./UnifiedDateConfigPanel";
export { UnifiedListConfigPanel } from "./UnifiedListConfigPanel";
export { UnifiedLayoutConfigPanel } from "./UnifiedLayoutConfigPanel";
export { UnifiedGroupConfigPanel } from "./UnifiedGroupConfigPanel";
export { UnifiedMediaConfigPanel } from "./UnifiedMediaConfigPanel";
export { UnifiedBizConfigPanel } from "./UnifiedBizConfigPanel";
export { UnifiedHierarchyConfigPanel } from "./UnifiedHierarchyConfigPanel";

View File

@ -0,0 +1,125 @@
/**
* Unified Components
*
* 10
*/
// Phase 1 컴포넌트
export { UnifiedInput } from "./UnifiedInput";
export { UnifiedSelect } from "./UnifiedSelect";
export { UnifiedDate } from "./UnifiedDate";
// Phase 2 컴포넌트
export { UnifiedList } from "./UnifiedList";
export { UnifiedLayout } from "./UnifiedLayout";
export { UnifiedGroup } from "./UnifiedGroup";
// Phase 3 컴포넌트
export { UnifiedMedia } from "./UnifiedMedia";
export { UnifiedBiz } from "./UnifiedBiz";
export { UnifiedHierarchy } from "./UnifiedHierarchy";
// UnifiedText는 UnifiedInput의 textarea 모드로 대체 가능
// 렌더러
export { UnifiedComponentRenderer } from "./UnifiedComponentRenderer";
// 설정 패널
export { DynamicConfigPanel, COMMON_SCHEMAS } from "./DynamicConfigPanel";
// 데모 컴포넌트
export { UnifiedComponentsDemo } from "./UnifiedComponentsDemo";
// 폼 컨텍스트 및 액션
export {
UnifiedFormProvider,
useUnifiedForm,
useUnifiedFormOptional,
useUnifiedField,
useCascadingOptions,
useFormActions,
useRepeaterField,
} from "./UnifiedFormContext";
// 설정 UI 패널
export { ConditionalConfigPanel } from "./ConditionalConfigPanel";
// 폼 관련 타입 re-export
export type {
FormStatus,
FieldError,
FieldState,
SubmitConfig,
SubmitResult,
ValidationResult,
FieldMapping,
ScreenDataTransferConfig,
FormCompatibilityBridge,
} from "@/types/unified-form";
// 타입 re-export
export type {
// 공통 타입
UnifiedComponentType,
UnifiedBaseProps,
ConditionalConfig,
AutoFillConfig,
CascadingConfig,
MutualExclusionConfig,
// UnifiedInput 타입
UnifiedInputType,
UnifiedInputFormat,
UnifiedInputConfig,
UnifiedInputProps,
// UnifiedSelect 타입
UnifiedSelectMode,
UnifiedSelectSource,
SelectOption,
UnifiedSelectConfig,
UnifiedSelectProps,
// UnifiedDate 타입
UnifiedDateType,
UnifiedDateConfig,
UnifiedDateProps,
// UnifiedList 타입
UnifiedListViewMode,
ListColumn,
UnifiedListConfig,
UnifiedListProps,
// UnifiedLayout 타입
UnifiedLayoutType,
UnifiedLayoutConfig,
UnifiedLayoutProps,
// UnifiedGroup 타입
UnifiedGroupType,
TabItem,
UnifiedGroupConfig,
UnifiedGroupProps,
// UnifiedMedia 타입
UnifiedMediaType,
UnifiedMediaConfig,
UnifiedMediaProps,
// UnifiedBiz 타입
UnifiedBizType,
UnifiedBizConfig,
UnifiedBizProps,
// UnifiedHierarchy 타입
UnifiedHierarchyType,
UnifiedHierarchyViewMode,
HierarchyNode,
UnifiedHierarchyConfig,
UnifiedHierarchyProps,
// 통합 Props
UnifiedComponentProps,
} from "@/types/unified-components";

View File

@ -0,0 +1,202 @@
"use client";
/**
* Unified
*
* 9 Unified ComponentRegistry에 .
*/
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ComponentDefinition, ComponentCategory } from "@/types/component";
import { WebType } from "@/types/screen";
// 실제 컴포넌트 import
import { UnifiedInput } from "./UnifiedInput";
import { UnifiedSelect } from "./UnifiedSelect";
import { UnifiedDate } from "./UnifiedDate";
import { UnifiedList } from "./UnifiedList";
import { UnifiedLayout } from "./UnifiedLayout";
import { UnifiedGroup } from "./UnifiedGroup";
import { UnifiedMedia } from "./UnifiedMedia";
import { UnifiedBiz } from "./UnifiedBiz";
import { UnifiedHierarchy } from "./UnifiedHierarchy";
// 설정 패널 import
import { UnifiedInputConfigPanel } from "./config-panels/UnifiedInputConfigPanel";
import { UnifiedSelectConfigPanel } from "./config-panels/UnifiedSelectConfigPanel";
import { UnifiedDateConfigPanel } from "./config-panels/UnifiedDateConfigPanel";
import { UnifiedListConfigPanel } from "./config-panels/UnifiedListConfigPanel";
import { UnifiedLayoutConfigPanel } from "./config-panels/UnifiedLayoutConfigPanel";
import { UnifiedGroupConfigPanel } from "./config-panels/UnifiedGroupConfigPanel";
import { UnifiedMediaConfigPanel } from "./config-panels/UnifiedMediaConfigPanel";
import { UnifiedBizConfigPanel } from "./config-panels/UnifiedBizConfigPanel";
import { UnifiedHierarchyConfigPanel } from "./config-panels/UnifiedHierarchyConfigPanel";
// Unified 컴포넌트 정의
const unifiedComponentDefinitions: ComponentDefinition[] = [
{
id: "unified-input",
name: "통합 입력",
description: "텍스트, 숫자, 비밀번호, 슬라이더, 컬러 등 다양한 입력 타입을 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "text" as WebType,
component: UnifiedInput as any,
tags: ["input", "text", "number", "password", "slider", "color", "unified"],
defaultSize: { width: 200, height: 40 },
configPanel: UnifiedInputConfigPanel as any,
defaultConfig: {
inputType: "text",
format: "none",
placeholder: "",
},
},
{
id: "unified-select",
name: "통합 선택",
description: "드롭다운, 라디오, 체크박스, 태그, 토글 등 다양한 선택 방식을 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "select" as WebType,
component: UnifiedSelect as any,
tags: ["select", "dropdown", "radio", "checkbox", "toggle", "unified"],
defaultSize: { width: 200, height: 40 },
configPanel: UnifiedSelectConfigPanel as any,
defaultConfig: {
mode: "dropdown",
source: "static",
options: [],
},
},
{
id: "unified-date",
name: "통합 날짜",
description: "날짜, 시간, 날짜시간, 날짜 범위 등을 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "date" as WebType,
component: UnifiedDate as any,
tags: ["date", "time", "datetime", "datepicker", "unified"],
defaultSize: { width: 200, height: 40 },
configPanel: UnifiedDateConfigPanel as any,
defaultConfig: {
dateType: "date",
format: "YYYY-MM-DD",
},
},
{
id: "unified-list",
name: "통합 목록",
description: "테이블, 카드, 칸반, 리스트 등 다양한 데이터 표시 방식을 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "list" as WebType,
component: UnifiedList as any,
tags: ["list", "table", "card", "kanban", "data", "unified"],
defaultSize: { width: 600, height: 400 },
configPanel: UnifiedListConfigPanel as any,
defaultConfig: {
viewMode: "table",
source: "static",
columns: [],
pagination: true,
sortable: true,
},
},
{
id: "unified-layout",
name: "통합 레이아웃",
description: "그리드, 분할 패널, 플렉스 등 다양한 레이아웃 구조를 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "container" as WebType,
component: UnifiedLayout as any,
tags: ["layout", "grid", "split", "flex", "container", "unified"],
defaultSize: { width: 400, height: 300 },
configPanel: UnifiedLayoutConfigPanel as any,
defaultConfig: {
layoutType: "grid",
columns: 2,
gap: "16",
use12Column: true,
},
},
{
id: "unified-group",
name: "통합 그룹",
description: "탭, 아코디언, 섹션, 모달 등 그룹 요소를 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "group" as WebType,
component: UnifiedGroup as any,
tags: ["group", "tabs", "accordion", "section", "modal", "unified"],
defaultSize: { width: 400, height: 300 },
configPanel: UnifiedGroupConfigPanel as any,
defaultConfig: {
groupType: "section",
title: "",
collapsible: false,
defaultOpen: true,
},
},
{
id: "unified-media",
name: "통합 미디어",
description: "이미지, 비디오, 오디오, 파일 업로드 등을 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "file" as WebType,
component: UnifiedMedia as any,
tags: ["media", "image", "video", "audio", "file", "upload", "unified"],
defaultSize: { width: 300, height: 200 },
configPanel: UnifiedMediaConfigPanel as any,
defaultConfig: {
mediaType: "image",
multiple: false,
preview: true,
},
},
{
id: "unified-biz",
name: "통합 비즈니스",
description: "플로우, 랙, 채번규칙 등 비즈니스 기능을 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "custom" as WebType,
component: UnifiedBiz as any,
tags: ["business", "flow", "rack", "numbering", "category", "unified"],
defaultSize: { width: 500, height: 400 },
configPanel: UnifiedBizConfigPanel as any,
defaultConfig: {
bizType: "flow",
},
},
{
id: "unified-hierarchy",
name: "통합 계층",
description: "트리, 조직도, BOM, 연쇄 선택박스 등 계층 구조를 지원하는 통합 컴포넌트",
category: ComponentCategory.UNIFIED,
webType: "tree" as WebType,
component: UnifiedHierarchy as any,
tags: ["hierarchy", "tree", "org-chart", "bom", "cascading", "unified"],
defaultSize: { width: 400, height: 400 },
configPanel: UnifiedHierarchyConfigPanel as any,
defaultConfig: {
hierarchyType: "tree",
viewMode: "tree",
dataSource: "static",
},
},
];
/**
* Unified ComponentRegistry에
*/
export function registerUnifiedComponents(): void {
for (const definition of unifiedComponentDefinitions) {
try {
// 이미 등록되어 있으면 스킵
if (ComponentRegistry.getComponent(definition.id)) {
continue;
}
ComponentRegistry.registerComponent(definition);
console.log(`✅ Unified 컴포넌트 등록: ${definition.id}`);
} catch (error) {
console.error(`❌ Unified 컴포넌트 등록 실패: ${definition.id}`, error);
}
}
}
export default registerUnifiedComponents;

View File

@ -142,3 +142,5 @@ export const useActiveTabOptional = () => {

View File

@ -99,6 +99,14 @@ export const ScreenMultiLangProvider: React.FC<ScreenMultiLangProviderProps> = (
}
});
}
// 집계 위젯 (aggregation-widget) items의 labelLangKey 수집
if ((comp as any).componentType === "aggregation-widget" && config?.items) {
config.items.forEach((item: any) => {
if (item.labelLangKey) {
keys.push(item.labelLangKey);
}
});
}
// 자식 컴포넌트 재귀 처리
if ((comp as any).children) {
collectLangKeys((comp as any).children);

View File

@ -1,27 +1,11 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
ReactNode,
} from "react";
import {
TableRegistration,
TableOptionsContextValue,
} from "@/types/table-options";
import React, { createContext, useContext, useState, useCallback, useMemo, ReactNode } from "react";
import { TableRegistration, TableOptionsContextValue } from "@/types/table-options";
import { useActiveTab } from "./ActiveTabContext";
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(
undefined
);
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(undefined);
export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [registeredTables, setRegisteredTables] = useState<
Map<string, TableRegistration>
>(new Map());
export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [registeredTables, setRegisteredTables] = useState<Map<string, TableRegistration>>(new Map());
const [selectedTableId, setSelectedTableId] = useState<string | null>(null);
/**
@ -60,7 +44,7 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
// cleanup에서 selectedTableId를 null로 만들면 필터 설정이 초기화됨
// 다른 테이블이 선택되어야 하면 TableSearchWidget에서 자동 선택함
},
[] // 의존성 없음 - 무한 루프 방지
[], // 의존성 없음 - 무한 루프 방지
);
/**
@ -70,7 +54,7 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
(tableId: string) => {
return registeredTables.get(tableId);
},
[registeredTables]
[registeredTables],
);
/**
@ -102,22 +86,23 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
// 활성 탭이 없으면 탭에 속하지 않은 테이블만 반환
if (activeTabIds.length === 0) {
return allTables.filter(table => !table.parentTabId);
return allTables.filter((table) => !table.parentTabId);
}
// 활성 탭에 속한 테이블 + 탭에 속하지 않은 테이블
return allTables.filter(table =>
!table.parentTabId || activeTabIds.includes(table.parentTabId)
);
return allTables.filter((table) => !table.parentTabId || activeTabIds.includes(table.parentTabId));
}, [registeredTables, activeTabContext]);
/**
*
*/
const getTablesForTab = useCallback((tabId: string) => {
const allTables = Array.from(registeredTables.values());
return allTables.filter(table => table.parentTabId === tabId);
}, [registeredTables]);
const getTablesForTab = useCallback(
(tabId: string) => {
const allTables = Array.from(registeredTables.values());
return allTables.filter((table) => table.parentTabId === tabId);
},
[registeredTables],
);
return (
<TableOptionsContext.Provider
@ -148,4 +133,3 @@ export const useTableOptions = () => {
}
return context;
};

View File

@ -66,13 +66,6 @@ export function useTableColumnHierarchy(tableName?: string, columnName?: string)
let hierarchyRole: ColumnHierarchyInfo["hierarchyRole"];
let hierarchyParentField: string | undefined;
console.log("🔍 [useTableColumnHierarchy] 컬럼 정보:", {
columnName,
detailSettings: targetColumn.detailSettings,
detailSettingsType: typeof targetColumn.detailSettings,
codeCategory: targetColumn.codeCategory,
});
if (targetColumn.detailSettings) {
try {
const settings =
@ -80,12 +73,9 @@ export function useTableColumnHierarchy(tableName?: string, columnName?: string)
? JSON.parse(targetColumn.detailSettings)
: targetColumn.detailSettings;
console.log("🔍 [useTableColumnHierarchy] 파싱된 settings:", settings);
hierarchyRole = settings.hierarchyRole;
hierarchyParentField = settings.hierarchyParentField;
} catch (e) {
console.log("🔍 [useTableColumnHierarchy] JSON 파싱 실패:", e);
} catch {
// JSON 파싱 실패 시 무시
}
}
@ -138,25 +128,11 @@ export function useCodeOptions(codeCategory?: string, enabled: boolean = true, m
queryFn: async () => {
if (!codeCategory || codeCategory === "none") return [];
console.log("🔍 [useCodeOptions] 코드 옵션 조회 시작:", {
codeCategory,
menuObjid,
hasMenuObjid: !!menuObjid,
});
const response = await commonCodeApi.codes.getList(codeCategory, {
isActive: true,
menuObjid,
});
console.log("📦 [useCodeOptions] API 응답:", {
codeCategory,
menuObjid,
success: response.success,
dataCount: response.data?.length || 0,
rawData: response.data,
});
if (response.success && response.data) {
const options = response.data.map((code: any) => {
const actualValue = code.code || code.CODE || code.value || code.code_value || code.codeValue;
@ -185,13 +161,6 @@ export function useCodeOptions(codeCategory?: string, enabled: boolean = true, m
};
});
console.log("✅ [useCodeOptions] 옵션 변환 완료:", {
codeCategory,
menuObjid,
optionsCount: options.length,
options,
});
return options;
}

View File

@ -199,3 +199,5 @@ export function applyAutoFillToFormData(

View File

@ -0,0 +1,270 @@
"use client";
/**
* 릿
*
* Unified .
*
* :
* 1. UnifiedFormProvider Unified
* 2. UnifiedFormProvider
* 3.
*/
import { useCallback, useContext, useMemo } from "react";
import UnifiedFormContext, { useUnifiedFormOptional } from "@/components/unified/UnifiedFormContext";
import { useScreenContext } from "@/contexts/ScreenContext";
import type {
FormCompatibilityBridge,
FormCompatibilityOptions,
SubmitConfig,
SubmitResult,
ValidationResult,
FieldError,
FormEventDetail,
} from "@/types/unified-form";
/**
* 릿
*
* @param options -
* @returns API
*
* @example
* // 레거시 컴포넌트에서 사용
* const { getValue, setValue, formData } = useFormCompatibility({
* legacyOnFormDataChange: props.onFormDataChange,
* });
*
* // 값 변경 시
* setValue("fieldName", newValue);
*
* // 저장 시
* const result = await submit({ tableName: "my_table", mode: "insert" });
*/
export function useFormCompatibility(options: FormCompatibilityOptions = {}): FormCompatibilityBridge {
const { legacyOnFormDataChange, screenContext: externalScreenContext, emitLegacyEvents = true } = options;
// Unified 시스템 (있으면 사용)
const unifiedContext = useUnifiedFormOptional();
// ScreenContext (레거시 시스템)
const internalScreenContext = useScreenContext();
const screenContext = externalScreenContext || internalScreenContext;
// 모드 판별
const isUnifiedMode = !!unifiedContext;
const isLegacyMode = !unifiedContext;
// ===== 값 관리 =====
/**
*
*/
const getValue = useCallback(
(field: string): unknown => {
// Unified 시스템 우선
if (unifiedContext) {
return unifiedContext.getValue(field);
}
// ScreenContext 폴백
if (screenContext?.formData) {
return screenContext.formData[field];
}
return undefined;
},
[unifiedContext, screenContext?.formData],
);
/**
* ( )
*/
const setValue = useCallback(
(field: string, value: unknown) => {
// 1. Unified 시스템
if (unifiedContext) {
unifiedContext.setValue(field, value);
}
// 2. ScreenContext (레거시)
if (screenContext?.updateFormData) {
screenContext.updateFormData(field, value);
}
// 3. 레거시 콜백
if (legacyOnFormDataChange) {
legacyOnFormDataChange(field, value);
}
},
[unifiedContext, screenContext, legacyOnFormDataChange],
);
// ===== 폼 데이터 =====
const formData = useMemo(() => {
if (unifiedContext) {
return unifiedContext.formData as Record<string, unknown>;
}
if (screenContext?.formData) {
return screenContext.formData as Record<string, unknown>;
}
return {};
}, [unifiedContext, screenContext?.formData]);
// ===== 폼 액션 =====
/**
*
*/
const submit = useCallback(
async (config?: Partial<SubmitConfig>): Promise<SubmitResult> => {
// Unified 시스템이 있으면 그쪽 사용 (레거시 이벤트도 내부적으로 발생시킴)
if (unifiedContext) {
return unifiedContext.submit(config);
}
// 레거시 모드: beforeFormSave 이벤트 발생
if (emitLegacyEvents && typeof window !== "undefined") {
const eventDetail: FormEventDetail = { formData: { ...formData } };
const legacyEvent = new CustomEvent("beforeFormSave", { detail: eventDetail });
window.dispatchEvent(legacyEvent);
// 이벤트에서 수집된 데이터 반환 (실제 저장은 외부에서 처리)
return {
success: true,
data: { ...formData, ...eventDetail.formData },
};
}
return { success: true, data: formData };
},
[unifiedContext, formData, emitLegacyEvents],
);
/**
*
*/
const reset = useCallback(() => {
if (unifiedContext) {
unifiedContext.reset();
}
// 레거시 모드에서는 특별한 처리 없음 (외부에서 처리)
}, [unifiedContext]);
/**
*
*/
const validate = useCallback(async (): Promise<ValidationResult> => {
if (unifiedContext) {
return unifiedContext.validate();
}
// 레거시 모드에서는 항상 valid
return { valid: true, errors: [] };
}, [unifiedContext]);
// ===== 상태 =====
const isSubmitting = unifiedContext?.status.isSubmitting ?? false;
const isDirty = unifiedContext?.status.isDirty ?? false;
const errors = unifiedContext?.errors ?? [];
return {
// 값 관리
getValue,
setValue,
formData,
// 폼 액션
submit,
reset,
validate,
// 상태
isSubmitting,
isDirty,
errors,
// 모드
isUnifiedMode,
isLegacyMode,
};
}
/**
* ScreenContext를
* ScreenContext가 (null )
*/
function useScreenContext() {
// ScreenContext import는 동적으로 처리 (순환 의존성 방지)
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { useScreenContext: useCtx } = require("@/contexts/ScreenContext");
return useCtx?.() || null;
} catch {
return null;
}
}
/**
* DynamicComponentRenderer에서 onChange
*
* @example
* const handleChange = createUnifiedChangeHandler({
* fieldName: "customer_name",
* unifiedContext,
* screenContext,
* legacyOnFormDataChange: props.onFormDataChange,
* });
*/
export function createUnifiedChangeHandler(options: {
fieldName: string;
unifiedContext?: ReturnType<typeof useUnifiedFormOptional>;
screenContext?: { updateFormData?: (field: string, value: unknown) => void };
legacyOnFormDataChange?: (field: string, value: unknown) => void;
}): (value: unknown) => void {
const { fieldName, unifiedContext, screenContext, legacyOnFormDataChange } = options;
return (value: unknown) => {
// 1. Unified 시스템
if (unifiedContext) {
unifiedContext.setValue(fieldName, value);
}
// 2. ScreenContext
if (screenContext?.updateFormData) {
screenContext.updateFormData(fieldName, value);
}
// 3. 레거시 콜백
if (legacyOnFormDataChange) {
legacyOnFormDataChange(fieldName, value);
}
};
}
/**
* beforeFormSave
*
*
*
* @example
* useBeforeFormSave((event) => {
* event.detail.formData["repeater_field"] = myRepeaterData;
* });
*/
export function useBeforeFormSave(handler: (event: CustomEvent<FormEventDetail>) => void, deps: unknown[] = []) {
const stableHandler = useCallback(handler, deps);
// 이벤트 리스너 등록
if (typeof window !== "undefined") {
// useEffect 내에서 처리해야 하지만, 훅의 단순성을 위해 여기서 처리
// 실제 사용 시에는 컴포넌트에서 useEffect로 감싸서 사용
}
return stableHandler;
}
export default useFormCompatibility;

View File

@ -0,0 +1,320 @@
"use client";
/**
*
*
* , .
*
* :
* 1. - 패턴: 목록에서
* 2. 오픈: 버튼
* 3. 임베딩: 부모
*/
import { useCallback, useEffect, useRef } from "react";
import type {
ScreenDataTransferConfig,
FieldMapping,
DataTransferTrigger,
} from "@/types/unified-form";
// ===== 이벤트 이름 상수 =====
export const SCREEN_DATA_TRANSFER_EVENT = "screenDataTransfer";
// ===== 전역 데이터 스토어 (간단한 인메모리 저장소) =====
const dataStore = new Map<string, unknown>();
/**
*
*/
export function setTransferData(key: string, data: unknown): void {
dataStore.set(key, data);
}
/**
*
*/
export function getTransferData<T = unknown>(key: string): T | undefined {
return dataStore.get(key) as T | undefined;
}
/**
*
*/
export function clearTransferData(key: string): void {
dataStore.delete(key);
}
// ===== 데이터 변환 유틸 =====
/**
*
*/
export function applyFieldMappings(
data: Record<string, unknown>,
mappings: FieldMapping[]
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const mapping of mappings) {
const sourceValue = data[mapping.sourceField];
// 변환 적용
let targetValue = sourceValue;
switch (mapping.transform) {
case "copy":
// 그대로 복사
targetValue = sourceValue;
break;
case "lookup":
// TODO: 다른 테이블에서 조회
targetValue = sourceValue;
break;
case "calculate":
// TODO: 계산식 적용
targetValue = sourceValue;
break;
case "format":
// TODO: 포맷팅 적용
if (typeof sourceValue === "string" && mapping.transformConfig?.format) {
// 간단한 포맷 적용 (확장 가능)
targetValue = sourceValue;
}
break;
default:
targetValue = sourceValue;
}
result[mapping.targetField] = targetValue;
}
return result;
}
// ===== 훅 =====
interface UseScreenDataTransferOptions {
// 이 컴포넌트/화면의 ID
screenId?: number;
componentId?: string;
// 데이터 수신 시 콜백
onReceiveData?: (data: Record<string, unknown>, trigger: DataTransferTrigger) => void;
// 자동 구독할 소스 (다른 화면에서 이 화면으로 전달되는 데이터)
subscribeFrom?: {
sourceScreenId?: number;
sourceComponentId?: string;
};
}
interface UseScreenDataTransferReturn {
/**
*
*/
sendData: (
data: Record<string, unknown>,
config: {
targetScreenId?: number;
targetComponentId?: string;
mappings?: FieldMapping[];
trigger?: DataTransferTrigger;
}
) => void;
/**
* ()
*/
receiveData: () => Record<string, unknown> | undefined;
/**
* ( )
*/
getStoredData: <T = unknown>(key: string) => T | undefined;
/**
* ( )
*/
setStoredData: (key: string, data: unknown) => void;
}
/**
*
*/
export function useScreenDataTransfer(
options: UseScreenDataTransferOptions = {}
): UseScreenDataTransferReturn {
const { screenId, componentId, onReceiveData, subscribeFrom } = options;
const receiveCallbackRef = useRef(onReceiveData);
receiveCallbackRef.current = onReceiveData;
// 이벤트 리스너 등록 (데이터 수신)
useEffect(() => {
if (!subscribeFrom && !screenId && !componentId) return;
const handleDataTransfer = (event: Event) => {
const customEvent = event as CustomEvent<{
sourceScreenId?: number;
sourceComponentId?: string;
targetScreenId?: number;
targetComponentId?: string;
data: Record<string, unknown>;
trigger: DataTransferTrigger;
}>;
const detail = customEvent.detail;
// 이 화면/컴포넌트를 대상으로 하는지 확인
const isTargetMatch =
(detail.targetScreenId && detail.targetScreenId === screenId) ||
(detail.targetComponentId && detail.targetComponentId === componentId);
// 구독 중인 소스에서 온 데이터인지 확인
const isSourceMatch = subscribeFrom && (
(subscribeFrom.sourceScreenId && subscribeFrom.sourceScreenId === detail.sourceScreenId) ||
(subscribeFrom.sourceComponentId && subscribeFrom.sourceComponentId === detail.sourceComponentId)
);
if (isTargetMatch || isSourceMatch) {
receiveCallbackRef.current?.(detail.data, detail.trigger);
}
};
window.addEventListener(SCREEN_DATA_TRANSFER_EVENT, handleDataTransfer);
return () => {
window.removeEventListener(SCREEN_DATA_TRANSFER_EVENT, handleDataTransfer);
};
}, [screenId, componentId, subscribeFrom]);
/**
*
*/
const sendData = useCallback((
data: Record<string, unknown>,
config: {
targetScreenId?: number;
targetComponentId?: string;
mappings?: FieldMapping[];
trigger?: DataTransferTrigger;
}
) => {
// 매핑 적용
const mappedData = config.mappings
? applyFieldMappings(data, config.mappings)
: data;
// 이벤트 발생
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(SCREEN_DATA_TRANSFER_EVENT, {
detail: {
sourceScreenId: screenId,
sourceComponentId: componentId,
targetScreenId: config.targetScreenId,
targetComponentId: config.targetComponentId,
data: mappedData,
trigger: config.trigger || "manual",
}
}));
}
// 스토어에도 저장 (비동기 조회용)
const storeKey = config.targetScreenId
? `screen_${config.targetScreenId}`
: config.targetComponentId
? `component_${config.targetComponentId}`
: "default";
setTransferData(storeKey, mappedData);
}, [screenId, componentId]);
/**
* ( )
*/
const receiveData = useCallback(() => {
const storeKey = screenId
? `screen_${screenId}`
: componentId
? `component_${componentId}`
: "default";
return getTransferData<Record<string, unknown>>(storeKey);
}, [screenId, componentId]);
return {
sendData,
receiveData,
getStoredData: getTransferData,
setStoredData: setTransferData,
};
}
/**
* ( )
*/
export function useConfiguredDataTransfer(config: ScreenDataTransferConfig) {
const { source, target, trigger, condition } = config;
const { sendData } = useScreenDataTransfer({
screenId: source.screenId,
componentId: source.componentId,
});
/**
*
*/
const transfer = useCallback((data: Record<string, unknown>) => {
// 조건 체크
if (condition) {
const fieldValue = data[condition.field];
let conditionMet = false;
switch (condition.operator) {
case "=":
conditionMet = fieldValue === condition.value;
break;
case "!=":
conditionMet = fieldValue !== condition.value;
break;
case ">":
conditionMet = Number(fieldValue) > Number(condition.value);
break;
case "<":
conditionMet = Number(fieldValue) < Number(condition.value);
break;
case "in":
conditionMet = Array.isArray(condition.value) && condition.value.includes(fieldValue);
break;
case "notIn":
conditionMet = Array.isArray(condition.value) && !condition.value.includes(fieldValue);
break;
}
if (!conditionMet) {
return; // 조건 불충족 시 전달 안 함
}
}
// 소스 필드만 추출
const sourceData: Record<string, unknown> = {};
for (const field of source.fields) {
sourceData[field] = data[field];
}
// 전달
sendData(sourceData, {
targetScreenId: target.screenId,
mappings: target.mappings,
trigger,
});
}, [source.fields, target.screenId, target.mappings, trigger, condition, sendData]);
return { transfer };
}
export default useScreenDataTransfer;

View File

@ -190,6 +190,7 @@ export const entityJoinApi = {
columnName: string;
columnLabel: string;
dataType: string;
inputType?: string;
description?: string;
}>;
}>;
@ -198,6 +199,7 @@ export const entityJoinApi = {
columnName: string;
columnLabel: string;
dataType: string;
inputType?: string;
joinAlias: string;
suggestedLabel: string;
}>;

View File

@ -319,8 +319,10 @@ export const tableTypeApi = {
},
// 테이블 컬럼 정보 조회 (모든 컬럼)
getColumns: async (tableName: string): Promise<any[]> => {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`);
getColumns: async (tableName: string, bustCache: boolean = false): Promise<any[]> => {
// bustCache: 캐시 우회 (테이블 타입 변경 후 즉시 반영 필요 시)
const cacheParam = bustCache ? `&_t=${Date.now()}` : "";
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000${cacheParam}`);
// 새로운 API 응답 구조에 맞게 수정: { columns, total, page, size, totalPages }
const data = response.data.data || response.data;
return data.columns || data || [];

View File

@ -1,11 +1,28 @@
"use client";
import React from "react";
import React, { useCallback } from "react";
import { ComponentData } from "@/types/screen";
import { DynamicLayoutRenderer } from "./DynamicLayoutRenderer";
import { ComponentRegistry } from "./ComponentRegistry";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
// Unified 컴포넌트 import
import {
UnifiedInput,
UnifiedSelect,
UnifiedDate,
UnifiedList,
UnifiedLayout,
UnifiedGroup,
UnifiedMedia,
UnifiedBiz,
UnifiedHierarchy,
} from "@/components/unified";
import { UnifiedRepeater } from "@/components/unified/UnifiedRepeater";
// 통합 폼 시스템 import
import { useUnifiedFormOptional } from "@/components/unified/UnifiedFormContext";
// 컴포넌트 렌더러 인터페이스
export interface ComponentRenderer {
(props: {
@ -29,7 +46,14 @@ export interface ComponentRenderer {
// 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[];
selectedRowsData?: any[];
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
onSelectedRowsChange?: (
selectedRows: any[],
selectedRowsData: any[],
sortBy?: string,
sortOrder?: "asc" | "desc",
columnOrder?: string[],
tableDisplayData?: any[],
) => void;
// 테이블 정렬 정보 (엑셀 다운로드용)
sortBy?: string;
sortOrder?: "asc" | "desc";
@ -113,7 +137,14 @@ export interface DynamicComponentRendererProps {
// 🆕 비활성화할 필드 목록 (EditModal → 각 컴포넌트)
disabledFields?: string[];
selectedRowsData?: any[];
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
onSelectedRowsChange?: (
selectedRows: any[],
selectedRowsData: any[],
sortBy?: string,
sortOrder?: "asc" | "desc",
columnOrder?: string[],
tableDisplayData?: any[],
) => void;
// 테이블 정렬 정보 (엑셀 다운로드용)
sortBy?: string;
sortOrder?: "asc" | "desc";
@ -133,8 +164,10 @@ export interface DynamicComponentRendererProps {
// 모달 내에서 렌더링 여부
isInModal?: boolean;
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
parentTabId?: string; // 부모 탭 ID
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
// 🆕 조건부 비활성화 상태
conditionalDisabled?: boolean;
[key: string]: any;
}
@ -151,6 +184,286 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
const componentType = (component as any).componentType || component.type;
// 🆕 Unified 폼 시스템 연동 (최상위에서 한 번만 호출)
// eslint-disable-next-line react-hooks/rules-of-hooks
const unifiedFormContextForUnified = useUnifiedFormOptional();
// 🆕 Unified 컴포넌트 처리
if (componentType?.startsWith("unified-")) {
const unifiedType = componentType as string;
const config = (component as any).componentConfig || {};
const fieldName = (component as any).columnName || component.id;
// Unified 시스템이 있으면 거기서 값 가져오기, 없으면 props.formData 사용
const currentValue = unifiedFormContextForUnified
? unifiedFormContextForUnified.getValue(fieldName)
: props.formData?.[fieldName];
// 🆕 통합 onChange 핸들러 - 양쪽 시스템에 전파
const handleChange = (value: any) => {
// 1. Unified 시스템에 전파
if (unifiedFormContextForUnified) {
unifiedFormContextForUnified.setValue(fieldName, value);
}
// 2. 레거시 콜백도 호출 (호환성)
if (props.onFormDataChange) {
props.onFormDataChange(fieldName, value);
}
};
// 공통 props
const commonProps = {
id: component.id,
label: (component as any).label,
required: (component as any).required,
readonly: (component as any).readonly,
// conditionalDisabled가 true이면 비활성화
disabled: (component as any).disabled || props.disabledFields?.includes(fieldName) || props.conditionalDisabled,
value: currentValue,
onChange: handleChange,
tableName: (component as any).tableName || props.tableName,
columnName: fieldName,
style: component.style,
size: component.size,
position: component.position,
};
switch (unifiedType) {
case "unified-input":
return (
<UnifiedInput
unifiedType="UnifiedInput"
{...commonProps}
config={{
type: config.inputType || config.type || "text",
format: config.format,
placeholder: config.placeholder,
mask: config.mask,
min: config.min,
max: config.max,
step: config.step,
buttonText: config.buttonText,
buttonVariant: config.buttonVariant,
autoGeneration: config.autoGeneration,
}}
autoGeneration={config.autoGeneration}
formData={props.formData}
originalData={props.originalData}
/>
);
case "unified-select":
return (
<UnifiedSelect
unifiedType="UnifiedSelect"
{...commonProps}
config={{
mode: config.mode || "dropdown",
source: config.source || "static",
options: config.options || [],
multiple: config.multiple,
searchable: config.searchable,
codeGroup: config.codeGroup,
codeCategory: config.codeCategory,
table: config.table,
valueColumn: config.valueColumn,
labelColumn: config.labelColumn,
// 엔티티(참조 테이블) 관련 속성
entityTable: config.entityTable,
entityValueColumn: config.entityValueColumn,
entityLabelColumn: config.entityLabelColumn,
entityValueField: config.entityValueField,
entityLabelField: config.entityLabelField,
}}
/>
);
case "unified-date":
return (
<UnifiedDate
unifiedType="UnifiedDate"
{...commonProps}
config={{
type: config.dateType || config.type || "date",
format: config.format,
range: config.range,
minDate: config.minDate,
maxDate: config.maxDate,
showToday: config.showToday,
}}
/>
);
case "unified-list":
// 데이터 소스: config.data > props.tableDisplayData > []
const listData = config.data?.length > 0 ? config.data : props.tableDisplayData || [];
return (
<UnifiedList
unifiedType="UnifiedList"
{...commonProps}
config={{
viewMode: config.viewMode || "table",
columns: config.columns || [],
source: config.source || "static",
sortable: config.sortable,
pagination: config.pagination,
searchable: config.searchable,
editable: config.editable,
pageable: config.pageable,
pageSize: config.pageSize,
cardConfig: config.cardConfig,
dataSource: {
table: config.dataSource?.table || props.tableName,
},
}}
data={listData}
selectedRows={props.selectedRowsData || []}
onRowSelect={(rows) => {
// 항상 선택된 데이터를 전달 (modalDataStore에 자동 저장됨)
if (props.onSelectedRowsChange) {
props.onSelectedRowsChange(
rows.map((r: any) => r.id || r.objid),
rows,
props.sortBy,
props.sortOrder,
undefined,
props.tableDisplayData,
);
}
}}
/>
);
case "unified-layout":
return (
<UnifiedLayout
unifiedType="UnifiedLayout"
{...commonProps}
config={{
type: config.layoutType || config.type || "grid",
columns: config.columns,
gap: config.gap,
direction: config.direction,
use12Column: config.use12Column,
}}
>
{children}
</UnifiedLayout>
);
case "unified-group":
return (
<UnifiedGroup
unifiedType="UnifiedGroup"
{...commonProps}
config={{
type: config.groupType || config.type || "section",
collapsible: config.collapsible,
defaultOpen: config.defaultOpen,
tabs: config.tabs || [],
showHeader: config.showHeader,
}}
title={config.title}
>
{children}
</UnifiedGroup>
);
case "unified-media":
return (
<UnifiedMedia
unifiedType="UnifiedMedia"
{...commonProps}
config={{
type: config.mediaType || config.type || "image",
accept: config.accept,
maxSize: config.maxSize,
multiple: config.multiple,
preview: config.preview,
}}
/>
);
case "unified-biz":
return (
<UnifiedBiz
unifiedType="UnifiedBiz"
{...commonProps}
config={{
type: config.bizType || config.type || "flow",
flowConfig: config.flowConfig,
rackConfig: config.rackConfig,
numberingConfig: config.numberingConfig,
}}
/>
);
case "unified-hierarchy":
return (
<UnifiedHierarchy
unifiedType="UnifiedHierarchy"
{...commonProps}
config={{
type: config.hierarchyType || config.type || "tree",
viewMode: config.viewMode || "tree",
dataSource: config.dataSource || "static",
maxLevel: config.maxLevel,
draggable: config.draggable,
}}
/>
);
case "unified-repeater":
return (
<UnifiedRepeater
config={{
renderMode: config.renderMode || "inline",
dataSource: {
tableName: config.dataSource?.tableName || props.tableName || "",
foreignKey: config.dataSource?.foreignKey || "",
referenceKey: config.dataSource?.referenceKey || "",
sourceTable: config.dataSource?.sourceTable,
displayColumn: config.dataSource?.displayColumn,
},
columns: config.columns || [],
modal: config.modal,
button: config.button,
features: config.features || {
showAddButton: true,
showDeleteButton: true,
inlineEdit: false,
dragSort: false,
showRowNumber: false,
selectable: false,
multiSelect: false,
},
}}
parentId={props.formData?.[config.dataSource?.referenceKey] || props.formData?.id}
onDataChange={(data) => {
console.log("UnifiedRepeater data changed:", data);
}}
onRowClick={(row) => {
console.log("UnifiedRepeater row clicked:", row);
}}
onButtonClick={(action, row, buttonConfig) => {
console.log("UnifiedRepeater button clicked:", action, row, buttonConfig);
}}
/>
);
default:
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-amber-300 bg-amber-50 p-4">
<div className="text-center">
<div className="mb-2 text-sm font-medium text-amber-600">Unified </div>
<div className="text-xs text-amber-500"> : {unifiedType}</div>
</div>
</div>
);
}
}
// 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인)
const inputType = (component as any).componentConfig?.inputType || (component as any).inputType;
const webType = (component as any).componentConfig?.webType;
@ -159,7 +472,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
// ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원)
if ((inputType === "category" || webType === "category") && tableName && columnName && componentType === "select-basic") {
if (
(inputType === "category" || webType === "category") &&
tableName &&
columnName &&
componentType === "select-basic"
) {
// select-basic은 ComponentRegistry에서 처리하도록 아래로 통과
} else if ((inputType === "category" || webType === "category") && tableName && columnName) {
try {
@ -292,7 +610,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
currentValue = formData?.[fieldName] || "";
}
// 🆕 Unified 폼 시스템 연동 (Context가 있으면 사용, 없으면 null)
// eslint-disable-next-line react-hooks/rules-of-hooks
const unifiedFormContext = useUnifiedFormOptional();
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
// 🆕 Unified 시스템과 레거시 시스템 모두에 전파
const handleChange = (value: any) => {
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
if (componentType === "autocomplete-search-input" || componentType === "entity-search-input") {
@ -306,6 +629,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
actualValue = value.target.value;
}
// 1. Unified 폼 시스템에 전파 (있으면)
if (unifiedFormContext) {
unifiedFormContext.setValue(fieldName, actualValue);
}
// 2. 레거시 onFormDataChange 콜백도 호출 (호환성 유지)
if (onFormDataChange) {
// modal-repeater-table은 배열 데이터를 다룸
if (componentType === "modal-repeater-table") {
@ -337,9 +666,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
};
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블)
const useConfigTableName = componentType === "entity-search-input" ||
componentType === "autocomplete-search-input" ||
componentType === "modal-repeater-table";
const useConfigTableName =
componentType === "entity-search-input" ||
componentType === "autocomplete-search-input" ||
componentType === "modal-repeater-table";
const rendererProps = {
component,
@ -364,7 +694,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onFormDataChange,
onChange: handleChange, // 개선된 onChange 핸들러 전달
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용
tableName: useConfigTableName ? (component.componentConfig?.tableName || tableName) : tableName,
tableName: useConfigTableName ? component.componentConfig?.tableName || tableName : tableName,
menuId, // 🆕 메뉴 ID
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
selectedScreen, // 🆕 화면 정보
@ -427,17 +757,53 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
};
// 렌더러가 클래스인지 함수인지 확인
if (
const isClass =
typeof NewComponentRenderer === "function" &&
NewComponentRenderer.prototype &&
NewComponentRenderer.prototype.render
) {
NewComponentRenderer.prototype.render;
if (componentType === "table-search-widget") {
console.log("🔍 [DynamicComponentRenderer] table-search-widget 렌더링 분기:", {
isClass,
hasPrototype: !!NewComponentRenderer.prototype,
hasRender: !!NewComponentRenderer.prototype?.render,
componentName: NewComponentRenderer.name,
componentProp: rendererProps.component,
screenId: rendererProps.screenId,
});
}
if (isClass) {
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
const rendererInstance = new NewComponentRenderer(rendererProps);
return rendererInstance.render();
} else {
// 함수형 컴포넌트
// refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제
// 🔧 디버깅: table-search-widget인 경우 직접 호출 후 반환
if (componentType === "table-search-widget") {
console.log("🔧🔧🔧 [DynamicComponentRenderer] TableSearchWidget 직접 호출 반환");
console.log("🔧 [DynamicComponentRenderer] NewComponentRenderer 함수 확인:", {
name: NewComponentRenderer.name,
toString: NewComponentRenderer.toString().substring(0, 200),
});
try {
const result = NewComponentRenderer(rendererProps);
console.log("🔧 [DynamicComponentRenderer] TableSearchWidget 결과 상세:", {
resultType: typeof result,
type: result?.type?.name || result?.type || "unknown",
propsKeys: result?.props ? Object.keys(result.props) : [],
propsStyle: result?.props?.style,
propsChildren: typeof result?.props?.children,
});
// 직접 호출 결과를 반환
return result;
} catch (directCallError) {
console.error("❌ [DynamicComponentRenderer] TableSearchWidget 직접 호출 실패:", directCallError);
}
}
return <NewComponentRenderer key={refreshKey} {...rendererProps} />;
}
}
@ -466,10 +832,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 폴백 렌더링 - 기본 플레이스홀더
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-border bg-muted p-4">
<div className="border-border bg-muted flex h-full w-full items-center justify-center rounded border-2 border-dashed p-4">
<div className="text-center">
<div className="mb-2 text-sm font-medium text-muted-foreground">{component.label || component.id}</div>
<div className="text-xs text-muted-foreground/70"> : {componentType}</div>
<div className="text-muted-foreground mb-2 text-sm font-medium">{component.label || component.id}</div>
<div className="text-muted-foreground/70 text-xs"> : {componentType}</div>
</div>
</div>
);

View File

@ -0,0 +1,312 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { ComponentRendererProps } from "@/types/component";
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType } from "./types";
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
interface AggregationWidgetComponentProps extends ComponentRendererProps {
config?: AggregationWidgetConfig;
// 외부에서 데이터를 직접 전달받을 수 있음
externalData?: any[];
}
/**
*
*
*/
export function AggregationWidgetComponent({
component,
isDesignMode = false,
config: propsConfig,
externalData,
}: AggregationWidgetComponentProps) {
// 다국어 지원
const { getText } = useScreenMultiLang();
const componentConfig: AggregationWidgetConfig = {
dataSourceType: "manual",
items: [],
layout: "horizontal",
showLabels: true,
showIcons: true,
gap: "16px",
...propsConfig,
...component?.config,
};
// 다국어 라벨 가져오기
const getItemLabel = (item: AggregationItem): string => {
if (item.labelLangKey) {
const translated = getText(item.labelLangKey);
if (translated && translated !== item.labelLangKey) {
return translated;
}
}
return item.columnLabel || item.columnName || "컬럼";
};
const {
dataSourceType,
dataSourceComponentId,
items,
layout,
showLabels,
showIcons,
gap,
backgroundColor,
borderRadius,
padding,
fontSize,
labelFontSize,
valueFontSize,
labelColor,
valueColor,
} = componentConfig;
// 데이터 상태
const [data, setData] = useState<any[]>([]);
// 외부 데이터가 있으면 사용
useEffect(() => {
if (externalData && Array.isArray(externalData)) {
setData(externalData);
}
}, [externalData]);
// 컴포넌트 데이터 변경 이벤트 리스닝
useEffect(() => {
if (!dataSourceComponentId || isDesignMode) return;
const handleDataChange = (event: CustomEvent) => {
const { componentId, data: eventData } = event.detail || {};
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
setData(eventData);
}
};
// 리피터 데이터 변경 이벤트
window.addEventListener("repeaterDataChange" as any, handleDataChange);
// 테이블 리스트 데이터 변경 이벤트
window.addEventListener("tableListDataChange" as any, handleDataChange);
return () => {
window.removeEventListener("repeaterDataChange" as any, handleDataChange);
window.removeEventListener("tableListDataChange" as any, handleDataChange);
};
}, [dataSourceComponentId, isDesignMode]);
// 집계 계산
const aggregationResults = useMemo((): AggregationResult[] => {
if (!items || items.length === 0) {
return [];
}
return items.map((item) => {
const values = data
.map((row) => {
const val = row[item.columnName];
return typeof val === "number" ? val : parseFloat(val) || 0;
})
.filter((v) => !isNaN(v));
let value: number = 0;
switch (item.type) {
case "sum":
value = values.reduce((acc, v) => acc + v, 0);
break;
case "avg":
value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0;
break;
case "count":
value = data.length;
break;
case "max":
value = values.length > 0 ? Math.max(...values) : 0;
break;
case "min":
value = values.length > 0 ? Math.min(...values) : 0;
break;
}
// 포맷팅
let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
if (item.format === "currency") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
} else if (item.format === "percent") {
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
} else if (item.format === "number") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
}
if (item.prefix) {
formattedValue = `${item.prefix}${formattedValue}`;
}
if (item.suffix) {
formattedValue = `${formattedValue}${item.suffix}`;
}
return {
id: item.id,
label: getItemLabel(item),
value,
formattedValue,
type: item.type,
};
});
}, [data, items, getText]);
// 집계 타입에 따른 아이콘
const getIcon = (type: AggregationType) => {
switch (type) {
case "sum":
return <Calculator className="h-4 w-4" />;
case "avg":
return <TrendingUp className="h-4 w-4" />;
case "count":
return <Hash className="h-4 w-4" />;
case "max":
return <ArrowUp className="h-4 w-4" />;
case "min":
return <ArrowDown className="h-4 w-4" />;
}
};
// 집계 타입 라벨
const getTypeLabel = (type: AggregationType) => {
switch (type) {
case "sum":
return "합계";
case "avg":
return "평균";
case "count":
return "개수";
case "max":
return "최대";
case "min":
return "최소";
}
};
// 디자인 모드 미리보기
if (isDesignMode) {
const previewItems: AggregationResult[] =
items.length > 0
? items.map((item) => ({
id: item.id,
label: getItemLabel(item),
value: 0,
formattedValue: item.prefix ? `${item.prefix}0${item.suffix || ""}` : `0${item.suffix || ""}`,
type: item.type,
}))
: [
{ id: "1", label: "총 수량", value: 150, formattedValue: "150", type: "sum" },
{ id: "2", label: "총 금액", value: 1500000, formattedValue: "₩1,500,000", type: "sum" },
{ id: "3", label: "건수", value: 5, formattedValue: "5건", type: "count" },
];
return (
<div
className={cn(
"flex items-center rounded-md border bg-slate-50 p-3",
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
)}
style={{
gap: gap || "12px",
backgroundColor: backgroundColor || undefined,
borderRadius: borderRadius || undefined,
padding: padding || undefined,
fontSize: fontSize || undefined,
}}
>
{previewItems.map((result, index) => (
<div
key={result.id || index}
className={cn(
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
layout === "vertical" ? "w-full justify-between" : ""
)}
>
{showIcons && (
<span className="text-muted-foreground">{getIcon(result.type)}</span>
)}
{showLabels && (
<span
className="text-muted-foreground text-xs"
style={{ fontSize: labelFontSize, color: labelColor }}
>
{result.label} ({getTypeLabel(result.type)}):
</span>
)}
<span
className="font-semibold"
style={{ fontSize: valueFontSize, color: valueColor }}
>
{result.formattedValue}
</span>
</div>
))}
</div>
);
}
// 실제 렌더링
if (aggregationResults.length === 0) {
return (
<div className="flex items-center justify-center rounded-md border border-dashed bg-slate-50 p-4 text-sm text-muted-foreground">
</div>
);
}
return (
<div
className={cn(
"flex items-center rounded-md border bg-slate-50 p-3",
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
)}
style={{
gap: gap || "12px",
backgroundColor: backgroundColor || undefined,
borderRadius: borderRadius || undefined,
padding: padding || undefined,
fontSize: fontSize || undefined,
}}
>
{aggregationResults.map((result, index) => (
<div
key={result.id || index}
className={cn(
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
layout === "vertical" ? "w-full justify-between" : ""
)}
>
{showIcons && (
<span className="text-muted-foreground">{getIcon(result.type)}</span>
)}
{showLabels && (
<span
className="text-muted-foreground text-xs"
style={{ fontSize: labelFontSize, color: labelColor }}
>
{result.label} ({getTypeLabel(result.type)}):
</span>
)}
<span
className="font-semibold"
style={{ fontSize: valueFontSize, color: valueColor }}
>
{result.formattedValue}
</span>
</div>
))}
</div>
);
}
export const AggregationWidgetWrapper = AggregationWidgetComponent;

View File

@ -0,0 +1,539 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Plus, Trash2, GripVertical, Database, Table2, ChevronsUpDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { AggregationWidgetConfig, AggregationItem, AggregationType } from "./types";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { tableTypeApi } from "@/lib/api/screen";
interface AggregationWidgetConfigPanelProps {
config: AggregationWidgetConfig;
onChange: (config: Partial<AggregationWidgetConfig>) => void;
screenTableName?: string;
}
/**
*
*/
export function AggregationWidgetConfigPanel({
config,
onChange,
screenTableName,
}: AggregationWidgetConfigPanelProps) {
const [columns, setColumns] = useState<Array<{ columnName: string; label?: string; dataType?: string; inputType?: string; webType?: string }>>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
// 실제 사용할 테이블 이름 계산
const targetTableName = useMemo(() => {
if (config.useCustomTable && config.customTableName) {
return config.customTableName;
}
return config.tableName || screenTableName;
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
// 화면 테이블명 자동 설정 (초기 한 번만)
useEffect(() => {
if (screenTableName && !config.tableName && !config.customTableName) {
onChange({ tableName: screenTableName });
}
}, [screenTableName, config.tableName, config.customTableName, onChange]);
// 전체 테이블 목록 로드
useEffect(() => {
const fetchTables = async () => {
setLoadingTables(true);
try {
const response = await tableTypeApi.getTables();
setAvailableTables(
response.map((table: any) => ({
tableName: table.tableName,
displayName: table.displayName || table.tableName,
}))
);
} catch (error) {
console.error("테이블 목록 가져오기 실패:", error);
} finally {
setLoadingTables(false);
}
};
fetchTables();
}, []);
// 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!targetTableName) {
setColumns([]);
return;
}
setLoadingColumns(true);
try {
const result = await tableManagementApi.getColumnList(targetTableName);
if (result.success && result.data?.columns) {
setColumns(
result.data.columns.map((col: any) => ({
columnName: col.columnName || col.column_name,
label: col.displayName || col.columnLabel || col.column_label || col.label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type,
inputType: col.inputType || col.input_type,
webType: col.webType || col.web_type,
}))
);
} else {
setColumns([]);
}
} catch (error) {
console.error("컬럼 로드 실패:", error);
setColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [targetTableName]);
// 집계 항목 추가
const addItem = () => {
const newItem: AggregationItem = {
id: `agg-${Date.now()}`,
columnName: "",
columnLabel: "",
type: "sum",
format: "number",
decimalPlaces: 0,
};
onChange({
items: [...(config.items || []), newItem],
});
};
// 집계 항목 삭제
const removeItem = (id: string) => {
onChange({
items: (config.items || []).filter((item) => item.id !== id),
});
};
// 집계 항목 업데이트
const updateItem = (id: string, updates: Partial<AggregationItem>) => {
onChange({
items: (config.items || []).map((item) =>
item.id === id ? { ...item, ...updates } : item
),
});
};
// 숫자형 컬럼만 필터링 (count 제외) - 입력 타입(inputType/webType)으로만 확인
const numericColumns = columns.filter((col) => {
const inputType = (col.inputType || col.webType || "")?.toLowerCase();
return (
inputType === "number" ||
inputType === "decimal" ||
inputType === "integer" ||
inputType === "float" ||
inputType === "currency" ||
inputType === "percent"
);
});
return (
<div className="space-y-4">
<div className="text-sm font-medium"> </div>
{/* 테이블 설정 (컴포넌트 개발 가이드 준수) */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
{/* 현재 선택된 테이블 표시 (카드 형태) */}
<div className="flex items-center gap-2 rounded-md border bg-slate-50 p-2">
<Database className="h-4 w-4 text-blue-500" />
<div className="flex-1">
<div className="text-xs font-medium">
{config.customTableName || config.tableName || screenTableName || "테이블 미선택"}
</div>
<div className="text-[10px] text-muted-foreground">
{config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"}
</div>
</div>
</div>
{/* 테이블 선택 Combobox */}
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
...
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-xs"> </CommandEmpty>
{/* 그룹 1: 화면 기본 테이블 */}
{screenTableName && (
<CommandGroup heading="기본 (화면 테이블)">
<CommandItem
key={`default-${screenTableName}`}
value={screenTableName}
onSelect={() => {
onChange({
useCustomTable: false,
customTableName: undefined,
tableName: screenTableName,
items: [], // 테이블 변경 시 집계 항목 초기화
});
setTableComboboxOpen(false);
}}
className="text-xs cursor-pointer"
>
<Check
className={cn(
"mr-2 h-3 w-3",
!config.useCustomTable ? "opacity-100" : "opacity-0"
)}
/>
<Database className="mr-2 h-3 w-3 text-blue-500" />
{screenTableName}
</CommandItem>
</CommandGroup>
)}
{/* 그룹 2: 전체 테이블 */}
<CommandGroup heading="전체 테이블">
{availableTables
.filter((table) => table.tableName !== screenTableName)
.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName || ""}`}
onSelect={() => {
onChange({
useCustomTable: true,
customTableName: table.tableName,
tableName: table.tableName,
items: [], // 테이블 변경 시 집계 항목 초기화
});
setTableComboboxOpen(false);
}}
className="text-xs cursor-pointer"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.customTableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<Table2 className="mr-2 h-3 w-3 text-slate-400" />
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 레이아웃 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"></h3>
</div>
<hr className="border-border" />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.layout || "horizontal"}
onValueChange={(value) => onChange({ layout: value as "horizontal" | "vertical" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"> </SelectItem>
<SelectItem value="vertical"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.gap || "16px"}
onChange={(e) => onChange({ gap: e.target.value })}
placeholder="16px"
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="showLabels"
checked={config.showLabels ?? true}
onCheckedChange={(checked) => onChange({ showLabels: checked as boolean })}
/>
<Label htmlFor="showLabels" className="text-xs">
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="showIcons"
checked={config.showIcons ?? true}
onCheckedChange={(checked) => onChange({ showIcons: checked as boolean })}
/>
<Label htmlFor="showIcons" className="text-xs">
</Label>
</div>
</div>
</div>
{/* 집계 항목 설정 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
<Button variant="outline" size="sm" onClick={addItem} className="h-7 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<hr className="border-border" />
{(config.items || []).length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center text-xs text-muted-foreground">
</div>
) : (
<div className="space-y-3">
{(config.items || []).map((item, index) => (
<div
key={item.id}
className="rounded-md border bg-slate-50 p-3 space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<span className="text-xs font-medium"> {index + 1}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeItem(item.id)}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
{/* 컬럼 선택 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Select
value={item.columnName}
onValueChange={(value) => {
const col = columns.find((c) => c.columnName === value);
updateItem(item.id, {
columnName: value,
columnLabel: col?.label || value,
});
}}
disabled={loadingColumns || columns.length === 0}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder={
loadingColumns
? "로딩 중..."
: columns.length === 0
? "테이블을 선택하세요"
: "컬럼 선택"
} />
</SelectTrigger>
<SelectContent>
{(item.type === "count" ? columns : numericColumns).length === 0 ? (
<div className="p-2 text-xs text-muted-foreground text-center">
{item.type === "count"
? "컬럼이 없습니다"
: "숫자형 컬럼이 없습니다"}
</div>
) : (
(item.type === "count" ? columns : numericColumns).map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.label || col.columnName}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* 집계 타입 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={item.type}
onValueChange={(value) => updateItem(item.id, { type: value as AggregationType })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sum"> (SUM)</SelectItem>
<SelectItem value="avg"> (AVG)</SelectItem>
<SelectItem value="count"> (COUNT)</SelectItem>
<SelectItem value="max"> (MAX)</SelectItem>
<SelectItem value="min"> (MIN)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 표시 라벨 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={item.columnLabel || ""}
onChange={(e) => updateItem(item.id, { columnLabel: e.target.value })}
placeholder="표시될 라벨"
className="h-7 text-xs"
/>
</div>
{/* 표시 형식 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={item.format || "number"}
onValueChange={(value) =>
updateItem(item.id, { format: value as "number" | "currency" | "percent" })
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number"></SelectItem>
<SelectItem value="currency"></SelectItem>
<SelectItem value="percent"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 접두사 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={item.prefix || ""}
onChange={(e) => updateItem(item.id, { prefix: e.target.value })}
placeholder="예: ₩"
className="h-7 text-xs"
/>
</div>
{/* 접미사 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={item.suffix || ""}
onChange={(e) => updateItem(item.id, { suffix: e.target.value })}
placeholder="예: 원, 개"
className="h-7 text-xs"
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* 스타일 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"></h3>
</div>
<hr className="border-border" />
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
type="color"
value={config.backgroundColor || "#f8fafc"}
onChange={(e) => onChange({ backgroundColor: e.target.value })}
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.borderRadius || "6px"}
onChange={(e) => onChange({ borderRadius: e.target.value })}
placeholder="6px"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="color"
value={config.labelColor || "#64748b"}
onChange={(e) => onChange({ labelColor: e.target.value })}
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="color"
value={config.valueColor || "#0f172a"}
onChange={(e) => onChange({ valueColor: e.target.value })}
className="h-8"
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,12 @@
"use client";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { AggregationWidgetDefinition } from "./index";
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent(AggregationWidgetDefinition);
}
export {};

View File

@ -0,0 +1,43 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { AggregationWidgetWrapper } from "./AggregationWidgetComponent";
import { AggregationWidgetConfigPanel } from "./AggregationWidgetConfigPanel";
import type { AggregationWidgetConfig } from "./types";
/**
* AggregationWidget
* (, , )
*/
export const AggregationWidgetDefinition = createComponentDefinition({
id: "aggregation-widget",
name: "집계 위젯",
nameEng: "Aggregation Widget",
description: "데이터의 합계, 평균, 개수 등 집계 결과를 표시하는 위젯",
category: ComponentCategory.DISPLAY,
webType: "text",
component: AggregationWidgetWrapper,
defaultConfig: {
dataSourceType: "manual",
items: [],
layout: "horizontal",
showLabels: true,
showIcons: true,
gap: "16px",
backgroundColor: "#f8fafc",
borderRadius: "6px",
padding: "12px",
} as Partial<AggregationWidgetConfig>,
defaultSize: { width: 400, height: 60 },
configPanel: AggregationWidgetConfigPanel,
icon: "Calculator",
tags: ["집계", "합계", "평균", "개수", "통계", "데이터"],
version: "1.0.0",
author: "개발팀",
hidden: true, // v2-aggregation-widget 사용으로 패널에서 숨김
});
// 타입 내보내기
export type { AggregationWidgetConfig, AggregationItem, AggregationType, AggregationResult } from "./types";

View File

@ -0,0 +1,67 @@
import { ComponentConfig } from "@/types/component";
/**
*
*/
export type AggregationType = "sum" | "avg" | "count" | "max" | "min";
/**
*
*/
export interface AggregationItem {
id: string;
columnName: string; // 집계할 컬럼
columnLabel?: string; // 표시 라벨
labelLangKeyId?: number; // 다국어 키 ID
labelLangKey?: string; // 다국어 키
type: AggregationType; // 집계 타입
format?: "number" | "currency" | "percent"; // 표시 형식
decimalPlaces?: number; // 소수점 자릿수
prefix?: string; // 접두사 (예: "₩")
suffix?: string; // 접미사 (예: "원", "개")
}
/**
*
*/
export interface AggregationWidgetConfig extends ComponentConfig {
// 데이터 소스 설정
dataSourceType: "repeater" | "tableList" | "manual"; // 데이터 소스 타입
dataSourceComponentId?: string; // 연결할 컴포넌트 ID (repeater 또는 tableList)
// 컴포넌트별 테이블 설정 (개발 가이드 준수)
tableName?: string; // 사용할 테이블명
customTableName?: string; // 커스텀 테이블명
useCustomTable?: boolean; // true: customTableName 사용
// 집계 항목들
items: AggregationItem[];
// 레이아웃 설정
layout: "horizontal" | "vertical"; // 배치 방향
showLabels: boolean; // 라벨 표시 여부
showIcons: boolean; // 아이콘 표시 여부
gap?: string; // 항목 간 간격
// 스타일 설정
backgroundColor?: string;
borderRadius?: string;
padding?: string;
fontSize?: string;
labelFontSize?: string;
valueFontSize?: string;
labelColor?: string;
valueColor?: string;
}
/**
*
*/
export interface AggregationResult {
id: string;
label: string;
value: number | string;
formattedValue: string;
type: AggregationType;
}

View File

@ -406,23 +406,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 선택된 데이터가 없으면 비활성화
if (!hasSelection) {
console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label);
return true;
}
// 다중 선택 허용하지 않는 경우, 정확히 1개만 선택되어야 함
if (!allowMultiRowSelection && selectionCount !== 1) {
console.log("🚫 [ButtonPrimary] 정확히 1개 행 선택 필요 → 비활성화:", component.label, {
selectionCount,
allowMultiRowSelection,
});
return true;
}
console.log("✅ [ButtonPrimary] 행 선택 조건 충족:", component.label, {
selectionCount,
selectionSource,
});
return false;
}, [
component.componentConfig?.action,
@ -734,7 +725,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
if (!sourceProvider) {
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
console.log("🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...");
const allProviders = screenContext.getAllDataProviders();
@ -1059,9 +1050,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
if (groupByColumns && groupByColumns.length > 0 && effectiveSelectedRowsData.length > 1) {
// 첫 번째 그룹핑 컬럼 기준으로 중복 체크 (예: order_no)
const groupByColumn = groupByColumns[0];
const uniqueValues = new Set(
effectiveSelectedRowsData.map((row: any) => row[groupByColumn]).filter(Boolean)
);
const uniqueValues = new Set(effectiveSelectedRowsData.map((row: any) => row[groupByColumn]).filter(Boolean));
if (uniqueValues.size > 1) {
// 컬럼명을 한글로 변환 (order_no -> 수주번호)
@ -1144,10 +1133,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
splitPanelParentData,
// 🆕 분할 패널 컨텍스트 (quickInsert 등에서 좌측 패널 데이터 접근용)
splitPanelContext: splitPanelContext ? {
selectedLeftData: splitPanelContext.selectedLeftData,
refreshRightPanel: splitPanelContext.refreshRightPanel,
} : undefined,
splitPanelContext: splitPanelContext
? {
selectedLeftData: splitPanelContext.selectedLeftData,
refreshRightPanel: splitPanelContext.refreshRightPanel,
}
: undefined,
} as ButtonActionContext;
// 확인이 필요한 액션인지 확인 (save/delete만 확인 다이얼로그 표시)
@ -1268,15 +1259,16 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수)
const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
const finalDisabled =
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
// 공통 버튼 스타일
// 🔧 component.style에서 background/backgroundColor 충돌 방지
const userStyle = component.style
? Object.fromEntries(
Object.entries(component.style).filter(
([key]) => !["width", "height", "background", "backgroundColor"].includes(key)
)
([key]) => !["width", "height", "background", "backgroundColor"].includes(key),
),
)
: {};

View File

@ -36,6 +36,7 @@ export const ButtonPrimaryDefinition = createComponentDefinition({
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/button-primary",
hidden: true, // v2-button-primary 사용으로 패널에서 숨김
});
// 컴포넌트는 ButtonPrimaryRenderer에서 자동 등록됩니다

View File

@ -1,7 +1,8 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableManagementApi } from "@/lib/api/tableManagement";
import {
Select,
SelectContent,
@ -11,11 +12,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Trash2 } from "lucide-react";
import { Trash2, Database, ChevronsUpDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";
interface CardDisplayConfigPanelProps {
config: any;
@ -57,6 +61,13 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
screenTableName,
tableColumns = [],
}) => {
// 테이블 선택 상태
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [availableColumns, setAvailableColumns] = useState<any[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
// 엔티티 조인 컬럼 상태
const [entityJoinColumns, setEntityJoinColumns] = useState<{
availableColumns: EntityJoinColumn[];
@ -64,18 +75,80 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
}>({ availableColumns: [], joinTables: [] });
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
// 현재 사용할 테이블명
const targetTableName = useMemo(() => {
if (config.useCustomTable && config.customTableName) {
return config.customTableName;
}
return config.tableName || screenTableName;
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
// 전체 테이블 목록 로드
useEffect(() => {
const loadAllTables = async () => {
setLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.tableLabel || t.displayName || t.tableName || t.table_name,
})));
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setLoadingTables(false);
}
};
loadAllTables();
}, []);
// 선택된 테이블의 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!targetTableName) {
setAvailableColumns([]);
return;
}
// 커스텀 테이블이 아니면 props로 받은 tableColumns 사용
if (!config.useCustomTable && tableColumns && tableColumns.length > 0) {
setAvailableColumns(tableColumns);
return;
}
setLoadingColumns(true);
try {
const result = await tableManagementApi.getColumnList(targetTableName);
if (result.success && result.data?.columns) {
setAvailableColumns(result.data.columns.map((col: any) => ({
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
})));
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setAvailableColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [targetTableName, config.useCustomTable, tableColumns]);
// 엔티티 조인 컬럼 정보 가져오기
useEffect(() => {
const fetchEntityJoinColumns = async () => {
const tableName = config.tableName || screenTableName;
if (!tableName) {
if (!targetTableName) {
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
return;
}
setLoadingEntityJoins(true);
try {
const result = await entityJoinApi.getEntityJoinColumns(tableName);
const result = await entityJoinApi.getEntityJoinColumns(targetTableName);
setEntityJoinColumns({
availableColumns: result.availableColumns || [],
joinTables: result.joinTables || [],
@ -89,7 +162,38 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
};
fetchEntityJoinColumns();
}, [config.tableName, screenTableName]);
}, [targetTableName]);
// 테이블 선택 핸들러
const handleTableSelect = (tableName: string, isScreenTable: boolean) => {
if (isScreenTable) {
// 화면 기본 테이블 선택
onChange({
...config,
useCustomTable: false,
customTableName: undefined,
tableName: tableName,
columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화
});
} else {
// 다른 테이블 선택
onChange({
...config,
useCustomTable: true,
customTableName: tableName,
tableName: tableName,
columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화
});
}
setTableComboboxOpen(false);
};
// 현재 선택된 테이블 표시명 가져오기
const getSelectedTableDisplay = () => {
if (!targetTableName) return "테이블을 선택하세요";
const found = allTables.find(t => t.tableName === targetTableName);
return found?.displayName || targetTableName;
};
const handleChange = (key: string, value: any) => {
onChange({ ...config, [key]: value });
@ -219,6 +323,9 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
joinColumnsByTable[col.tableName].push(col);
});
// 현재 사용할 컬럼 목록 (커스텀 테이블이면 로드한 컬럼, 아니면 props)
const currentTableColumns = config.useCustomTable ? availableColumns : (tableColumns.length > 0 ? tableColumns : availableColumns);
// 컬럼 선택 셀렉트 박스 렌더링 (Shadcn UI)
const renderColumnSelect = (
value: string,
@ -240,12 +347,12 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
</SelectItem>
{/* 기본 테이블 컬럼 */}
{tableColumns.length > 0 && (
{currentTableColumns.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs font-semibold text-muted-foreground">
</SelectLabel>
{tableColumns.map((column) => (
{currentTableColumns.map((column: any) => (
<SelectItem
key={column.columnName}
value={column.columnName}
@ -283,13 +390,99 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
<div className="space-y-4">
<div className="text-sm font-medium"> </div>
{/* 테이블 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-9 w-full justify-between text-xs"
disabled={loadingTables}
>
<div className="flex items-center gap-2 truncate">
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{getSelectedTableDisplay()}</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
.
</CommandEmpty>
{/* 화면 기본 테이블 */}
{screenTableName && (
<CommandGroup heading="기본 (화면 테이블)">
<CommandItem
value={screenTableName}
onSelect={() => handleTableSelect(screenTableName, true)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
targetTableName === screenTableName && !config.useCustomTable
? "opacity-100"
: "opacity-0"
)}
/>
<Database className="mr-2 h-3.5 w-3.5 text-blue-500" />
{allTables.find(t => t.tableName === screenTableName)?.displayName || screenTableName}
</CommandItem>
</CommandGroup>
)}
{/* 전체 테이블 */}
<CommandGroup heading="전체 테이블">
{allTables
.filter(t => t.tableName !== screenTableName)
.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => handleTableSelect(table.tableName, false)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.useCustomTable && targetTableName === table.tableName
? "opacity-100"
: "opacity-0"
)}
/>
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate">{table.displayName}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{config.useCustomTable && (
<p className="text-[10px] text-muted-foreground">
.
</p>
)}
</div>
{/* 테이블이 선택된 경우 컬럼 매핑 설정 */}
{tableColumns && tableColumns.length > 0 && (
{(currentTableColumns.length > 0 || loadingColumns) && (
<div className="space-y-3">
<h5 className="text-xs font-medium text-muted-foreground"> </h5>
{loadingEntityJoins && (
<div className="text-xs text-muted-foreground"> ...</div>
{(loadingEntityJoins || loadingColumns) && (
<div className="text-xs text-muted-foreground">
{loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."}
</div>
)}
<div className="space-y-1">

View File

@ -45,6 +45,7 @@ export const CardDisplayDefinition = createComponentDefinition({
author: "개발팀",
documentation:
"테이블 데이터를 카드 형태로 표시하는 컴포넌트입니다. 레이아웃과 다르게 컴포넌트로서 재사용 가능하며, 다양한 설정이 가능합니다.",
hidden: true, // v2-card-display 사용으로 패널에서 숨김
});
// 컴포넌트는 CardDisplayRenderer에서 자동 등록됩니다

View File

@ -45,6 +45,12 @@ export interface CardDisplayConfig extends ComponentConfig {
// 컬럼 매핑 설정
columnMapping?: ColumnMappingConfig;
// 컴포넌트별 테이블 설정
useCustomTable?: boolean;
customTableName?: string;
tableName?: string;
isReadOnly?: boolean;
// 테이블 데이터 설정
dataSource?: "static" | "table" | "api";
tableId?: string;

View File

@ -31,6 +31,7 @@ export const DividerLineDefinition = createComponentDefinition({
version: "1.0.0",
author: "Developer",
documentation: "https://docs.example.com/components/divider-line",
hidden: true, // v2-divider-line 사용으로 패널에서 숨김
});
// 타입 내보내기

View File

@ -16,8 +16,7 @@ import { initializeHotReload } from "../utils/hotReload";
* CLI로 import만
*/
// 예시 컴포넌트들 (CLI로 생성 후 주석 해제)
import "./button-primary/ButtonPrimaryRenderer";
// 기본 입력 컴포넌트들 (v2 버전 없음 - 유지)
import "./text-input/TextInputRenderer";
import "./textarea-basic/TextareaBasicRenderer";
import "./number-input/NumberInputRenderer";
@ -25,71 +24,88 @@ import "./select-basic/SelectBasicRenderer";
import "./checkbox-basic/CheckboxBasicRenderer";
import "./radio-basic/RadioBasicRenderer";
import "./date-input/DateInputRenderer";
// import "./label-basic/LabelBasicRenderer"; // 제거됨 - text-display로 교체
import "./text-display/TextDisplayRenderer";
import "./file-upload/FileUploadRenderer";
import "./image-widget/ImageWidgetRenderer";
import "./slider-basic/SliderBasicRenderer";
import "./toggle-switch/ToggleSwitchRenderer";
import "./image-display/ImageDisplayRenderer";
import "./divider-line/DividerLineRenderer";
import "./accordion-basic/AccordionBasicRenderer";
import "./table-list/TableListRenderer";
import "./card-display/CardDisplayRenderer";
import "./split-panel-layout/SplitPanelLayoutRenderer";
import "./accordion-basic/AccordionBasicRenderer"; // 컴포넌트 패널에서만 숨김
import "./split-panel-layout2/SplitPanelLayout2Renderer"; // 분할 패널 레이아웃 v2
import "./map/MapRenderer";
import "./repeater-field-group/RepeaterFieldGroupRenderer";
import "./flow-widget/FlowWidgetRenderer";
import "./numbering-rule/NumberingRuleRenderer";
import "./category-manager/CategoryManagerRenderer";
import "./table-search-widget"; // 🆕 테이블 검색 필터 위젯
import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처별 품목정보
import "./customer-item-mapping/CustomerItemMappingRenderer"; // 거래처별 품목정보
// 🆕 수주 등록 관련 컴포넌트들
// 수주 등록 관련 컴포넌트들 (v2 버전 없음 - 유지)
import "./autocomplete-search-input/AutocompleteSearchInputRenderer";
import "./entity-search-input/EntitySearchInputRenderer";
import "./modal-repeater-table/ModalRepeaterTableRenderer";
import "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 🆕 단순 반복 테이블
import "./repeat-screen-modal/RepeatScreenModalRenderer"; // 🆕 반복 화면 모달 (카드 형태)
import "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 단순 반복 테이블
// 🆕 조건부 컨테이너 컴포넌트
import "./conditional-container/ConditionalContainerRenderer";
// 조건부 컨테이너 컴포넌트
import "./conditional-container/ConditionalContainerRenderer"; // 컴포넌트 패널에서만 숨김
import "./selected-items-detail-input/SelectedItemsDetailInputRenderer";
// 🆕 섹션 그룹화 레이아웃 컴포넌트
import "./section-paper/SectionPaperRenderer"; // Section Paper (색종이 - 배경색 기반 그룹화) - Renderer 방식
import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 기반 그룹화) - Renderer 방식
// 🆕 탭 컴포넌트
import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트
// 🆕 반복 화면 모달 컴포넌트
import "./repeat-screen-modal/RepeatScreenModalRenderer";
// 🆕 출발지/도착지 선택 컴포넌트
import "./location-swap-selector/LocationSwapSelectorRenderer";
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
// 화면 임베딩 및 분할 패널 컴포넌트
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
// 🆕 범용 폼 모달 컴포넌트
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
// 범용 폼 모달 컴포넌트
import "./universal-form-modal/UniversalFormModalRenderer"; // 컴포넌트 패널에서만 숨김
// 🆕 렉 구조 설정 컴포넌트
import "./rack-structure/RackStructureRenderer"; // 창고 렉 위치 일괄 생성
// 🆕 세금계산서 관리 컴포넌트
// 세금계산서 관리 컴포넌트
import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, 작성, 발행, 취소
// 🆕 메일 수신자 선택 컴포넌트
// 메일 수신자 선택 컴포넌트
import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인원 선택 + 외부 이메일 입력
// 🆕 연관 데이터 버튼 컴포넌트
// 연관 데이터 버튼 컴포넌트
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
// 🆕 피벗 그리드 컴포넌트
import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화, 집계, 드릴다운)
// ============================================================
// 기존 컴포넌트들 (기존 화면 호환성 유지)
// V2 버전도 별도로 존재하지만, 기존 화면은 이 컴포넌트들을 사용
// ============================================================
import "./button-primary/ButtonPrimaryRenderer";
import "./text-display/TextDisplayRenderer";
import "./divider-line/DividerLineRenderer";
import "./table-list/TableListRenderer";
import "./card-display/CardDisplayRenderer";
import "./split-panel-layout/SplitPanelLayoutRenderer";
import "./numbering-rule/NumberingRuleRenderer";
import "./table-search-widget";
import "./repeat-screen-modal/RepeatScreenModalRenderer";
import "./section-paper/SectionPaperRenderer";
import "./section-card/SectionCardRenderer";
import "./tabs/tabs-component";
import "./location-swap-selector/LocationSwapSelectorRenderer";
import "./rack-structure/RackStructureRenderer";
import "./unified-repeater/UnifiedRepeaterRenderer";
import "./pivot-grid/PivotGridRenderer";
import "./aggregation-widget/AggregationWidgetRenderer";
import "./repeat-container/RepeatContainerRenderer";
// ============================================================
// V2 컴포넌트들 (화면관리 전용 - 충돌 방지용 별도 버전)
// ============================================================
import "./v2-unified-repeater/UnifiedRepeaterRenderer";
import "./v2-button-primary/ButtonPrimaryRenderer";
import "./v2-split-panel-layout/SplitPanelLayoutRenderer";
import "./v2-aggregation-widget/AggregationWidgetRenderer";
import "./v2-card-display/CardDisplayRenderer";
import "./v2-numbering-rule/NumberingRuleRenderer";
import "./v2-table-list/TableListRenderer";
import "./v2-text-display/TextDisplayRenderer";
import "./v2-pivot-grid/PivotGridRenderer";
import "./v2-repeat-screen-modal/RepeatScreenModalRenderer";
import "./v2-divider-line/DividerLineRenderer";
import "./v2-repeat-container/RepeatContainerRenderer";
import "./v2-section-card/SectionCardRenderer";
import "./v2-section-paper/SectionPaperRenderer";
import "./v2-rack-structure/RackStructureRenderer";
import "./v2-location-swap-selector/LocationSwapSelectorRenderer";
import "./v2-table-search-widget";
import "./v2-tabs-widget/tabs-component";
/**
*

View File

@ -49,6 +49,7 @@ export const LocationSwapSelectorDefinition = createComponentDefinition({
tags: ["출발지", "도착지", "교환", "스왑", "위치", "모바일"],
version: "1.0.0",
author: "개발팀",
hidden: true, // v2-location-swap-selector 사용으로 패널에서 숨김
});
// 컴포넌트 내보내기

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