feat: V2 컴포넌트 설정 스키마 정비 및 화면 복제 기능 개선
- 레거시 컴포넌트를 제거하고, V2/V2 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 통합 관리합니다. - V2 컴포넌트의 overrides 스키마를 정의하고, 관련된 설정 패널을 통합하였습니다. - 화면 복제 기능을 개선하여 DB 구조 개편 후의 효율적인 화면 관리를 지원하며, 버튼의 `targetScreenId` 매핑 버그를 수정하였습니다. - 프리뷰 모드에서 URL 파라미터의 company_code를 우선 사용하도록 변경하였습니다. - UnifiedRepeater 및 UnifiedSelect 컴포넌트를 추가하여 다양한 데이터 관리 기능을 지원합니다.
This commit is contained in:
parent
3ab8c9b5a0
commit
924c95ab89
22
PLAN.MD
22
PLAN.MD
|
|
@ -1,15 +1,18 @@
|
||||||
# 프로젝트: V2/V2 컴포넌트 설정 스키마 정비
|
# 프로젝트: V2/V2 컴포넌트 설정 스키마 정비
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
|
|
||||||
레거시 컴포넌트를 제거하고, V2/V2 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 한 곳에서 관리한다.
|
레거시 컴포넌트를 제거하고, V2/V2 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 한 곳에서 관리한다.
|
||||||
|
|
||||||
## 핵심 기능
|
## 핵심 기능
|
||||||
|
|
||||||
1. [x] 레거시 컴포넌트 스키마 제거
|
1. [x] 레거시 컴포넌트 스키마 제거
|
||||||
2. [x] V2 컴포넌트 overrides 스키마 정의 (16개)
|
2. [x] V2 컴포넌트 overrides 스키마 정의 (16개)
|
||||||
3. [x] V2 컴포넌트 overrides 스키마 정의 (9개)
|
3. [x] V2 컴포넌트 overrides 스키마 정의 (9개)
|
||||||
4. [x] componentConfig.ts 한 파일에서 통합 관리
|
4. [x] componentConfig.ts 한 파일에서 통합 관리
|
||||||
|
|
||||||
## 정의된 V2 컴포넌트 (18개)
|
## 정의된 V2 컴포넌트 (18개)
|
||||||
|
|
||||||
- v2-table-list, v2-button-primary, v2-text-display
|
- v2-table-list, v2-button-primary, v2-text-display
|
||||||
- v2-split-panel-layout, v2-section-card, v2-section-paper
|
- v2-split-panel-layout, v2-section-card, v2-section-paper
|
||||||
- v2-divider-line, v2-repeat-container, v2-rack-structure
|
- v2-divider-line, v2-repeat-container, v2-rack-structure
|
||||||
|
|
@ -19,45 +22,56 @@
|
||||||
- v2-v2-repeater
|
- v2-v2-repeater
|
||||||
|
|
||||||
## 정의된 V2 컴포넌트 (9개)
|
## 정의된 V2 컴포넌트 (9개)
|
||||||
|
|
||||||
- v2-input, v2-select, v2-date
|
- v2-input, v2-select, v2-date
|
||||||
- v2-list, v2-layout, v2-group
|
- v2-list, v2-layout, v2-group
|
||||||
- v2-media, v2-biz, v2-hierarchy
|
- v2-media, v2-biz, v2-hierarchy
|
||||||
|
|
||||||
## 테스트 계획
|
## 테스트 계획
|
||||||
|
|
||||||
### 1단계: 기본 기능
|
### 1단계: 기본 기능
|
||||||
|
|
||||||
- [x] V2 레이아웃 저장 시 컴포넌트별 overrides 스키마 검증 통과
|
- [x] V2 레이아웃 저장 시 컴포넌트별 overrides 스키마 검증 통과
|
||||||
- [x] V2 컴포넌트 기본값과 스키마가 매칭됨
|
- [x] V2 컴포넌트 기본값과 스키마가 매칭됨
|
||||||
|
|
||||||
### 2단계: 에러 케이스
|
### 2단계: 에러 케이스
|
||||||
|
|
||||||
- [x] 잘못된 overrides 입력 시 Zod 검증 실패 처리 (safeParse + console.warn + graceful fallback)
|
- [x] 잘못된 overrides 입력 시 Zod 검증 실패 처리 (safeParse + console.warn + graceful fallback)
|
||||||
- [x] 누락된 기본값 컴포넌트 저장 시 안전한 기본값 적용 (레지스트리 조회 → 빈 객체)
|
- [x] 누락된 기본값 컴포넌트 저장 시 안전한 기본값 적용 (레지스트리 조회 → 빈 객체)
|
||||||
|
|
||||||
## 에러 처리 계획
|
## 에러 처리 계획
|
||||||
|
|
||||||
- 스키마 파싱 실패 시 로그/에러 메시지 표준화
|
- 스키마 파싱 실패 시 로그/에러 메시지 표준화
|
||||||
- 기본값 누락 시 안전한 fallback 적용
|
- 기본값 누락 시 안전한 fallback 적용
|
||||||
|
|
||||||
## 진행 상태
|
## 진행 상태
|
||||||
|
|
||||||
- [x] 레거시 컴포넌트 제거 완료
|
- [x] 레거시 컴포넌트 제거 완료
|
||||||
- [x] V2/V2 스키마 정의 완료
|
- [x] V2/V2 스키마 정의 완료
|
||||||
- [x] 한 파일 통합 관리 완료
|
- [x] 한 파일 통합 관리 완료
|
||||||
|
|
||||||
# 프로젝트: 화면 복제 기능 개선 (DB 구조 개편 후)
|
# 프로젝트: 화면 복제 기능 개선 (DB 구조 개편 후)
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
|
|
||||||
채번/카테고리에서 `menu_objid` 의존성 제거 완료 후, 화면 복제 기능을 새 DB 구조에 맞게 수정하고 테스트합니다.
|
채번/카테고리에서 `menu_objid` 의존성 제거 완료 후, 화면 복제 기능을 새 DB 구조에 맞게 수정하고 테스트합니다.
|
||||||
|
|
||||||
## 핵심 변경사항
|
## 핵심 변경사항
|
||||||
|
|
||||||
### DB 구조 변경 (완료)
|
### DB 구조 변경 (완료)
|
||||||
|
|
||||||
- 채번규칙: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반
|
- 채번규칙: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반
|
||||||
- 카테고리: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반
|
- 카테고리: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반
|
||||||
- 복제 순서 의존성 문제 해결
|
- 복제 순서 의존성 문제 해결
|
||||||
|
|
||||||
### 복제 옵션 정리 (완료)
|
### 복제 옵션 정리 (완료)
|
||||||
|
|
||||||
- [x] **삭제**: 코드 카테고리 + 코드 복사 옵션
|
- [x] **삭제**: 코드 카테고리 + 코드 복사 옵션
|
||||||
- [x] **삭제**: 연쇄관계 설정 복사 옵션
|
- [x] **삭제**: 연쇄관계 설정 복사 옵션
|
||||||
- [x] **이름 변경**: "카테고리 매핑 + 값 복사" → "카테고리 값 복사"
|
- [x] **이름 변경**: "카테고리 매핑 + 값 복사" → "카테고리 값 복사"
|
||||||
|
|
||||||
### 현재 복제 옵션 (3개)
|
### 현재 복제 옵션 (3개)
|
||||||
|
|
||||||
1. **채번 규칙 복사** - 채번규칙 복제
|
1. **채번 규칙 복사** - 채번규칙 복제
|
||||||
2. **카테고리 값 복사** - 카테고리 값 복제 (table_column_category_values)
|
2. **카테고리 값 복사** - 카테고리 값 복제 (table_column_category_values)
|
||||||
3. **테이블 타입관리 입력타입 설정 복사** - table_type_columns 복제
|
3. **테이블 타입관리 입력타입 설정 복사** - table_type_columns 복제
|
||||||
|
|
@ -67,20 +81,24 @@
|
||||||
## 테스트 계획
|
## 테스트 계획
|
||||||
|
|
||||||
### 1. 화면 간 연결 복제 테스트
|
### 1. 화면 간 연결 복제 테스트
|
||||||
|
|
||||||
- [ ] 수주관리 1번→2번→3번→4번 화면 연결 상태에서 복제
|
- [ ] 수주관리 1번→2번→3번→4번 화면 연결 상태에서 복제
|
||||||
- [ ] 복제 후 연결 관계가 유지되는지 확인
|
- [ ] 복제 후 연결 관계가 유지되는지 확인
|
||||||
- [ ] 각 화면의 고유 키값이 새로운 화면을 참조하도록 변경되는지 확인
|
- [ ] 각 화면의 고유 키값이 새로운 화면을 참조하도록 변경되는지 확인
|
||||||
|
|
||||||
### 2. 제어관리 복제 테스트
|
### 2. 제어관리 복제 테스트
|
||||||
|
|
||||||
- [ ] 다른 회사로 제어관리 복제
|
- [ ] 다른 회사로 제어관리 복제
|
||||||
- [ ] 복제된 플로우 스텝/연결이 정상 작동하는지 확인
|
- [ ] 복제된 플로우 스텝/연결이 정상 작동하는지 확인
|
||||||
|
|
||||||
### 3. 추가 옵션 복제 테스트
|
### 3. 추가 옵션 복제 테스트
|
||||||
|
|
||||||
- [ ] 채번규칙 복사 정상 작동 확인
|
- [ ] 채번규칙 복사 정상 작동 확인
|
||||||
- [ ] 카테고리 값 복사 정상 작동 확인
|
- [ ] 카테고리 값 복사 정상 작동 확인
|
||||||
- [ ] 테이블 타입관리 입력타입 설정 복사 정상 작동 확인
|
- [ ] 테이블 타입관리 입력타입 설정 복사 정상 작동 확인
|
||||||
|
|
||||||
### 4. 기본 복제 테스트
|
### 4. 기본 복제 테스트
|
||||||
|
|
||||||
- [ ] 단일 화면 복제 (모달 포함)
|
- [ ] 단일 화면 복제 (모달 포함)
|
||||||
- [ ] 그룹 전체 복제 (재귀적)
|
- [ ] 그룹 전체 복제 (재귀적)
|
||||||
- [ ] 메뉴 동기화 정상 작동
|
- [ ] 메뉴 동기화 정상 작동
|
||||||
|
|
@ -88,6 +106,7 @@
|
||||||
---
|
---
|
||||||
|
|
||||||
## 관련 파일
|
## 관련 파일
|
||||||
|
|
||||||
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
|
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
|
||||||
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
|
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
|
||||||
- `backend-node/src/services/screenManagementService.ts` - 복제 서비스
|
- `backend-node/src/services/screenManagementService.ts` - 복제 서비스
|
||||||
|
|
@ -95,6 +114,7 @@
|
||||||
- `docs/DB_STRUCTURE_DIAGRAM.md` - DB 구조 문서
|
- `docs/DB_STRUCTURE_DIAGRAM.md` - DB 구조 문서
|
||||||
|
|
||||||
## 진행 상태
|
## 진행 상태
|
||||||
|
|
||||||
- [완료] DB 구조 개편 (menu_objid 의존성 제거)
|
- [완료] DB 구조 개편 (menu_objid 의존성 제거)
|
||||||
- [완료] 복제 옵션 정리 (코드카테고리/연쇄관계 삭제, 이름 변경)
|
- [완료] 복제 옵션 정리 (코드카테고리/연쇄관계 삭제, 이름 변경)
|
||||||
- [완료] 화면 간 연결 복제 버그 수정 (targetScreenId 매핑 추가)
|
- [완료] 화면 간 연결 복제 버그 수정 (targetScreenId 매핑 추가)
|
||||||
|
|
@ -109,9 +129,11 @@
|
||||||
### 2026-01-26: 버튼 targetScreenId 매핑 버그 수정
|
### 2026-01-26: 버튼 targetScreenId 매핑 버그 수정
|
||||||
|
|
||||||
**문제**: 그룹 복제 시 버튼의 `targetScreenId`가 새 화면으로 매핑되지 않음
|
**문제**: 그룹 복제 시 버튼의 `targetScreenId`가 새 화면으로 매핑되지 않음
|
||||||
|
|
||||||
- 수주관리 1→2→3→4 화면 복제 시 연결이 깨지는 문제
|
- 수주관리 1→2→3→4 화면 복제 시 연결이 깨지는 문제
|
||||||
|
|
||||||
**수정 파일**: `backend-node/src/services/screenManagementService.ts`
|
**수정 파일**: `backend-node/src/services/screenManagementService.ts`
|
||||||
|
|
||||||
- `updateTabScreenReferences` 함수에 `targetScreenId` 처리 로직 추가
|
- `updateTabScreenReferences` 함수에 `targetScreenId` 처리 로직 추가
|
||||||
- 쿼리에 `targetScreenId` 검색 조건 추가
|
- 쿼리에 `targetScreenId` 검색 조건 추가
|
||||||
- 문자열/숫자 타입 모두 처리
|
- 문자열/숫자 타입 모두 처리
|
||||||
|
|
|
||||||
|
|
@ -696,10 +696,11 @@ export const getLayoutV1 = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
export const getLayoutV2 = async (req: AuthenticatedRequest, res: Response) => {
|
export const getLayoutV2 = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { screenId } = req.params;
|
const { screenId } = req.params;
|
||||||
const { companyCode } = req.user as any;
|
const { companyCode, userType } = req.user as any;
|
||||||
const layout = await screenManagementService.getLayoutV2(
|
const layout = await screenManagementService.getLayoutV2(
|
||||||
parseInt(screenId),
|
parseInt(screenId),
|
||||||
companyCode
|
companyCode,
|
||||||
|
userType
|
||||||
);
|
);
|
||||||
res.json({ success: true, data: layout });
|
res.json({ success: true, data: layout });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -279,11 +279,7 @@ function ScreenViewPage() {
|
||||||
const conditional = (component as any).conditional;
|
const conditional = (component as any).conditional;
|
||||||
if (!conditional?.enabled) return;
|
if (!conditional?.enabled) return;
|
||||||
|
|
||||||
const conditionalResult = evaluateConditional(
|
const conditionalResult = evaluateConditional(conditional, formData as Record<string, any>, layout.components);
|
||||||
conditional,
|
|
||||||
formData as Record<string, any>,
|
|
||||||
layout.components,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 숨김 또는 비활성화 상태인 경우
|
// 숨김 또는 비활성화 상태인 경우
|
||||||
if (!conditionalResult.visible || conditionalResult.disabled) {
|
if (!conditionalResult.visible || conditionalResult.disabled) {
|
||||||
|
|
@ -414,7 +410,10 @@ function ScreenViewPage() {
|
||||||
<ScreenPreviewProvider isPreviewMode={false}>
|
<ScreenPreviewProvider isPreviewMode={false}>
|
||||||
<ActiveTabProvider>
|
<ActiveTabProvider>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div ref={containerRef} className={`bg-background h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}>
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`bg-background h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}
|
||||||
|
>
|
||||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||||
{!layoutReady && (
|
{!layoutReady && (
|
||||||
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
||||||
|
|
@ -604,7 +603,14 @@ function ScreenViewPage() {
|
||||||
sortOrder={tableSortOrder}
|
sortOrder={tableSortOrder}
|
||||||
columnOrder={tableColumnOrder}
|
columnOrder={tableColumnOrder}
|
||||||
tableDisplayData={tableDisplayData}
|
tableDisplayData={tableDisplayData}
|
||||||
onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => {
|
onSelectedRowsChange={(
|
||||||
|
_,
|
||||||
|
selectedData,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
columnOrder,
|
||||||
|
tableDisplayData,
|
||||||
|
) => {
|
||||||
setSelectedRowsData(selectedData);
|
setSelectedRowsData(selectedData);
|
||||||
setTableSortBy(sortBy);
|
setTableSortBy(sortBy);
|
||||||
setTableSortOrder(sortOrder || "asc");
|
setTableSortOrder(sortOrder || "asc");
|
||||||
|
|
|
||||||
|
|
@ -1081,12 +1081,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
// detailSettings 파싱 (문자열이면 JSON 파싱)
|
// detailSettings 파싱 (문자열이면 JSON 파싱)
|
||||||
let detailSettings = col.detailSettings || col.detail_settings;
|
let detailSettings = col.detailSettings || col.detail_settings;
|
||||||
if (typeof detailSettings === "string") {
|
if (typeof detailSettings === "string") {
|
||||||
|
// JSON 형식인 경우에만 파싱 시도 (중괄호로 시작하는 경우)
|
||||||
|
if (detailSettings.trim().startsWith("{")) {
|
||||||
try {
|
try {
|
||||||
detailSettings = JSON.parse(detailSettings);
|
detailSettings = JSON.parse(detailSettings);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("detailSettings 파싱 실패:", e);
|
console.warn("detailSettings 파싱 실패:", e);
|
||||||
detailSettings = {};
|
detailSettings = {};
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// JSON이 아닌 일반 문자열인 경우 빈 객체로 처리
|
||||||
|
detailSettings = {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 엔티티 타입 디버깅
|
// 엔티티 타입 디버깅
|
||||||
|
|
|
||||||
|
|
@ -528,6 +528,7 @@ export function ScreenSettingModal({
|
||||||
{/* ScreenDesigner 전체 화면 모달 */}
|
{/* ScreenDesigner 전체 화면 모달 */}
|
||||||
<Dialog open={showDesignerModal} onOpenChange={setShowDesignerModal}>
|
<Dialog open={showDesignerModal} onOpenChange={setShowDesignerModal}>
|
||||||
<DialogContent className="max-w-[98vw] h-[95vh] p-0 overflow-hidden">
|
<DialogContent className="max-w-[98vw] h-[95vh] p-0 overflow-hidden">
|
||||||
|
<DialogTitle className="sr-only">화면 디자이너</DialogTitle>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<ScreenDesigner
|
<ScreenDesigner
|
||||||
selectedScreen={{
|
selectedScreen={{
|
||||||
|
|
@ -4547,6 +4548,7 @@ function ControlManagementTab({
|
||||||
{/* FlowEditor 전체 화면 모달 */}
|
{/* FlowEditor 전체 화면 모달 */}
|
||||||
<Dialog open={showFlowEditorModal} onOpenChange={setShowFlowEditorModal}>
|
<Dialog open={showFlowEditorModal} onOpenChange={setShowFlowEditorModal}>
|
||||||
<DialogContent className="max-w-[98vw] h-[95vh] p-0 overflow-hidden">
|
<DialogContent className="max-w-[98vw] h-[95vh] p-0 overflow-hidden">
|
||||||
|
<DialogTitle className="sr-only">플로우 편집기</DialogTitle>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-background">
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-background">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,948 @@
|
||||||
|
"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";
|
||||||
|
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
||||||
|
|
||||||
|
// 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";
|
||||||
|
|
||||||
|
// 전역 리피터 등록
|
||||||
|
// 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블)
|
||||||
|
useEffect(() => {
|
||||||
|
const targetTableName =
|
||||||
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||||
|
|
||||||
|
if (targetTableName) {
|
||||||
|
if (!window.__unifiedRepeaterInstances) {
|
||||||
|
window.__unifiedRepeaterInstances = new Set();
|
||||||
|
}
|
||||||
|
window.__unifiedRepeaterInstances.add(targetTableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (targetTableName && window.__unifiedRepeaterInstances) {
|
||||||
|
window.__unifiedRepeaterInstances.delete(targetTableName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [config.useCustomTable, config.mainTableName, 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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnifiedRepeater 저장 시작
|
||||||
|
const saveInfo = {
|
||||||
|
tableName,
|
||||||
|
useCustomTable: config.useCustomTable,
|
||||||
|
mainTableName: config.mainTableName,
|
||||||
|
foreignKeyColumn: config.foreignKeyColumn,
|
||||||
|
masterRecordId,
|
||||||
|
dataLength: data.length,
|
||||||
|
};
|
||||||
|
console.log("UnifiedRepeater 저장 시작", saveInfo);
|
||||||
|
|
||||||
|
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 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용
|
||||||
|
if (config.foreignKeyColumn) {
|
||||||
|
// foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용
|
||||||
|
// 없으면 마스터 레코드 ID 사용 (기존 동작)
|
||||||
|
const sourceColumn = config.foreignKeySourceColumn;
|
||||||
|
let fkValue: any;
|
||||||
|
|
||||||
|
if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) {
|
||||||
|
// mainFormData에서 참조 컬럼 값 가져오기
|
||||||
|
fkValue = mainFormData[sourceColumn];
|
||||||
|
} else {
|
||||||
|
// 기본: 마스터 레코드 ID 사용
|
||||||
|
fkValue = masterRecordId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fkValue !== undefined && fkValue !== null) {
|
||||||
|
mergedData[config.foreignKeyColumn] = fkValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ UnifiedRepeater 저장 실패:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// V2 EventBus 구독
|
||||||
|
const unsubscribe = v2EventBus.subscribe(
|
||||||
|
V2_EVENTS.REPEATER_SAVE,
|
||||||
|
async (payload) => {
|
||||||
|
const tableName =
|
||||||
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||||
|
if (payload.tableName === tableName) {
|
||||||
|
await handleSaveEvent({ detail: payload } as CustomEvent);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ componentId: `unified-repeater-${config.dataSource?.tableName}` },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||||
|
window.addEventListener("repeaterSave" as any, handleSaveEvent);
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
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";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용)
|
||||||
|
if (onDataChange) {
|
||||||
|
const targetTable =
|
||||||
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||||
|
|
||||||
|
if (targetTable) {
|
||||||
|
// 각 행에 _targetTable 추가
|
||||||
|
const dataWithTarget = newData.map((row) => ({
|
||||||
|
...row,
|
||||||
|
_targetTable: targetTable,
|
||||||
|
}));
|
||||||
|
onDataChange(dataWithTarget);
|
||||||
|
} else {
|
||||||
|
onDataChange(newData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
|
||||||
|
setAutoWidthTrigger((prev) => prev + 1);
|
||||||
|
},
|
||||||
|
[onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 행 변경 핸들러
|
||||||
|
const handleRowChange = useCallback(
|
||||||
|
(index: number, newRow: any) => {
|
||||||
|
const newData = [...data];
|
||||||
|
newData[index] = newRow;
|
||||||
|
setData(newData);
|
||||||
|
|
||||||
|
// 🆕 _targetTable 메타데이터 포함
|
||||||
|
if (onDataChange) {
|
||||||
|
const targetTable =
|
||||||
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||||
|
|
||||||
|
if (targetTable) {
|
||||||
|
const dataWithTarget = newData.map((row) => ({
|
||||||
|
...row,
|
||||||
|
_targetTable: targetTable,
|
||||||
|
}));
|
||||||
|
onDataChange(dataWithTarget);
|
||||||
|
} else {
|
||||||
|
onDataChange(newData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 행 삭제 핸들러
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// V2 EventBus 구독
|
||||||
|
const unsubscribe = v2EventBus.subscribe(
|
||||||
|
V2_EVENTS.FORM_SAVE_COLLECT,
|
||||||
|
async (payload) => {
|
||||||
|
// formData 객체가 있으면 데이터 수집
|
||||||
|
const fakeEvent = {
|
||||||
|
detail: { formData: payload.formData },
|
||||||
|
} as CustomEvent;
|
||||||
|
await handleBeforeFormSave(fakeEvent);
|
||||||
|
},
|
||||||
|
{ componentId: `unified-repeater-${config.dataSource?.tableName}` },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||||
|
window.addEventListener("beforeFormSave", handleBeforeFormSave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || {};
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// V2 EventBus 구독
|
||||||
|
const unsubscribeComponent = v2EventBus.subscribe(
|
||||||
|
V2_EVENTS.COMPONENT_DATA_TRANSFER,
|
||||||
|
(payload) => {
|
||||||
|
const fakeEvent = {
|
||||||
|
detail: {
|
||||||
|
targetComponentId: payload.targetComponentId,
|
||||||
|
transferData: [payload.data],
|
||||||
|
mappingRules: [],
|
||||||
|
mode: "append",
|
||||||
|
},
|
||||||
|
} as CustomEvent;
|
||||||
|
handleComponentDataTransfer(fakeEvent);
|
||||||
|
},
|
||||||
|
{ componentId: `unified-repeater-${config.dataSource?.tableName}` },
|
||||||
|
);
|
||||||
|
|
||||||
|
const unsubscribeSplitPanel = v2EventBus.subscribe(
|
||||||
|
V2_EVENTS.SPLIT_PANEL_DATA_TRANSFER,
|
||||||
|
(payload) => {
|
||||||
|
const fakeEvent = {
|
||||||
|
detail: {
|
||||||
|
transferData: [payload.data],
|
||||||
|
mappingRules: [],
|
||||||
|
mode: "append",
|
||||||
|
},
|
||||||
|
} as CustomEvent;
|
||||||
|
handleSplitPanelDataTransfer(fakeEvent);
|
||||||
|
},
|
||||||
|
{ componentId: `unified-repeater-${config.dataSource?.tableName}` },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||||
|
window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
|
||||||
|
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeComponent();
|
||||||
|
unsubscribeSplitPanel();
|
||||||
|
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";
|
||||||
|
|
||||||
|
// V2ErrorBoundary로 래핑된 안전한 버전 export
|
||||||
|
export const SafeUnifiedRepeater: React.FC<UnifiedRepeaterProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<V2ErrorBoundary
|
||||||
|
componentId={props.parentId || "unified-repeater"}
|
||||||
|
componentType="UnifiedRepeater"
|
||||||
|
fallbackStyle="compact"
|
||||||
|
>
|
||||||
|
<UnifiedRepeater {...props} />
|
||||||
|
</V2ErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnifiedRepeater;
|
||||||
|
|
@ -0,0 +1,814 @@
|
||||||
|
"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="flex-1 truncate text-left">
|
||||||
|
{selectedLabels.length > 0
|
||||||
|
? multiple
|
||||||
|
? `${selectedLabels.length}개 선택됨`
|
||||||
|
: selectedLabels[0]
|
||||||
|
: placeholder}
|
||||||
|
</span>
|
||||||
|
<div className="ml-2 flex items-center gap-1">
|
||||||
|
{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="cursor-pointer text-sm">
|
||||||
|
{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="cursor-pointer text-sm">
|
||||||
|
{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 && "cursor-not-allowed opacity-50")}
|
||||||
|
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 items-stretch gap-2", className)}>
|
||||||
|
{/* 왼쪽: 선택 가능 */}
|
||||||
|
<div className="flex-1 rounded-md border">
|
||||||
|
<div className="bg-muted border-b p-2 text-xs font-medium">선택 가능</div>
|
||||||
|
<div className="max-h-40 space-y-1 overflow-y-auto p-2">
|
||||||
|
{available.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-accent cursor-pointer rounded p-2 text-sm",
|
||||||
|
disabled && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
onClick={() => !disabled && handleMoveRight(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{available.length === 0 && <div className="text-muted-foreground p-2 text-xs">항목 없음</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중앙: 이동 버튼 */}
|
||||||
|
<div className="flex flex-col justify-center gap-1">
|
||||||
|
<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 rounded-md border">
|
||||||
|
<div className="bg-primary/10 border-b p-2 text-xs font-medium">선택됨</div>
|
||||||
|
<div className="max-h-40 space-y-1 overflow-y-auto p-2">
|
||||||
|
{selected.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-accent flex cursor-pointer items-center justify-between rounded p-2 text-sm",
|
||||||
|
disabled && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
onClick={() => !disabled && handleMoveLeft(option.value)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<X className="h-3 w-3 opacity-50" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{selected.length === 0 && <div className="text-muted-foreground p-2 text-xs">선택 없음</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,
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
} = 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);
|
||||||
|
|
||||||
|
// 옵션 로딩에 필요한 값들만 추출 (객체 참조 대신 원시값 사용)
|
||||||
|
const rawSource = config.source;
|
||||||
|
const categoryTable = (config as any).categoryTable;
|
||||||
|
const categoryColumn = (config as any).categoryColumn;
|
||||||
|
|
||||||
|
// category 소스 유지 (category_values_test 테이블에서 로드)
|
||||||
|
const source = rawSource;
|
||||||
|
const codeGroup = 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;
|
||||||
|
}
|
||||||
|
} else if (source === "category") {
|
||||||
|
// 카테고리에서 로드 (category_values_test 테이블)
|
||||||
|
// tableName, columnName은 props에서 가져옴
|
||||||
|
const catTable = categoryTable || tableName;
|
||||||
|
const catColumn = categoryColumn || columnName;
|
||||||
|
|
||||||
|
if (catTable && catColumn) {
|
||||||
|
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success && data.data) {
|
||||||
|
// 트리 구조를 평탄화하여 옵션으로 변환
|
||||||
|
// value로 valueId를 사용하여 채번 규칙 매핑과 일치하도록 함
|
||||||
|
const flattenTree = (
|
||||||
|
items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[],
|
||||||
|
depth: number = 0,
|
||||||
|
): SelectOption[] => {
|
||||||
|
const result: SelectOption[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
|
||||||
|
result.push({
|
||||||
|
value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치)
|
||||||
|
label: prefix + item.valueLabel,
|
||||||
|
});
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
result.push(...flattenTree(item.children, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
fetchedOptions = flattenTree(data.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (source === "select" || source === "distinct") {
|
||||||
|
// 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
|
||||||
|
// tableName, columnName은 props에서 가져옴
|
||||||
|
// 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀
|
||||||
|
const isValidColumnName = columnName && !columnName.startsWith("comp_");
|
||||||
|
if (tableName && isValidColumnName) {
|
||||||
|
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success && data.data) {
|
||||||
|
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
|
||||||
|
value: String(item.value),
|
||||||
|
label: String(item.label),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (!isValidColumnName) {
|
||||||
|
// columnName이 없거나 유효하지 않으면 빈 옵션
|
||||||
|
console.warn("UnifiedSelect: 유효한 columnName이 없어 옵션을 로드하지 않습니다.", {
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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="text-muted-foreground flex h-10 items-center text-sm">로딩 중...</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="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">{renderSelect()}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
UnifiedSelect.displayName = "UnifiedSelect";
|
||||||
|
|
||||||
|
export default UnifiedSelect;
|
||||||
|
|
@ -253,11 +253,13 @@ const ModalGroup = forwardRef<HTMLDivElement, {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
|
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
|
||||||
{(title || description) && (
|
{title || description ? (
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
{title && <DialogTitle>{title}</DialogTitle>}
|
{title && <DialogTitle>{title}</DialogTitle>}
|
||||||
{description && <DialogDescription>{description}</DialogDescription>}
|
{description && <DialogDescription>{description}</DialogDescription>}
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
) : (
|
||||||
|
<DialogTitle className="sr-only">모달</DialogTitle>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
@ -313,11 +315,13 @@ const FormModalGroup = forwardRef<HTMLDivElement, {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
|
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
|
||||||
{(title || description) && (
|
{title || description ? (
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
{title && <DialogTitle>{title}</DialogTitle>}
|
{title && <DialogTitle>{title}</DialogTitle>}
|
||||||
{description && <DialogDescription>{description}</DialogDescription>}
|
{description && <DialogDescription>{description}</DialogDescription>}
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
) : (
|
||||||
|
<DialogTitle className="sr-only">폼 모달</DialogTitle>
|
||||||
)}
|
)}
|
||||||
<div className="py-4">{children}</div>
|
<div className="py-4">{children}</div>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
|
|
||||||
|
|
@ -122,4 +122,3 @@ export type {
|
||||||
// 통합 Props
|
// 통합 Props
|
||||||
V2ComponentProps,
|
V2ComponentProps,
|
||||||
} from "@/types/v2-components";
|
} from "@/types/v2-components";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,6 @@ import "./section-card/SectionCardRenderer";
|
||||||
import "./tabs/tabs-component";
|
import "./tabs/tabs-component";
|
||||||
import "./location-swap-selector/LocationSwapSelectorRenderer";
|
import "./location-swap-selector/LocationSwapSelectorRenderer";
|
||||||
import "./rack-structure/RackStructureRenderer";
|
import "./rack-structure/RackStructureRenderer";
|
||||||
import "./v2-repeater/V2RepeaterRenderer";
|
|
||||||
import "./pivot-grid/PivotGridRenderer";
|
import "./pivot-grid/PivotGridRenderer";
|
||||||
import "./aggregation-widget/AggregationWidgetRenderer";
|
import "./aggregation-widget/AggregationWidgetRenderer";
|
||||||
import "./repeat-container/RepeatContainerRenderer";
|
import "./repeat-container/RepeatContainerRenderer";
|
||||||
|
|
|
||||||
|
|
@ -1062,7 +1062,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 부모 컴포넌트에 초기 컬럼 순서 전달
|
// 부모 컴포넌트에 초기 컬럼 순서 전달
|
||||||
if (onSelectedRowsChange && parsedOrder.length > 0) {
|
if (onSelectedRowsChange && parsedOrder.length > 0) {
|
||||||
|
|
||||||
// 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬)
|
// 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬)
|
||||||
const initialData = data.map((row: any) => {
|
const initialData = data.map((row: any) => {
|
||||||
const reordered: any = {};
|
const reordered: any = {};
|
||||||
|
|
@ -2637,14 +2636,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
columnName: col.columnName,
|
columnName: col.columnName,
|
||||||
value: valueStr,
|
value: valueStr,
|
||||||
hasMappings: !!categoryMappings[col.columnName],
|
hasMappings: !!categoryMappings[col.columnName],
|
||||||
mappingsKeys: categoryMappings[col.columnName] ? Object.keys(categoryMappings[col.columnName]).slice(0, 5) : [],
|
mappingsKeys: categoryMappings[col.columnName]
|
||||||
|
? Object.keys(categoryMappings[col.columnName]).slice(0, 5)
|
||||||
|
: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (categoryMappings[col.columnName]) {
|
if (categoryMappings[col.columnName]) {
|
||||||
// 쉼표로 구분된 중복 값 처리
|
// 쉼표로 구분된 중복 값 처리
|
||||||
if (valueStr.includes(",")) {
|
if (valueStr.includes(",")) {
|
||||||
const values = valueStr.split(",").map((v) => v.trim()).filter((v) => v);
|
const values = valueStr
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => v);
|
||||||
const labels = values.map((v) => {
|
const labels = values.map((v) => {
|
||||||
const mapping = categoryMappings[col.columnName][v];
|
const mapping = categoryMappings[col.columnName][v];
|
||||||
return mapping ? mapping.label : v;
|
return mapping ? mapping.label : v;
|
||||||
|
|
@ -5778,7 +5782,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-primary/20 ml-1 rounded p-0.5 transition-colors",
|
"hover:bg-primary/20 ml-1 rounded p-0.5 transition-colors",
|
||||||
(headerFilters[column.columnName]?.size > 0 || headerLikeFilters[column.columnName]) && "text-primary bg-primary/10",
|
(headerFilters[column.columnName]?.size > 0 ||
|
||||||
|
headerLikeFilters[column.columnName]) &&
|
||||||
|
"text-primary bg-primary/10",
|
||||||
)}
|
)}
|
||||||
title="필터"
|
title="필터"
|
||||||
>
|
>
|
||||||
|
|
@ -5795,7 +5801,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<span className="text-xs font-medium">
|
<span className="text-xs font-medium">
|
||||||
필터: {columnLabels[column.columnName] || column.displayName}
|
필터: {columnLabels[column.columnName] || column.displayName}
|
||||||
</span>
|
</span>
|
||||||
{(headerFilters[column.columnName]?.size > 0 || headerLikeFilters[column.columnName]) && (
|
{(headerFilters[column.columnName]?.size > 0 ||
|
||||||
|
headerLikeFilters[column.columnName]) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearHeaderFilter(column.columnName);
|
clearHeaderFilter(column.columnName);
|
||||||
|
|
@ -5813,7 +5820,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
{/* LIKE 검색 입력 필드 */}
|
{/* LIKE 검색 입력 필드 */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="text-muted-foreground absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2" />
|
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3 w-3 -translate-y-1/2" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="검색어 입력 (포함)"
|
placeholder="검색어 입력 (포함)"
|
||||||
|
|
@ -5824,12 +5831,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
[column.columnName]: e.target.value,
|
[column.columnName]: e.target.value,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
className="border-input bg-background placeholder:text-muted-foreground h-7 w-full rounded-md border pl-7 pr-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
className="border-input bg-background placeholder:text-muted-foreground focus:ring-primary h-7 w-full rounded-md border pr-2 pl-7 text-xs focus:ring-1 focus:outline-none"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* 구분선 */}
|
{/* 구분선 */}
|
||||||
<div className="text-muted-foreground border-t pt-2 text-[10px]">또는 값 선택:</div>
|
<div className="text-muted-foreground border-t pt-2 text-[10px]">
|
||||||
|
또는 값 선택:
|
||||||
|
</div>
|
||||||
<div className="max-h-40 space-y-1 overflow-y-auto">
|
<div className="max-h-40 space-y-1 overflow-y-auto">
|
||||||
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
|
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
|
||||||
const isSelected = headerFilters[column.columnName]?.has(val);
|
const isSelected = headerFilters[column.columnName]?.has(val);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
getComponentTypeFromUrl,
|
getComponentTypeFromUrl,
|
||||||
getDefaultsByUrl,
|
getDefaultsByUrl,
|
||||||
mergeComponentConfig,
|
mergeComponentConfig,
|
||||||
extractCustomConfig
|
extractCustomConfig,
|
||||||
} from "@/lib/schemas/componentConfig";
|
} from "@/lib/schemas/componentConfig";
|
||||||
|
|
||||||
// 기존 ComponentData 타입 (간략화)
|
// 기존 ComponentData 타입 (간략화)
|
||||||
|
|
@ -144,22 +144,12 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
|
||||||
// V2 레이아웃 유효성 검사
|
// V2 레이아웃 유효성 검사
|
||||||
// ============================================
|
// ============================================
|
||||||
export function isValidV2Layout(data: any): data is LayoutV2 {
|
export function isValidV2Layout(data: any): data is LayoutV2 {
|
||||||
return (
|
return data && typeof data === "object" && data.version === "2.0" && Array.isArray(data.components);
|
||||||
data &&
|
|
||||||
typeof data === "object" &&
|
|
||||||
data.version === "2.0" &&
|
|
||||||
Array.isArray(data.components)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 기존 레이아웃인지 확인
|
// 기존 레이아웃인지 확인
|
||||||
// ============================================
|
// ============================================
|
||||||
export function isLegacyLayout(data: any): boolean {
|
export function isLegacyLayout(data: any): boolean {
|
||||||
return (
|
return data && typeof data === "object" && Array.isArray(data.components) && data.version !== "2.0";
|
||||||
data &&
|
|
||||||
typeof data === "object" &&
|
|
||||||
Array.isArray(data.components) &&
|
|
||||||
data.version !== "2.0"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export type ColumnWidthOption = "auto" | "60px" | "80px" | "100px" | "120px" | "
|
||||||
export type AutoFillType =
|
export type AutoFillType =
|
||||||
| "none" // 자동 입력 없음
|
| "none" // 자동 입력 없음
|
||||||
| "currentDate" // 현재 날짜
|
| "currentDate" // 현재 날짜
|
||||||
| "currentDateTime"// 현재 날짜+시간
|
| "currentDateTime" // 현재 날짜+시간
|
||||||
| "sequence" // 순번 (1, 2, 3...)
|
| "sequence" // 순번 (1, 2, 3...)
|
||||||
| "numbering" // 채번 규칙 (관리자가 등록한 규칙 선택)
|
| "numbering" // 채번 규칙 (관리자가 등록한 규칙 선택)
|
||||||
| "fromMainForm" // 메인 폼에서 값 복사
|
| "fromMainForm" // 메인 폼에서 값 복사
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue