feat: V2 레이아웃 동기화 및 컴포넌트 개선
- TableManagementService에서 V2 레이아웃 동기화 로직을 추가하여, 새로운 입력 타입에 따라 화면 레이아웃을 자동으로 업데이트하도록 개선하였습니다. - syncScreenLayoutsV2InputType 메서드를 통해 V2 레이아웃의 컴포넌트 source를 동기화하는 기능을 구현하였습니다. - EditModal에서 배열 데이터를 쉼표 구분 문자열로 변환하는 로직을 추가하여, 손상된 값을 필터링하고 데이터 저장 시 일관성을 높였습니다. - CategorySelectComponent에서 불필요한 스타일 및 높이 관련 props를 제거하여 코드 간결성을 개선하였습니다. - V2Select 및 관련 컴포넌트에서 height 스타일을 통일하여 사용자 경험을 향상시켰습니다.
This commit is contained in:
parent
9f3437d499
commit
a424b3b775
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -819,12 +848,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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -908,7 +964,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;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6207,6 +6207,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}
|
||||
|
|
@ -6375,6 +6376,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
onDragStart={(e) => startComponentDrag(child, e)}
|
||||
onDragEnd={endDrag}
|
||||
selectedScreen={selectedScreen}
|
||||
tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달
|
||||
// onZoneComponentDrop 제거
|
||||
onZoneClick={handleZoneClick}
|
||||
// 설정 변경 핸들러 (자식 컴포넌트용)
|
||||
|
|
@ -6597,6 +6599,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
component={relativeButton}
|
||||
isDesignMode={true}
|
||||
formData={{}}
|
||||
tableName={selectedScreen?.tableName}
|
||||
onDataflowComplete={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -758,16 +934,6 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
|
||||
// 🔍 디버깅: 높이값 확인 (warn으로 변경하여 캡처되도록)
|
||||
console.warn("🔍 [V2Select] 높이 디버깅:", {
|
||||
id,
|
||||
"size?.height": size?.height,
|
||||
"style?.height": style?.height,
|
||||
componentHeight,
|
||||
size,
|
||||
style,
|
||||
});
|
||||
|
||||
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
|
||||
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
|
|
@ -777,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,
|
||||
|
|
|
|||
|
|
@ -116,9 +116,11 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -19,11 +19,55 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
|||
const config = component.componentConfig || component.config || {};
|
||||
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) && defaultValue && isInteractive && onFormDataChange && columnName) {
|
||||
// 초기 렌더링 시 기본값을 formData에 설정
|
||||
|
|
@ -35,10 +79,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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -47,16 +97,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;
|
||||
|
|
@ -72,7 +114,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 || "선택하세요",
|
||||
|
|
@ -81,10 +124,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}
|
||||
|
|
|
|||
|
|
@ -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: "스왑 (좌우 이동)" },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3863,7 +3928,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 모드이지만 폼 데이터가 없습니다.");
|
||||
}
|
||||
|
|
@ -3908,7 +3974,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;
|
||||
|
|
@ -4045,8 +4112,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) {
|
||||
// 테이블 섹션 데이터 (마지막 순위)
|
||||
|
|
@ -4163,8 +4230,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) {
|
||||
// 테이블 섹션 데이터 (마지막 순위)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue