diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 20f37f8f..3a3d4e12 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -4319,7 +4319,7 @@ export const TableListComponent: React.FC = ({ // 다중 값인 경우: 여러 배지 렌더링 return ( -
+
{values.map((val, idx) => { const categoryData = mapping?.[val]; const displayLabel = categoryData?.label || val; @@ -4328,7 +4328,7 @@ export const TableListComponent: React.FC = ({ // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 if (!displayColor || displayColor === "none" || !categoryData) { return ( - + {displayLabel} {idx < values.length - 1 && ", "} @@ -4342,7 +4342,7 @@ export const TableListComponent: React.FC = ({ backgroundColor: displayColor, borderColor: displayColor, }} - className="text-white" + className="shrink-0 whitespace-nowrap text-white" > {displayLabel} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 26acaf34..c806e0df 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -247,6 +247,10 @@ export function UniversalFormModalComponent({ // 폼 데이터 상태 const [formData, setFormData] = useState({}); + // formDataRef: 항상 최신 formData를 유지하는 ref + // React 상태 업데이트는 비동기적이므로, handleBeforeFormSave 등에서 + // 클로저의 formData가 오래된 값을 참조하는 문제를 방지 + const formDataRef = useRef({}); const [, setOriginalData] = useState>({}); // 반복 섹션 데이터 @@ -398,18 +402,19 @@ export function UniversalFormModalComponent({ console.log("[UniversalFormModal] beforeFormSave 이벤트 수신"); console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields)); + // formDataRef.current 사용: blur+click 동시 처리 시 클로저의 formData가 오래된 문제 방지 + const latestFormData = formDataRef.current; + // 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용) - // - 신규 등록: formData.id가 없으므로 영향 없음 - // - 편집 모드: formData.id가 있으면 메인 테이블 UPDATE에 사용 - if (formData.id !== undefined && formData.id !== null && formData.id !== "") { - event.detail.formData.id = formData.id; - console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, formData.id); + if (latestFormData.id !== undefined && latestFormData.id !== null && latestFormData.id !== "") { + event.detail.formData.id = latestFormData.id; + console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, latestFormData.id); } // UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함) // 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀 // (UniversalFormModal이 해당 필드의 주인이므로) - for (const [key, value] of Object.entries(formData)) { + for (const [key, value] of Object.entries(latestFormData)) { // 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합 const isConfiguredField = configuredFields.has(key); const isNumberingRuleId = key.endsWith("_numberingRuleId"); @@ -432,17 +437,13 @@ export function UniversalFormModalComponent({ } // 🆕 테이블 섹션 데이터 병합 (품목 리스트 등) - // 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블), - // handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용 - for (const [key, value] of Object.entries(formData)) { - // 싱글/더블 언더스코어 모두 처리 + // formDataRef.current(= latestFormData) 사용: React 상태 커밋 전에도 최신 데이터 보장 + for (const [key, value] of Object.entries(latestFormData)) { + // _tableSection_ 과 __tableSection_ 모두 원본 키 그대로 전달 + // buttonActions.ts에서 DB데이터(__tableSection_)와 수정데이터(_tableSection_)를 구분하여 병합 if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) { - // 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대) - const normalizedKey = key.startsWith("__tableSection_") - ? key.replace("__tableSection_", "_tableSection_") - : key; - event.detail.formData[normalizedKey] = value; - console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key} → ${normalizedKey}, ${value.length}개 항목`); + event.detail.formData[key] = value; + console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`); } // 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용) @@ -457,6 +458,22 @@ export function UniversalFormModalComponent({ event.detail.formData._originalGroupedData = originalGroupedData; console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`); } + + // 🆕 부모 formData의 중첩 객체(modalKey)도 최신 데이터로 업데이트 + // onChange(setTimeout)가 아직 부모에 전파되지 않았을 수 있으므로 직접 업데이트 + for (const parentKey of Object.keys(event.detail.formData)) { + const parentValue = event.detail.formData[parentKey]; + if (parentValue && typeof parentValue === "object" && !Array.isArray(parentValue)) { + const hasTableSection = Object.keys(parentValue).some( + (k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"), + ); + if (hasTableSection) { + event.detail.formData[parentKey] = { ...latestFormData }; + console.log(`[UniversalFormModal] 부모 중첩 객체 업데이트: ${parentKey}`); + break; + } + } + } }; window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); @@ -482,10 +499,11 @@ export function UniversalFormModalComponent({ // 테이블 섹션 데이터 설정 const tableSectionKey = `_tableSection_${tableSection.id}`; - setFormData((prev) => ({ - ...prev, - [tableSectionKey]: _groupedData, - })); + setFormData((prev) => { + const newData = { ...prev, [tableSectionKey]: _groupedData }; + formDataRef.current = newData; + return newData; + }); groupedDataInitializedRef.current = true; }, [_groupedData, config.sections]); @@ -965,6 +983,7 @@ export function UniversalFormModalComponent({ } setFormData(newFormData); + formDataRef.current = newFormData; setRepeatSections(newRepeatSections); setCollapsedSections(newCollapsed); setActivatedOptionalFieldGroups(newActivatedGroups); @@ -1132,6 +1151,9 @@ export function UniversalFormModalComponent({ console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`); } + // ref 즉시 업데이트 (React 상태 커밋 전에도 최신 데이터 접근 가능) + formDataRef.current = newData; + // onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용) if (onChange) { setTimeout(() => onChange(newData), 0); diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 1f922188..b4c16e64 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -4267,7 +4267,7 @@ export const TableListComponent: React.FC = ({ // 다중 값인 경우: 여러 배지 렌더링 return ( -
+
{values.map((val, idx) => { const categoryData = mapping?.[val]; const displayLabel = categoryData?.label || val; @@ -4276,7 +4276,7 @@ export const TableListComponent: React.FC = ({ // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 if (!displayColor || displayColor === "none" || !categoryData) { return ( - + {displayLabel} {idx < values.length - 1 && ", "} @@ -4290,7 +4290,7 @@ export const TableListComponent: React.FC = ({ backgroundColor: displayColor, borderColor: displayColor, }} - className="text-white" + className="shrink-0 whitespace-nowrap text-white" > {displayLabel} diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index b56d563c..6009e13f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -2108,31 +2108,72 @@ export class ButtonActionExecutor { const sections: any[] = modalComponentConfig?.sections || []; const saveConfig = modalComponentConfig?.saveConfig || {}; - // _tableSection_ 데이터 추출 + // 테이블 섹션 데이터 수집: DB 전체 데이터를 베이스로, 수정 데이터를 오버라이드 const tableSectionData: Record = {}; const commonFieldsData: Record = {}; - // 🆕 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용) - // modalData 내부 또는 최상위 formData에서 찾음 + // 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용) const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || []; + // 1단계: DB 데이터(__tableSection_)와 수정 데이터(_tableSection_)를 별도로 수집 + const dbSectionData: Record = {}; + const modifiedSectionData: Record = {}; + + // 1-1: modalData(부모의 중첩 객체)에서 수집 for (const [key, value] of Object.entries(modalData)) { - // initializeForm: __tableSection_ (더블), 수정 시: _tableSection_ (싱글) → 통일 처리 - if (key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) { - if (Array.isArray(value)) { - const normalizedKey = key.startsWith("__tableSection_") - ? key.replace("__tableSection_", "") - : key.replace("_tableSection_", ""); - // 싱글 언더스코어 키(수정된 데이터)가 더블 언더스코어 키(초기 데이터)보다 우선 - if (!tableSectionData[normalizedKey] || key.startsWith("_tableSection_")) { - tableSectionData[normalizedKey] = value as any[]; - } - } + if (key.startsWith("__tableSection_") && Array.isArray(value)) { + const sectionId = key.replace("__tableSection_", ""); + dbSectionData[sectionId] = value; + } else if (key.startsWith("_tableSection_") && Array.isArray(value)) { + const sectionId = key.replace("_tableSection_", ""); + modifiedSectionData[sectionId] = value; } else if (!key.startsWith("_")) { commonFieldsData[key] = value; } } + // 1-2: top-level formData에서도 수집 (handleBeforeFormSave가 직접 설정한 최신 데이터) + // modalData(중첩 객체)가 아직 업데이트되지 않았을 수 있으므로 보완 + for (const [key, value] of Object.entries(formData)) { + if (key === universalFormModalKey) continue; + if (key.startsWith("__tableSection_") && Array.isArray(value)) { + const sectionId = key.replace("__tableSection_", ""); + if (!dbSectionData[sectionId]) { + dbSectionData[sectionId] = value; + } + } else if (key.startsWith("_tableSection_") && Array.isArray(value)) { + const sectionId = key.replace("_tableSection_", ""); + if (!modifiedSectionData[sectionId]) { + modifiedSectionData[sectionId] = value; + } + } + } + + // 2단계: DB 데이터를 베이스로, 수정 데이터를 아이템별로 병합하여 전체 데이터 구성 + // - DB 데이터(__tableSection_): initializeForm에서 로드한 전체 컬럼 데이터 + // - 수정 데이터(_tableSection_): _groupedData 또는 사용자 UI 수정을 통해 설정된 데이터 (일부 컬럼만 포함 가능) + // - 병합: { ...dbItem, ...modItem } → DB 전체 컬럼 유지 + 수정된 필드만 오버라이드 + const allSectionIds = new Set([...Object.keys(dbSectionData), ...Object.keys(modifiedSectionData)]); + + for (const sectionId of allSectionIds) { + const dbItems = dbSectionData[sectionId] || []; + const modItems = modifiedSectionData[sectionId]; + + if (modItems) { + tableSectionData[sectionId] = modItems.map((modItem) => { + if (modItem.id) { + const dbItem = dbItems.find((db) => String(db.id) === String(modItem.id)); + if (dbItem) { + return { ...dbItem, ...modItem }; + } + } + return modItem; + }); + } else { + tableSectionData[sectionId] = dbItems; + } + } + // 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음 const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0); if (!hasTableSectionData && originalGroupedData.length === 0) { @@ -2262,28 +2303,26 @@ export class ButtonActionExecutor { // 각 테이블 섹션 처리 for (const [sectionId, currentItems] of Object.entries(tableSectionData)) { - // 🆕 해당 섹션의 설정 찾기 const sectionConfig = sections.find((s) => s.id === sectionId); const targetTableName = sectionConfig?.tableConfig?.saveConfig?.targetTable; - - // 🆕 실제 저장할 테이블 결정 - // - targetTable이 있으면 해당 테이블에 저장 - // - targetTable이 없으면 메인 테이블에 저장 const saveTableName = targetTableName || tableName!; + // 섹션별 DB 원본 데이터 조회 (전체 컬럼 보장) + // _originalTableSectionData_: initializeForm에서 DB 로드 시 저장한 원본 데이터 + const sectionOriginalKey = `_originalTableSectionData_${sectionId}`; + const sectionOriginalData: any[] = modalData[sectionOriginalKey] || formData[sectionOriginalKey] || []; + // 1️⃣ 신규 품목 INSERT (id가 없는 항목) const newItems = currentItems.filter((item) => !item.id); for (const item of newItems) { const rowToSave = { ...commonFieldsData, ...item, ...userInfo }; - // 내부 메타데이터 제거 Object.keys(rowToSave).forEach((key) => { if (key.startsWith("_")) { delete rowToSave[key]; } }); - // 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우) if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) { rowToSave[saveConfig.primaryKeyColumn] = mainRecordId; } @@ -2303,28 +2342,30 @@ export class ButtonActionExecutor { insertedCount++; } - // 2️⃣ 기존 품목 UPDATE (id가 있는 항목, 변경된 경우만) + // 2️⃣ 기존 품목 UPDATE (id가 있는 항목) + // 전체 데이터 기반 저장: DB 데이터(__tableSection_)를 베이스로 수정 데이터가 병합된 완전한 item 사용 const existingItems = currentItems.filter((item) => item.id); for (const item of existingItems) { - const originalItem = originalGroupedData.find((orig) => orig.id === item.id); + // DB 원본 데이터 우선 사용 (전체 컬럼 보장), 없으면 originalGroupedData에서 탐색 + const originalItem = + sectionOriginalData.find((orig) => String(orig.id) === String(item.id)) || + originalGroupedData.find((orig) => String(orig.id) === String(item.id)); + + // 마스터/디테일 분리 시: 디테일 데이터만 사용 (마스터 필드 병합 안 함) + // 같은 테이블 시: 공통 필드도 병합 (공유 필드 업데이트 필요) + const dataToSave = hasSeparateTargetTable ? item : { ...commonFieldsData, ...item }; if (!originalItem) { - // 🆕 폴백 로직: 원본 데이터가 없어도 id가 있으면 UPDATE 시도 - // originalGroupedData 전달이 누락된 경우를 처리 - console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - id가 있으므로 UPDATE 시도 (폴백): id=${item.id}`); + // 원본 없음: 전체 데이터로 UPDATE 실행 + console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - 전체 데이터로 UPDATE: id=${item.id}`); - // 마스터/디테일 테이블 분리 시: commonFieldsData 병합하지 않음 - // → 동명 컬럼(예: memo)이 마스터 값으로 덮어씌워지는 문제 방지 - const rowToUpdate = hasSeparateTargetTable - ? { ...item, ...userInfo } - : { ...commonFieldsData, ...item, ...userInfo }; + const rowToUpdate = { ...dataToSave, ...userInfo }; Object.keys(rowToUpdate).forEach((key) => { if (key.startsWith("_")) { delete rowToUpdate[key]; } }); - // id를 유지하고 UPDATE 실행 const updateResult = await DynamicFormApi.updateFormData(item.id, { tableName: saveTableName, data: rowToUpdate, @@ -2338,20 +2379,14 @@ export class ButtonActionExecutor { continue; } - // 변경 사항 확인 - // 마스터/디테일 테이블이 분리된 경우(hasSeparateTargetTable): - // 마스터 필드(commonFieldsData)를 디테일에 병합하지 않음 - // → 동명 컬럼(예: memo)이 마스터 값으로 덮어씌워지는 문제 방지 - // 같은 테이블인 경우: 공통 필드 병합 유지 (공유 필드 업데이트 필요) - const dataForComparison = hasSeparateTargetTable ? item : { ...commonFieldsData, ...item }; - const hasChanges = this.checkForChanges(originalItem, dataForComparison); + // 변경 사항 확인: 원본(DB) vs 현재(병합된 전체 데이터) + const hasChanges = this.checkForChanges(originalItem, dataToSave); if (hasChanges) { - // 변경된 필드만 추출하여 부분 업데이트 const updateResult = await DynamicFormApi.updateFormDataPartial( item.id, originalItem, - dataForComparison, + dataToSave, saveTableName, ); @@ -2360,16 +2395,11 @@ export class ButtonActionExecutor { } updatedCount++; - } else { } } // 3️⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목) - // 🆕 테이블 섹션별 원본 데이터 사용 (우선), 없으면 전역 originalGroupedData 사용 - const sectionOriginalKey = `_originalTableSectionData_${sectionId}`; - const sectionOriginalData: any[] = modalData[sectionOriginalKey] || formData[sectionOriginalKey] || []; - - // 섹션별 원본 데이터가 있으면 사용, 없으면 전역 originalGroupedData 사용 + // 섹션별 DB 원본 데이터 사용 (위에서 이미 조회), 없으면 전역 originalGroupedData 사용 const originalDataForDelete = sectionOriginalData.length > 0 ? sectionOriginalData : originalGroupedData; // ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지)