fix: Improve TableListComponent and UniversalFormModalComponent for better data handling

- Updated TableListComponent to use flex-nowrap and overflow-hidden for better badge rendering.
- Enhanced UniversalFormModalComponent to maintain the latest formData using a ref, preventing stale closures during form save events.
- Improved data merging logic in UniversalFormModalComponent to ensure accurate updates and maintain original data integrity.
- Refactored buttonActions to streamline table section data collection and merging, ensuring proper handling of modified and original data during save operations.
This commit is contained in:
kjs 2026-02-25 13:53:20 +09:00
parent 60b1ac1442
commit 38dda2f807
4 changed files with 125 additions and 73 deletions

View File

@ -4319,7 +4319,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 다중 값인 경우: 여러 배지 렌더링
return (
<div className="flex flex-wrap gap-1">
<div className="flex flex-nowrap gap-1 overflow-hidden">
{values.map((val, idx) => {
const categoryData = mapping?.[val];
const displayLabel = categoryData?.label || val;
@ -4328,7 +4328,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) {
return (
<span key={idx} className="text-sm">
<span key={idx} className="shrink-0 whitespace-nowrap text-sm">
{displayLabel}
{idx < values.length - 1 && ", "}
</span>
@ -4342,7 +4342,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
className="shrink-0 whitespace-nowrap text-white"
>
{displayLabel}
</Badge>

View File

@ -247,6 +247,10 @@ export function UniversalFormModalComponent({
// 폼 데이터 상태
const [formData, setFormData] = useState<FormDataState>({});
// formDataRef: 항상 최신 formData를 유지하는 ref
// React 상태 업데이트는 비동기적이므로, handleBeforeFormSave 등에서
// 클로저의 formData가 오래된 값을 참조하는 문제를 방지
const formDataRef = useRef<FormDataState>({});
const [, setOriginalData] = useState<Record<string, any>>({});
// 반복 섹션 데이터
@ -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);

View File

@ -4267,7 +4267,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 다중 값인 경우: 여러 배지 렌더링
return (
<div className="flex flex-wrap gap-1">
<div className="flex flex-nowrap gap-1 overflow-hidden">
{values.map((val, idx) => {
const categoryData = mapping?.[val];
const displayLabel = categoryData?.label || val;
@ -4276,7 +4276,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) {
return (
<span key={idx} className="text-sm">
<span key={idx} className="shrink-0 whitespace-nowrap text-sm">
{displayLabel}
{idx < values.length - 1 && ", "}
</span>
@ -4290,7 +4290,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
className="shrink-0 whitespace-nowrap text-white"
>
{displayLabel}
</Badge>

View File

@ -2108,31 +2108,72 @@ export class ButtonActionExecutor {
const sections: any[] = modalComponentConfig?.sections || [];
const saveConfig = modalComponentConfig?.saveConfig || {};
// _tableSection_ 데이터 추출
// 테이블 섹션 데이터 수집: DB 전체 데이터를 베이스로, 수정 데이터를 오버라이드
const tableSectionData: Record<string, any[]> = {};
const commonFieldsData: Record<string, any> = {};
// 🆕 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용)
// modalData 내부 또는 최상위 formData에서 찾음
// 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용)
const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || [];
// 1단계: DB 데이터(__tableSection_)와 수정 데이터(_tableSection_)를 별도로 수집
const dbSectionData: Record<string, any[]> = {};
const modifiedSectionData: Record<string, any[]> = {};
// 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 문자열 불일치 방지)