Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal

This commit is contained in:
kjs 2026-02-06 11:07:56 +09:00
commit 33db3933d8
12 changed files with 834 additions and 95 deletions

View File

@ -102,6 +102,80 @@ export interface NodeExecutionSummary {
error?: string;
}
// ===== 헬퍼 함수 =====
/**
* 🔧 (, , )
*
*/
function isValidDBValue(v: any): boolean {
// 숫자면 유효 (나중에 문자열로 변환됨)
if (typeof v === "number" && !isNaN(v)) return true;
// 문자열이 아니면 무효
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
}
/**
* 🔧 DB (PostgreSQL )
* - JavaScript ( )
* - PostgreSQL ( )
* - null
*/
function normalizeValueForDB(value: any): any {
// 1. 배열이면 유효한 값만 필터링 후 쉼표 구분 문자열로 변환
if (Array.isArray(value)) {
// 숫자를 문자열로 변환하고 유효한 값만 필터링
const validValues = value
.map(v => typeof v === "number" ? String(v) : v)
.filter(isValidDBValue)
.map(v => typeof v === "number" ? String(v) : v); // 최종 문자열 변환
if (validValues.length === 0) {
console.warn(`⚠️ [normalizeValueForDB] 배열에 유효한 값 없음:`, value);
return null;
}
const normalized = validValues.join(",");
console.log(`🔧 [normalizeValueForDB] 배열→문자열:`, { original: value.length, valid: validValues.length, normalized });
return normalized;
}
// 2. 문자열인데 잘못된 형식이면 정리
if (typeof value === "string" && value) {
// 잘못된 형식 감지
if (value.includes("{") || value.includes("}") || value.includes('\\"') || value.includes("\\\\")) {
console.warn(`⚠️ [normalizeValueForDB] 잘못된 문자열 형식:`, value.substring(0, 80));
// 정규표현식으로 유효한 코드만 추출
const codePattern = /\b(CAT_[A-Z0-9_]+|[A-Z]{2,}_[A-Z0-9_]+)\b/g;
const matches = value.match(codePattern);
if (matches && matches.length > 0) {
const uniqueValues = [...new Set(matches)];
const normalized = uniqueValues.join(",");
console.log(`🔧 [normalizeValueForDB] 코드 추출:`, { count: uniqueValues.length, normalized });
return normalized;
}
console.warn(`⚠️ [normalizeValueForDB] 유효한 코드 없음, null 반환`);
return null;
}
// 쉼표 구분 문자열이면 각 값 검증
if (value.includes(",")) {
const parts = value.split(",").map(v => v.trim()).filter(isValidDBValue);
if (parts.length === 0) {
return null;
}
return parts.join(",");
}
}
return value;
}
// ===== 메인 실행 서비스 =====
export class NodeFlowExecutionService {
@ -1019,10 +1093,12 @@ export class NodeFlowExecutionService {
);
}
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
const normalizedValue = normalizeValueForDB(value);
values.push(normalizedValue);
// 🔥 삽입된 값을 데이터에 반영
insertedData[mapping.targetField] = value;
insertedData[mapping.targetField] = normalizedValue;
}
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
@ -1155,9 +1231,11 @@ export class NodeFlowExecutionService {
mapping.staticValue !== undefined
? mapping.staticValue
: data[mapping.sourceField];
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
const normalizedValue = normalizeValueForDB(value);
values.push(normalizedValue);
// 🔥 삽입된 데이터 객체에 매핑된 값 적용
insertedData[mapping.targetField] = value;
insertedData[mapping.targetField] = normalizedValue;
});
// 외부 DB별 SQL 문법 차이 처리
@ -1493,7 +1571,8 @@ export class NodeFlowExecutionService {
if (mapping.targetField) {
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
values.push(normalizeValueForDB(value));
paramIndex++;
}
});
@ -1556,11 +1635,13 @@ export class NodeFlowExecutionService {
// targetField가 비어있지 않은 경우만 추가
if (mapping.targetField) {
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
const normalizedValue = normalizeValueForDB(value);
values.push(normalizedValue);
paramIndex++;
// 🔥 업데이트된 값을 데이터에 반영
updatedData[mapping.targetField] = value;
updatedData[mapping.targetField] = normalizedValue;
} else {
console.log(
`⚠️ targetField가 비어있어 스킵: ${mapping.sourceField}`
@ -1685,10 +1766,12 @@ export class NodeFlowExecutionService {
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
}
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
const normalizedValue = normalizeValueForDB(value);
values.push(normalizedValue);
paramIndex++;
// 🔥 업데이트된 데이터 객체에 매핑된 값 적용
updatedData[mapping.targetField] = value;
updatedData[mapping.targetField] = normalizedValue;
});
// WHERE 조건 생성
@ -2317,7 +2400,8 @@ export class NodeFlowExecutionService {
? mapping.staticValue
: data[mapping.sourceField];
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
updateValues.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
updateValues.push(normalizeValueForDB(value));
paramIndex++;
}
});
@ -2368,7 +2452,8 @@ export class NodeFlowExecutionService {
? mapping.staticValue
: data[mapping.sourceField];
columns.push(mapping.targetField);
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
values.push(normalizeValueForDB(value));
});
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
@ -2549,7 +2634,8 @@ export class NodeFlowExecutionService {
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
}
updateValues.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
updateValues.push(normalizeValueForDB(value));
paramIndex++;
}
});
@ -2587,7 +2673,8 @@ export class NodeFlowExecutionService {
? mapping.staticValue
: data[mapping.sourceField];
columns.push(mapping.targetField);
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
values.push(normalizeValueForDB(value));
});
let insertSql: string;

View File

@ -0,0 +1,253 @@
# 다중 선택(Multi-Select) 배열 직렬화 문제 해결 보고서
## 문제 요약
**증상**: 다중 선택 컴포넌트(TagboxSelect, 체크박스 등)로 선택한 값이 DB에 저장될 때 손상되거나 `null`로 저장됨
**영향받는 기능**:
- 품목정보의 `division` (구분) 필드
- 모든 다중 선택 카테고리 필드
**손상된 데이터 예시**:
```
{"{\"{\\\"CAT_ML7SR2T9_IM7H\\\",\\\"CAT_ML8ZFQFU_EE5Z\\\"}\"}",...}
```
**정상 데이터 예시**:
```
CAT_ML7SR2T9_IM7H,CAT_ML8ZFQFU_EE5Z,CAT_ML8ZFVEL_1TOR
```
---
## 문제 원인 분석
### 1. PostgreSQL의 배열 자동 변환
Node.js의 `node-pg` 라이브러리는 JavaScript 배열을 PostgreSQL 배열 리터럴(`{...}`)로 자동 변환합니다.
```javascript
// JavaScript
["CAT_1", "CAT_2", "CAT_3"]
// PostgreSQL로 자동 변환됨
{"CAT_1","CAT_2","CAT_3"}
```
하지만 우리 시스템은 커스텀 테이블에서 **쉼표 구분 문자열**을 기대합니다:
```
CAT_1,CAT_2,CAT_3
```
### 2. 여러 저장 경로의 존재
코드를 분석한 결과, 저장 로직이 여러 경로로 나뉘어 있었습니다:
| 경로 | 파일 | 설명 |
|------|------|------|
| 1 | `buttonActions.ts` | 기본 저장 로직 (INSERT/UPDATE) |
| 2 | `EditModal.tsx` | 모달 내 직접 저장 (CREATE/UPDATE) |
| 3 | `nodeFlowExecutionService.ts` | 백엔드 노드 플로우 저장 |
### 3. 왜 초기 수정이 실패했는가?
#### 시도 1: `buttonActions.ts`에 배열 변환 추가
```typescript
// buttonActions.ts (라인 1002-1025)
if (isUpdate) {
for (const key of Object.keys(formData)) {
if (Array.isArray(value)) {
formData[key] = value.join(",");
}
}
}
```
**실패 이유**: `EditModal``onSave` 콜백을 제공하면, `buttonActions.ts`는 이 콜백을 바로 호출하고 내부 저장 로직을 건너뜀
```typescript
// buttonActions.ts (라인 545-552)
if (onSave) {
await onSave(); // 바로 여기서 EditModal.handleSave()가 호출됨
return true; // 아래 배열 변환 로직에 도달하지 않음!
}
```
#### 시도 2: `nodeFlowExecutionService.ts``normalizeValueForDB` 추가
**부분 성공**: INSERT에서는 동작했으나, EditModal의 UPDATE 경로는 여전히 문제
---
## 최종 해결 방법
### 핵심 수정: `EditModal.tsx`에 직접 배열 변환 추가
EditModal이 직접 `dynamicFormApi.updateFormDataPartial`을 호출하므로, **저장 직전**에 배열을 변환해야 했습니다.
#### 수정 위치 1: UPDATE 경로 (라인 957-1002)
```typescript
// EditModal.tsx - UPDATE 모드
Object.keys(formData).forEach((key) => {
if (formData[key] !== originalData[key]) {
let value = formData[key];
if (Array.isArray(value)) {
// 리피터 데이터 제외
const isRepeaterData = value.length > 0 &&
typeof value[0] === "object" &&
("_targetTable" in value[0] || "_isNewItem" in value[0]);
if (!isRepeaterData) {
// 🔧 손상된 값 필터링
const isValidValue = (v: any): boolean => {
if (typeof v === "number") return true;
if (typeof v !== "string") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\"))
return false;
return true;
};
// 유효한 값만 쉼표로 연결
const validValues = value.filter(isValidValue);
value = validValues.join(",");
}
}
changedData[key] = value;
}
});
```
#### 수정 위치 2: CREATE 경로 (라인 855-875)
```typescript
// EditModal.tsx - CREATE 모드
Object.entries(dataToSave).forEach(([key, value]) => {
if (!Array.isArray(value)) {
masterDataToSave[key] = value;
} else {
const isRepeaterData = /* 리피터 체크 */;
if (isRepeaterData) {
// 리피터 데이터는 제외 (별도 저장)
} else {
// 다중 선택 배열 → 쉼표 구분 문자열
const validValues = value.filter(isValidValue);
masterDataToSave[key] = validValues.join(",");
}
}
});
```
#### 수정 위치 3: 그룹 UPDATE 경로 (라인 630-650)
그룹 품목 수정 시에도 동일한 로직 적용
---
## 손상된 데이터 필터링
기존에 손상된 데이터가 배열에 포함될 수 있어서, 변환 전 필터링이 필요했습니다:
```typescript
const isValidValue = (v: any): boolean => {
// 숫자는 유효
if (typeof v === "number" && !isNaN(v)) return true;
// 문자열이 아니면 무효
if (typeof v !== "string") return false;
// 빈 값 무효
if (!v || v.trim() === "") return false;
// PostgreSQL 배열 형식 감지 → 무효
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\"))
return false;
return true;
};
```
**필터링 예시**:
```
입력 배열: ['{"CAT_1","CAT_2"}', 'CAT_ML7SR2T9_IM7H', 'CAT_ML8ZFQFU_EE5Z']
↑ 손상됨 (필터링) ↑ 유효 ↑ 유효
출력: 'CAT_ML7SR2T9_IM7H,CAT_ML8ZFQFU_EE5Z'
```
---
## 수정된 파일 목록
| 파일 | 수정 내용 |
|------|-----------|
| `frontend/components/screen/EditModal.tsx` | CREATE/UPDATE/그룹UPDATE 경로에 배열→문자열 변환 + 손상값 필터링 |
| `frontend/lib/utils/buttonActions.ts` | INSERT 경로에 배열→문자열 변환 (이미 수정됨) |
| `frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx` | handleChange에서 배열→문자열 변환 |
| `backend-node/src/services/nodeFlowExecutionService.ts` | normalizeValueForDB 헬퍼 추가 |
---
## 교훈 및 향후 주의사항
### 1. 저장 경로 파악의 중요성
프론트엔드에서 저장 로직이 여러 경로로 분기될 수 있으므로, **모든 경로를 추적**해야 합니다.
```
사용자 저장 버튼 클릭
ButtonPrimaryComponent
buttonActions.handleSave()
┌─────────────────────────────────────┐
│ onSave 콜백이 있으면? │
│ → EditModal.handleSave() 직접 호출│ ← 이 경로를 놓침!
│ onSave 콜백이 없으면? │
│ → buttonActions 내부 저장 로직 │
└─────────────────────────────────────┘
```
### 2. 로그 기반 디버깅
로그가 어디까지 찍히고 어디서 안 찍히는지를 통해 코드 경로를 추적:
```
[예상한 로그]
buttonActions.ts:512 🔍 [handleSave] 진입
buttonActions.ts:1021 🔧 배열→문자열 변환 ← 이게 안 나옴!
[실제 로그]
buttonActions.ts:512 🔍 [handleSave] 진입
dynamicForm.ts:140 🔄 폼 데이터 부분 업데이트 ← 바로 여기로 점프!
```
### 3. 리피터 데이터 vs 다중 선택 구분
배열이라고 모두 쉼표 문자열로 변환하면 안 됩니다:
| 타입 | 예시 | 처리 방법 |
|------|------|-----------|
| 다중 선택 | `["CAT_1", "CAT_2"]` | 쉼표 문자열로 변환 |
| 리피터 데이터 | `[{id: 1, _targetTable: "..."}]` | 별도 테이블에 저장, 마스터에서 제외 |
---
## 확인된 정상 동작
```
EditModal.tsx:1002 🔧 [EditModal UPDATE] 배열→문자열 변환: division
{original: 3, valid: 3, converted: 'CAT_ML7SR2T9_IM7H,CAT_ML8ZFQFU_EE5Z,CAT_ML8ZFVEL_1TOR'}
dynamicForm.ts:153 ✅ 폼 데이터 부분 업데이트 성공
```
---
## 작성일
2026-02-05
## 작성자
AI Assistant (Claude)

View File

@ -618,7 +618,36 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (currentValue !== originalValue) {
console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue}${currentValue}`);
// 날짜 필드는 정규화된 값 사용, 나머지는 원본 값 사용
changedData[key] = dateFields.includes(key) ? currentValue : currentData[key];
let finalValue = dateFields.includes(key) ? currentValue : currentData[key];
// 🔧 배열이면 쉼표 구분 문자열로 변환 (리피터 데이터 제외)
if (Array.isArray(finalValue)) {
const isRepeaterData = finalValue.length > 0 &&
typeof finalValue[0] === "object" &&
finalValue[0] !== null &&
("_targetTable" in finalValue[0] || "_isNewItem" in finalValue[0] || "_existingRecord" in finalValue[0]);
if (!isRepeaterData) {
// 🔧 손상된 값 필터링 헬퍼
const isValidValue = (v: any): boolean => {
if (typeof v === "number" && !isNaN(v)) return true;
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
};
const validValues = finalValue
.map((v: any) => typeof v === "number" ? String(v) : v)
.filter(isValidValue);
const stringValue = validValues.join(",");
console.log(`🔧 [EditModal 그룹UPDATE] 배열→문자열 변환: ${key}`, { original: finalValue.length, valid: validValues.length, converted: stringValue });
finalValue = stringValue;
}
}
changedData[key] = finalValue;
}
});
@ -827,12 +856,39 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 🔧 단, 다중 선택 배열은 쉼표 구분 문자열로 변환하여 저장
const masterDataToSave: Record<string, any> = {};
Object.entries(dataToSave).forEach(([key, value]) => {
if (!Array.isArray(value)) {
masterDataToSave[key] = value;
} else {
console.log(`🔄 [EditModal] 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
const isRepeaterData = value.length > 0 &&
typeof value[0] === "object" &&
value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
if (isRepeaterData) {
console.log(`🔄 [EditModal] 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
} else {
// 🔧 손상된 값 필터링 헬퍼 (중괄호, 따옴표, 백슬래시 포함 시 무효)
const isValidValue = (v: any): boolean => {
if (typeof v === "number" && !isNaN(v)) return true;
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
};
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 (손상된 값 필터링)
const validValues = value
.map((v: any) => typeof v === "number" ? String(v) : v)
.filter(isValidValue);
const stringValue = validValues.join(",");
console.log(`🔧 [EditModal CREATE] 배열→문자열 변환: ${key}`, { original: value.length, valid: validValues.length, converted: stringValue });
masterDataToSave[key] = stringValue;
}
}
});
@ -916,7 +972,47 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const changedData: Record<string, any> = {};
Object.keys(formData).forEach((key) => {
if (formData[key] !== originalData[key]) {
changedData[key] = formData[key];
let value = formData[key];
// 🔧 배열이면 쉼표 구분 문자열로 변환 (리피터 데이터 제외)
if (Array.isArray(value)) {
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
const isRepeaterData = value.length > 0 &&
typeof value[0] === "object" &&
value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
if (!isRepeaterData) {
// 🔧 손상된 값 필터링 헬퍼 (중괄호, 따옴표, 백슬래시 포함 시 무효)
const isValidValue = (v: any): boolean => {
if (typeof v === "number" && !isNaN(v)) return true;
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
// 손상된 PostgreSQL 배열 형식 감지
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
};
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 (손상된 값 필터링)
const validValues = value
.map((v: any) => typeof v === "number" ? String(v) : v)
.filter(isValidValue);
if (validValues.length !== value.length) {
console.warn(`⚠️ [EditModal UPDATE] 손상된 값 필터링: ${key}`, {
before: value.length,
after: validValues.length,
removed: value.filter((v: any) => !isValidValue(v))
});
}
const stringValue = validValues.join(",");
console.log(`🔧 [EditModal UPDATE] 배열→문자열 변환: ${key}`, { original: value.length, valid: validValues.length, converted: stringValue });
value = stringValue;
}
}
changedData[key] = value;
}
});

View File

@ -6321,6 +6321,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
selectedScreen={selectedScreen}
tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
// onZoneComponentDrop 제거
onZoneClick={handleZoneClick}
@ -6487,6 +6488,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
onDragStart={(e) => startComponentDrag(child, e)}
onDragEnd={endDrag}
selectedScreen={selectedScreen}
tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달
// onZoneComponentDrop 제거
onZoneClick={handleZoneClick}
// 설정 변경 핸들러 (자식 컴포넌트용)
@ -6707,6 +6709,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
component={relativeButton}
isDesignMode={true}
formData={{}}
tableName={selectedScreen?.tableName}
onDataflowComplete={() => {}}
/>
</div>

View File

@ -302,6 +302,127 @@ const TagSelect = forwardRef<HTMLDivElement, {
});
TagSelect.displayName = "TagSelect";
/**
* ( + )
* - (Badge)
* -
*/
const TagboxSelect = forwardRef<HTMLDivElement, {
options: SelectOption[];
value?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
maxSelect?: number;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
}>(({ options, value = [], onChange, placeholder = "선택하세요", maxSelect, disabled, className, style }, ref) => {
const [open, setOpen] = useState(false);
// 선택된 옵션들의 라벨 가져오기
const selectedOptions = useMemo(() =>
options.filter((o) => value.includes(o.value)),
[options, value]
);
// 체크박스 토글 핸들러
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]);
// 태그 제거 핸들러
const handleRemove = useCallback((e: React.MouseEvent, optionValue: string) => {
e.stopPropagation();
onChange?.(value.filter((v) => v !== optionValue));
}, [value, onChange]);
// 🔧 높이 처리: style.height가 있으면 minHeight로 사용 (기본 40px 보장)
const triggerStyle: React.CSSProperties = {
minHeight: style?.height || 40,
height: style?.height || "auto",
maxWidth: "100%", // 🔧 부모 컨테이너를 넘지 않도록
};
return (
<div ref={ref} className={cn("w-full max-w-full overflow-hidden", className)} style={{ width: style?.width }}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div
className={cn(
"flex w-full max-w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer overflow-hidden",
"hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
disabled && "opacity-50 cursor-not-allowed"
)}
style={triggerStyle}
>
{selectedOptions.length > 0 ? (
<>
{selectedOptions.map((option) => (
<Badge
key={option.value}
variant="secondary"
className="flex items-center gap-1 px-2 py-0.5"
>
{option.label}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={(e) => !disabled && handleRemove(e, option.value)}
/>
</Badge>
))}
</>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</div>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<div className="max-h-[300px] overflow-auto p-2">
{options.map((option) => {
const isSelected = value.includes(option.value);
return (
<div
key={option.value}
className={cn(
"flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-accent/50"
)}
onClick={() => !disabled && handleToggle(option.value)}
>
<Checkbox
checked={isSelected}
disabled={disabled}
className="pointer-events-none"
/>
<span>{option.label}</span>
</div>
);
})}
{options.length === 0 && (
<div className="py-2 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
);
});
TagboxSelect.displayName = "TagboxSelect";
/**
* (Boolean용)
*/
@ -461,6 +582,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
onChange,
tableName,
columnName,
isDesignMode, // 🔧 디자인 모드 (클릭 방지)
} = props;
// config가 없으면 기본값 사용
@ -605,13 +727,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
const data = response.data;
if (data.success && data.data) {
// 트리 구조를 평탄화하여 옵션으로 변환
// value로 valueId를 사용하여 채번 규칙 매핑과 일치하도록 함
// 🔧 value로 valueCode를 사용 (커스텀 테이블 저장/조회 호환)
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로 사용 (채번 매핑과 일치)
value: item.valueCode, // 🔧 valueCode를 value로 사용
label: prefix + item.valueLabel,
});
if (item.children && item.children.length > 0) {
@ -639,7 +761,6 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
}
} else if (!isValidColumnName) {
// columnName이 없거나 유효하지 않으면 빈 옵션
console.warn("V2Select: 유효한 columnName이 없어 옵션을 로드하지 않습니다.", { tableName, columnName });
}
}
@ -669,6 +790,48 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
? { height: componentHeight }
: undefined;
// 🔧 디자인 모드용: 옵션이 없고 dropdown/combobox가 아닌 모드일 때 source 정보 표시
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap"];
if (options.length === 0 && nonDropdownModes.includes(config.mode || "dropdown")) {
// 데이터 소스 정보 기반 메시지 생성
let sourceInfo = "";
if (source === "static") {
sourceInfo = "정적 옵션 설정 필요";
} else if (source === "code") {
sourceInfo = codeGroup ? `공통코드: ${codeGroup}` : "공통코드 설정 필요";
} else if (source === "entity") {
sourceInfo = entityTable ? `엔티티: ${entityTable}` : "엔티티 설정 필요";
} else if (source === "category") {
const catInfo = categoryTable || tableName || columnName;
sourceInfo = catInfo ? `카테고리: ${catInfo}` : "카테고리 설정 필요";
} else if (source === "db") {
sourceInfo = table ? `테이블: ${table}` : "테이블 설정 필요";
} else if (!source || source === "distinct") {
// distinct 또는 미설정인 경우 - 컬럼명 기반으로 표시
sourceInfo = columnName ? `컬럼: ${columnName}` : "데이터 소스 설정 필요";
} else {
sourceInfo = `소스: ${source}`;
}
// 모드 이름 한글화
const modeNames: Record<string, string> = {
radio: "라디오",
check: "체크박스",
checkbox: "체크박스",
tag: "태그",
tagbox: "태그박스",
toggle: "토글",
swap: "스왑",
};
const modeName = modeNames[config.mode || ""] || config.mode;
return (
<div className="h-full flex items-center justify-center text-xs text-muted-foreground border border-dashed rounded p-2">
<span className="opacity-70">[{modeName}] {sourceInfo}</span>
</div>
);
}
switch (config.mode) {
case "dropdown":
case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운
@ -720,6 +883,19 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
/>
);
case "tagbox":
return (
<TagboxSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
placeholder={config.placeholder || "선택하세요"}
maxSelect={config.maxSelect}
disabled={isDisabled}
style={heightStyle}
/>
);
case "toggle":
return (
<ToggleSelect
@ -767,7 +943,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<div
ref={ref}
id={id}
className="relative"
className={cn("relative", isDesignMode && "pointer-events-none")}
style={{
width: componentWidth,
height: componentHeight,

View File

@ -109,9 +109,11 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
</SelectTrigger>
<SelectContent>
<SelectItem value="dropdown"></SelectItem>
<SelectItem value="combobox"> ()</SelectItem>
<SelectItem value="radio"> </SelectItem>
<SelectItem value="check"></SelectItem>
<SelectItem value="tag"> </SelectItem>
<SelectItem value="tagbox"> (+)</SelectItem>
<SelectItem value="toggle"> </SelectItem>
<SelectItem value="swap"> </SelectItem>
</SelectContent>

View File

@ -211,27 +211,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// componentConfig 또는 overrides에서 conditionalConfig를 가져와서 formData와 비교
const conditionalConfig = (component as any).componentConfig?.conditionalConfig || (component as any).overrides?.conditionalConfig;
// 디버그: 조건부 렌더링 설정 확인
if (conditionalConfig?.enabled) {
console.log(`🔍 [조건부 렌더링] ${component.id}:`, {
conditionalConfig,
formData: props.formData,
hasFormData: !!props.formData
});
}
// 조건부 렌더링 처리
if (conditionalConfig?.enabled && props.formData) {
const { field, operator, value, action } = conditionalConfig;
const fieldValue = props.formData[field];
console.log(`🔍 [조건부 렌더링 평가] ${component.id}:`, {
field,
fieldValue,
operator,
expectedValue: value,
action
});
// 조건 평가
let conditionMet = false;
switch (operator) {
@ -270,20 +254,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}
// 액션에 따라 렌더링 결정
console.log(`🔍 [조건부 렌더링 결과] ${component.id}:`, {
conditionMet,
action,
shouldRender: action === "show" ? conditionMet : !conditionMet
});
if (action === "show" && !conditionMet) {
// "show" 액션: 조건이 충족되지 않으면 렌더링하지 않음
console.log(`❌ [조건부 렌더링] ${component.id} 숨김 처리 (show 조건 불충족)`);
return null;
}
if (action === "hide" && conditionMet) {
// "hide" 액션: 조건이 충족되면 렌더링하지 않음
console.log(`❌ [조건부 렌더링] ${component.id} 숨김 처리 (hide 조건 충족)`);
return null;
}
// "enable"/"disable" 액션은 conditionalDisabled props로 전달
@ -297,17 +271,66 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const webType = (component as any).componentConfig?.webType;
const tableName = (component as any).tableName;
const columnName = (component as any).columnName;
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
// ⚠️ 단, componentType이 "select-basic" 또는 "v2-select"인 경우는 ComponentRegistry로 처리
// (다중선택, 체크박스, 라디오 등 고급 모드 지원)
// ⚠️ 단, 다음 경우는 V2SelectRenderer로 직접 처리 (고급 모드 지원):
// 1. componentType이 "select-basic" 또는 "v2-select"인 경우
// 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등)
const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode;
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"];
const isNonDropdownMode = componentMode && nonDropdownModes.includes(componentMode);
const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode;
if (
(inputType === "category" || webType === "category") &&
tableName &&
columnName &&
(componentType === "select-basic" || componentType === "v2-select")
shouldUseV2Select
) {
// select-basic, v2-select는 ComponentRegistry에서 처리하도록 아래로 통과
// V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드)
try {
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
const fieldName = columnName || component.id;
const currentValue = props.formData?.[fieldName] || "";
const handleChange = (value: any) => {
if (props.onFormDataChange) {
props.onFormDataChange(fieldName, value);
}
};
// V2SelectRenderer용 컴포넌트 데이터 구성
const selectComponent = {
...component,
componentConfig: {
...component.componentConfig,
mode: componentMode || "dropdown",
source: "category",
categoryTable: tableName,
categoryColumn: columnName,
},
tableName,
columnName,
inputType: "category",
webType: "category",
};
const rendererProps = {
component: selectComponent,
formData: props.formData,
onFormDataChange: props.onFormDataChange,
isDesignMode: props.isDesignMode,
isInteractive: props.isInteractive ?? !props.isDesignMode,
tableName,
style: (component as any).style,
size: (component as any).size,
};
const rendererInstance = new V2SelectRenderer(rendererProps);
return rendererInstance.render();
} catch (error) {
console.error("❌ V2SelectRenderer 로드 실패:", error);
}
} else if ((inputType === "category" || webType === "category") && tableName && columnName) {
try {
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
@ -438,19 +461,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트의 columnName에 해당하는 formData 값 추출
const fieldName = (component as any).columnName || (component as any).componentConfig?.columnName || component.id;
// 🔍 파일 업로드 컴포넌트 디버깅
if (componentType === "v2-media" || componentType === "file-upload") {
console.log("[DynamicComponentRenderer] 파일 업로드:", {
componentType,
componentId: component.id,
columnName: (component as any).columnName,
configColumnName: (component as any).componentConfig?.columnName,
fieldName,
formDataValue: props.formData?.[fieldName],
formDataKeys: props.formData ? Object.keys(props.formData) : []
});
}
// 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화
let currentValue;

View File

@ -91,11 +91,6 @@ export const CategorySelectComponent: React.FC<
useEffect(() => {
if (!tableName || !columnName) {
console.warn("CategorySelectComponent: tableName 또는 columnName이 없습니다", {
tableName,
columnName,
component,
});
return;
}
@ -128,7 +123,6 @@ export const CategorySelectComponent: React.FC<
};
const handleValueChange = (newValue: string) => {
console.log("🔄 카테고리 값 변경:", { oldValue: value, newValue });
onChange?.(newValue);
};
@ -216,7 +210,7 @@ export const CategorySelectComponent: React.FC<
<Select
value={value}
onValueChange={handleValueChange}
disabled={disabled || readonly}
disabled={disabled || readonly || isDesignMode}
required={required}
>
<SelectTrigger className={`w-full h-full ${className}`} style={heightStyle}>

View File

@ -20,10 +20,55 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
const columnName = component.columnName;
const tableName = component.tableName || this.props.tableName;
// 🔧 카테고리 타입 감지 (inputType 또는 webType이 category인 경우)
const inputType = component.componentConfig?.inputType || component.inputType;
const webType = component.componentConfig?.webType || component.webType;
const isCategoryType = inputType === "category" || webType === "category";
// formData에서 현재 값 가져오기 (기본값 지원)
const defaultValue = config.defaultValue || "";
// 🔧 tagbox, check, tag, swap 모드는 본질적으로 다중 선택
const multiSelectModes = ["tagbox", "check", "checkbox", "tag", "swap"];
const isMultiple = config.multiple || multiSelectModes.includes(config.mode);
let currentValue = formData?.[columnName] ?? component.value ?? "";
// 🔧 다중 선택 시 값 정규화 (잘못된 형식 필터링)
if (isMultiple) {
// 헬퍼: 유효한 값인지 체크 (중괄호, 따옴표, 백슬래시 없어야 함)
// 숫자도 유효한 값으로 처리
const isValidValue = (v: any): boolean => {
// 숫자면 유효
if (typeof v === "number" && !isNaN(v)) return true;
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
};
if (typeof currentValue === "string" && currentValue) {
// 🔧 PostgreSQL 배열 형식 또는 중첩된 잘못된 형식 감지
if (currentValue.startsWith("{") || currentValue.includes('{"') || currentValue.includes('\\"')) {
currentValue = [];
} else if (currentValue.includes(",")) {
// 쉼표 구분 문자열 파싱 후 유효한 값만 필터링
currentValue = currentValue
.split(",")
.map((v) => v.trim())
.filter(isValidValue);
} else if (isValidValue(currentValue)) {
currentValue = [currentValue];
} else {
currentValue = [];
}
} else if (Array.isArray(currentValue)) {
// 🔧 배열일 때도 잘못된 값 필터링 + 숫자→문자열 변환!
const filtered = currentValue.map((v) => (typeof v === "number" ? String(v) : v)).filter(isValidValue);
currentValue = filtered;
} else {
currentValue = [];
}
}
// 🆕 formData에 값이 없고 기본값이 설정된 경우, 기본값 적용
if (
(currentValue === "" || currentValue === undefined || currentValue === null) &&
@ -41,10 +86,16 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
currentValue = defaultValue;
}
// 값 변경 핸들러
// 값 변경 핸들러 (배열 → 쉼표 구분 문자열로 변환하여 저장)
const handleChange = (value: any) => {
if (isInteractive && onFormDataChange && columnName) {
onFormDataChange(columnName, value);
// 🔧 배열이면 무조건 쉼표 구분 문자열로 변환 (PostgreSQL 배열 형식 방지)
if (Array.isArray(value)) {
const stringValue = value.map((v) => (typeof v === "number" ? String(v) : v)).join(",");
onFormDataChange(columnName, stringValue);
} else {
onFormDataChange(columnName, value);
}
}
};
@ -53,16 +104,8 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
const effectiveStyle = restProps.style || component.style;
const effectiveSize = restProps.size || component.size;
// 🔍 디버깅: props 확인 (warn으로 변경하여 캡처되도록)
console.warn("🔍 [V2SelectRenderer] props 디버깅:", {
componentId: component.id,
"component.style": component.style,
"component.size": component.size,
"restProps.style": restProps.style,
"restProps.size": restProps.size,
effectiveStyle,
effectiveSize,
});
// 디버깅 필요시 주석 해제
// console.log("🔍 [V2SelectRenderer]", { componentId: component.id, effectiveStyle, effectiveSize });
// 🔧 restProps에서 style, size 제외 (effectiveStyle/effectiveSize가 우선되어야 함)
const { style: _style, size: _size, ...restPropsClean } = restProps as any;
@ -78,7 +121,8 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
onChange={handleChange}
config={{
mode: config.mode || "dropdown",
source: config.source || "distinct",
// 🔧 카테고리 타입이면 source를 "category"로 설정
source: config.source || (isCategoryType ? "category" : "distinct"),
multiple: config.multiple || false,
searchable: config.searchable ?? true,
placeholder: config.placeholder || "선택하세요",
@ -87,10 +131,14 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
entityTable: config.entityTable,
entityLabelColumn: config.entityLabelColumn,
entityValueColumn: config.entityValueColumn,
// 🔧 카테고리 소스 지원 (tableName, columnName 폴백)
categoryTable: config.categoryTable || (isCategoryType ? tableName : undefined),
categoryColumn: config.categoryColumn || (isCategoryType ? columnName : undefined),
}}
tableName={tableName}
columnName={columnName}
formData={formData}
isDesignMode={isDesignMode}
{...restPropsClean}
style={effectiveStyle}
size={effectiveSize}

View File

@ -43,6 +43,7 @@ export const V2SelectDefinition = createComponentDefinition({
{ value: "radio", label: "라디오 버튼" },
{ value: "check", label: "체크박스" },
{ value: "tag", label: "태그" },
{ value: "tagbox", label: "태그박스 (태그+드롭다운)" },
{ value: "toggle", label: "토글" },
{ value: "swap", label: "스왑 (좌우 이동)" },
],

View File

@ -8,6 +8,30 @@ import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionEx
import { apiClient } from "@/lib/api/client";
import type { ExtendedControlContext } from "@/types/control-management";
/**
* 🔧 formData
* PostgreSQL
*/
function normalizeFormDataArrays(formData: Record<string, any>): Record<string, any> {
if (!formData || typeof formData !== "object") return formData;
const normalized: Record<string, any> = {};
for (const [key, value] of Object.entries(formData)) {
if (Array.isArray(value)) {
// 배열 내 숫자를 문자열로 변환 후 쉼표 구분
const stringValue = value
.map(v => typeof v === "number" ? String(v) : v)
.filter(v => v !== null && v !== undefined && v !== "")
.join(",");
console.log(`🔧 [normalizeFormDataArrays] 배열→문자열: ${key}`, { original: value, converted: stringValue });
normalized[key] = stringValue;
} else {
normalized[key] = value;
}
}
return normalized;
}
/**
*
*/
@ -978,6 +1002,28 @@ export class ButtonActionExecutor {
if (isUpdate) {
// UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우)
// 🔧 UPDATE 전 formData 배열 → 쉼표 구분 문자열 변환 (리피터 데이터 제외)
for (const key of Object.keys(formData)) {
const value = formData[key];
if (Array.isArray(value)) {
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
const isRepeaterData = value.length > 0 &&
typeof value[0] === "object" &&
value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
if (!isRepeaterData) {
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환
const stringValue = value
.map(v => typeof v === "number" ? String(v) : v)
.filter(v => v !== null && v !== undefined && v !== "")
.join(",");
console.log(`🔧 [handleSave UPDATE] 배열→문자열 변환: ${key}`, { original: value, converted: stringValue });
formData[key] = stringValue;
}
}
}
if (hasRealOriginalData) {
// 부분 업데이트: 변경된 필드만 업데이트
@ -1131,11 +1177,30 @@ export class ButtonActionExecutor {
}
}
// 🆕 배열 데이터(리피터 데이터) 제거 - 마스터 테이블에는 배열 데이터를 저장하지 않음
// 🆕 배열 데이터 처리 - 마스터 테이블에는 배열 데이터를 저장하지 않음
// 리피터 데이터는 별도의 RepeaterFieldGroup/V2Repeater 저장 로직에서 처리됨
// 🔧 단순 배열(다중 선택)은 쉼표 구분 문자열로 변환, 리피터 데이터 배열은 제거
for (const key of Object.keys(dataWithUserInfo)) {
if (Array.isArray(dataWithUserInfo[key])) {
delete dataWithUserInfo[key];
const value = dataWithUserInfo[key];
if (Array.isArray(value)) {
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
const isRepeaterData = value.length > 0 &&
typeof value[0] === "object" &&
value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
if (isRepeaterData) {
// 리피터 데이터는 제거 (별도 저장 로직에서 처리)
delete dataWithUserInfo[key];
} else {
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환
const stringValue = value
.map(v => typeof v === "number" ? String(v) : v)
.filter(v => v !== null && v !== undefined && v !== "")
.join(",");
console.log(`🔧 [handleSave] 배열→문자열 변환: ${key}`, { original: value, converted: stringValue });
dataWithUserInfo[key] = stringValue;
}
}
}
@ -3864,7 +3929,8 @@ export class ButtonActionExecutor {
case "form":
if (context.formData && Object.keys(context.formData).length > 0) {
sourceData = [context.formData];
// 🔧 배열 값을 쉼표 구분 문자열로 변환
sourceData = [normalizeFormDataArrays(context.formData)];
} else {
console.warn("⚠️ form 모드이지만 폼 데이터가 없습니다.");
}
@ -3909,7 +3975,8 @@ export class ButtonActionExecutor {
dataSourceType = "table-selection";
}
} else if (context.formData && Object.keys(context.formData).length > 0) {
sourceData = [context.formData];
// 🔧 배열 값을 쉼표 구분 문자열로 변환
sourceData = [normalizeFormDataArrays(context.formData)];
dataSourceType = "form";
}
break;
@ -4046,8 +4113,8 @@ export class ButtonActionExecutor {
console.log("📦 [executeAfterSaveControl] savedData 필드:", Object.keys(context.savedData));
console.log("📦 [executeAfterSaveControl] savedData.sabun:", context.savedData.sabun);
} else if (context.formData && Object.keys(context.formData).length > 0) {
// 폼 데이터 사용
sourceData = [context.formData];
// 폼 데이터 사용 (🔧 배열 값을 쉼표 구분 문자열로 변환)
sourceData = [normalizeFormDataArrays(context.formData)];
console.log("📦 [executeAfterSaveControl] formData 사용:", sourceData);
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
// 테이블 섹션 데이터 (마지막 순위)
@ -4164,8 +4231,8 @@ export class ButtonActionExecutor {
sourceData = Array.isArray(context.savedData) ? context.savedData : [context.savedData];
console.log("📦 [executeSingleFlowControl] savedData 사용:", sourceData);
} else if (context.formData && Object.keys(context.formData).length > 0) {
// 폼 데이터 사용
sourceData = [context.formData];
// 폼 데이터 사용 (🔧 배열 값을 쉼표 구분 문자열로 변환)
sourceData = [normalizeFormDataArrays(context.formData)];
console.log("📦 [executeSingleFlowControl] formData 사용:", sourceData);
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
// 테이블 섹션 데이터 (마지막 순위)

View File

@ -95,6 +95,8 @@ export interface V2BaseProps {
autoFill?: AutoFillConfig;
// 유효성 검사
validation?: ValidationRule[];
// 디자인 모드 (클릭 방지)
isDesignMode?: boolean;
}
// ===== V2Input =====
@ -129,7 +131,7 @@ export interface V2InputProps extends V2BaseProps {
// ===== V2Select =====
export type V2SelectMode = "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
export type V2SelectMode = "dropdown" | "combobox" | "radio" | "check" | "tag" | "tagbox" | "toggle" | "swap";
export type V2SelectSource = "static" | "code" | "db" | "api" | "entity" | "category";
export interface SelectOption {