From a424b3b775631c5bd3ada83c53c413ff9f3fe1be Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 6 Feb 2026 09:15:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20V2=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=B0=8F=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TableManagementService에서 V2 레이아웃 동기화 로직을 추가하여, 새로운 입력 타입에 따라 화면 레이아웃을 자동으로 업데이트하도록 개선하였습니다. - syncScreenLayoutsV2InputType 메서드를 통해 V2 레이아웃의 컴포넌트 source를 동기화하는 기능을 구현하였습니다. - EditModal에서 배열 데이터를 쉼표 구분 문자열로 변환하는 로직을 추가하여, 손상된 값을 필터링하고 데이터 저장 시 일관성을 높였습니다. - CategorySelectComponent에서 불필요한 스타일 및 높이 관련 props를 제거하여 코드 간결성을 개선하였습니다. - V2Select 및 관련 컴포넌트에서 height 스타일을 통일하여 사용자 경험을 향상시켰습니다. --- .../src/services/nodeFlowExecutionService.ts | 113 +++++++- .../MULTI_SELECT_ARRAY_SERIALIZATION_FIX.md | 253 ++++++++++++++++++ frontend/components/screen/EditModal.tsx | 102 ++++++- frontend/components/screen/ScreenDesigner.tsx | 3 + frontend/components/v2/V2Select.tsx | 194 +++++++++++++- .../v2/config-panels/V2SelectConfigPanel.tsx | 2 + .../lib/registry/DynamicComponentRenderer.tsx | 100 +++---- .../CategorySelectComponent.tsx | 8 +- .../components/v2-select/V2SelectRenderer.tsx | 73 ++++- .../registry/components/v2-select/index.ts | 1 + frontend/lib/utils/buttonActions.ts | 85 +++++- frontend/types/v2-components.ts | 4 +- 12 files changed, 833 insertions(+), 105 deletions(-) create mode 100644 docs/DDD1542/MULTI_SELECT_ARRAY_SERIALIZATION_FIX.md diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index cadfdefc..9bc59d97 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -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; diff --git a/docs/DDD1542/MULTI_SELECT_ARRAY_SERIALIZATION_FIX.md b/docs/DDD1542/MULTI_SELECT_ARRAY_SERIALIZATION_FIX.md new file mode 100644 index 00000000..3d5ba77c --- /dev/null +++ b/docs/DDD1542/MULTI_SELECT_ARRAY_SERIALIZATION_FIX.md @@ -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) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 3dccd0db..e28c83b4 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -618,7 +618,36 @@ export const EditModal: React.FC = ({ 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 = ({ className }) => { } // 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) + // 🔧 단, 다중 선택 배열은 쉼표 구분 문자열로 변환하여 저장 const masterDataToSave: Record = {}; 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 = ({ className }) => { const changedData: Record = {}; 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; } }); diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 389e8366..88dd197f 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -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={() => {}} /> diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 84dd0d3c..218fe9b0 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -302,6 +302,127 @@ const TagSelect = forwardRef 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 ( +
+ + +
+ {selectedOptions.length > 0 ? ( + <> + {selectedOptions.map((option) => ( + + {option.label} + !disabled && handleRemove(e, option.value)} + /> + + ))} + + ) : ( + {placeholder} + )} + +
+
+ +
+ {options.map((option) => { + const isSelected = value.includes(option.value); + return ( +
!disabled && handleToggle(option.value)} + > + + {option.label} +
+ ); + })} + {options.length === 0 && ( +
+ 옵션이 없습니다 +
+ )} +
+
+
+
+ ); +}); +TagboxSelect.displayName = "TagboxSelect"; + /** * 토글 선택 컴포넌트 (Boolean용) */ @@ -461,6 +582,7 @@ export const V2Select = forwardRef( onChange, tableName, columnName, + isDesignMode, // 🔧 디자인 모드 (클릭 방지) } = props; // config가 없으면 기본값 사용 @@ -605,13 +727,13 @@ export const V2Select = forwardRef( 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( } } else if (!isValidColumnName) { // columnName이 없거나 유효하지 않으면 빈 옵션 - console.warn("V2Select: 유효한 columnName이 없어 옵션을 로드하지 않습니다.", { tableName, columnName }); } } @@ -669,6 +790,48 @@ export const V2Select = forwardRef( ? { 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 = { + radio: "라디오", + check: "체크박스", + checkbox: "체크박스", + tag: "태그", + tagbox: "태그박스", + toggle: "토글", + swap: "스왑", + }; + const modeName = modeNames[config.mode || ""] || config.mode; + + return ( +
+ [{modeName}] {sourceInfo} +
+ ); + } + switch (config.mode) { case "dropdown": case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운 @@ -720,6 +883,19 @@ export const V2Select = forwardRef( /> ); + case "tagbox": + return ( + + ); + case "toggle": return ( ( 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(
= ({ 드롭다운 + 콤보박스 (검색) 라디오 버튼 체크박스 태그 선택 + 태그박스 (태그+드롭다운) 토글 스위치 스왑 선택 diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 66a6e1a3..e6b13067 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -211,27 +211,11 @@ export const DynamicComponentRenderer: React.FC = // 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 = } // 액션에 따라 렌더링 결정 - 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 = 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 = // 컴포넌트의 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; diff --git a/frontend/lib/registry/components/category-select/CategorySelectComponent.tsx b/frontend/lib/registry/components/category-select/CategorySelectComponent.tsx index baa4cfa3..ba5d752d 100644 --- a/frontend/lib/registry/components/category-select/CategorySelectComponent.tsx +++ b/frontend/lib/registry/components/category-select/CategorySelectComponent.tsx @@ -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<