feat: Add close confirmation dialog to ScreenModal and enhance SelectedItemsDetailInputComponent

- Implemented a confirmation dialog in ScreenModal to prevent accidental closure, allowing users to confirm before exiting and potentially losing unsaved data.
- Enhanced SelectedItemsDetailInputComponent by ensuring that base records are created even when detail data is absent, maintaining item-client mapping.
- Improved logging for better traceability during the UPSERT process and refined the handling of parent data mappings for more robust data management.
This commit is contained in:
DDD1542
2026-02-09 13:22:48 +09:00
parent bb4d90fd58
commit 2e500f066f
3 changed files with 454 additions and 116 deletions

View File

@@ -490,22 +490,25 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0);
if (allGroupsEmpty) {
// 🔧 아이템이 1개뿐이면 기본 레코드 생성 (첫 저장 시)
// 아이템이 여러 개면 빈 아이템은 건너뛰기 (불필요한 NULL 레코드 방지)
if (itemsList.length === 1) {
console.log("📝 [generateCartesianProduct] 단일 아이템, 모든 그룹 비어있음 - 기본 레코드 생성", {
itemIndex,
itemId: item.id,
});
// 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨
allRecords.push({});
} else {
console.log("⏭️ [generateCartesianProduct] 다중 아이템 중 빈 아이템 - 건너뜀", {
itemIndex,
itemId: item.id,
totalItems: itemsList.length,
});
}
// 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지)
// autoFillFrom 필드 (item_id 등)는 반드시 포함시켜야 나중에 식별 가능
const baseRecord: Record<string, any> = {};
additionalFields.forEach((f) => {
if (f.autoFillFrom && item.originalData) {
const value = item.originalData[f.autoFillFrom];
if (value !== undefined && value !== null) {
baseRecord[f.name] = value;
}
}
});
console.log("📝 [generateCartesianProduct] 모든 그룹 비어있음 - 기본 레코드 생성 (매핑 유지)", {
itemIndex,
itemId: item.id,
baseRecord,
totalItems: itemsList.length,
});
allRecords.push(baseRecord);
return;
}
@@ -579,17 +582,15 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
return;
}
// 🆕 수정 모드인지 확인 (URL에 mode=edit이 있으면)
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get("mode");
const isEditMode = mode === "edit";
// parentDataMapping이 있으면 UPSERT API로 직접 저장 (생성/수정 모드 무관)
const hasParentMapping = componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0;
console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { mode, isEditMode });
console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { hasParentMapping });
if (isEditMode && componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0) {
// 🔄 수정 모드: UPSERT API 사용
if (hasParentMapping) {
// UPSERT API로 직접 DB 저장
try {
console.log("🔄 [SelectedItemsDetailInput] UPSERT 모드로 저장 시작");
console.log("🔄 [SelectedItemsDetailInput] UPSERT 저장 시작");
console.log("📋 [SelectedItemsDetailInput] componentConfig:", {
targetTable: componentConfig.targetTable,
parentDataMapping: componentConfig.parentDataMapping,
@@ -622,14 +623,30 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
);
componentConfig.parentDataMapping.forEach((mapping) => {
// 🆕 Entity Join 필드도 처리 (예: customer_code -> customer_id_customer_code)
const value = getFieldValue(sourceData, mapping.sourceField);
// 1차: formData(sourceData)에서 찾기
let value = getFieldValue(sourceData, mapping.sourceField);
// 2차: formData에 없으면 dataRegistry[sourceTable]에서 찾기
// v2-split-panel-layout에서 좌측 항목 선택 시 dataRegistry에 저장한 데이터 활용
if ((value === undefined || value === null) && mapping.sourceTable) {
const registryData = dataRegistry[mapping.sourceTable];
if (registryData && registryData.length > 0) {
const registryItem = registryData[0].originalData || registryData[0];
value = registryItem[mapping.sourceField];
console.log(
`🔄 [parentKeys] dataRegistry["${mapping.sourceTable}"]에서 찾음: ${mapping.sourceField} =`,
value,
);
}
}
if (value !== undefined && value !== null) {
parentKeys[mapping.targetField] = value;
console.log(`✅ [parentKeys] ${mapping.sourceField}${mapping.targetField}:`, value);
} else {
console.warn(
`⚠️ [SelectedItemsDetailInput] 부모 키 누락: ${mapping.sourceField}${mapping.targetField}`,
`(sourceData, dataRegistry["${mapping.sourceTable}"] 모두 확인)`,
);
}
});
@@ -657,15 +674,6 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
return;
}
// items를 Cartesian Product로 변환
const records = generateCartesianProduct(items);
console.log("📦 [SelectedItemsDetailInput] UPSERT 레코드:", {
parentKeys,
recordCount: records.length,
records,
});
// targetTable 검증
if (!componentConfig.targetTable) {
console.error("❌ [SelectedItemsDetailInput] targetTable이 설정되지 않았습니다!");
@@ -674,57 +682,228 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
detail: { message: "대상 테이블이 설정되지 않았습니다." },
}),
);
// 🔧 기본 저장 건너뛰기 - event.detail 객체 직접 수정
if (event instanceof CustomEvent && event.detail) {
(event.detail as any).skipDefaultSave = true;
console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (targetTable 없음)");
}
return;
}
// 🔧 먼저 기본 저장 로직 건너뛰기 설정 (UPSERT 전에!)
// buttonActions.ts에서 beforeSaveEventDetail 객체를 event.detail로 전달하므로 직접 수정 가능
// 🔧 기본 저장 건너뛰기 설정 (UPSERT 전에!)
if (event instanceof CustomEvent && event.detail) {
(event.detail as any).skipDefaultSave = true;
console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (UPSERT 처리)", event.detail);
} else {
console.error("❌ [SelectedItemsDetailInput] event.detail이 없습니다! 기본 저장이 실행될 수 있습니다.", event);
console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (UPSERT 처리)");
}
console.log("🎯 [SelectedItemsDetailInput] targetTable:", componentConfig.targetTable);
console.log("📡 [SelectedItemsDetailInput] UPSERT API 호출 직전:", {
tableName: componentConfig.targetTable,
tableNameType: typeof componentConfig.targetTable,
tableNameLength: componentConfig.targetTable?.length,
parentKeys,
recordsCount: records.length,
const { dataApi } = await import("@/lib/api/data");
const groups = componentConfig.fieldGroups || [];
const additionalFields = componentConfig.additionalFields || [];
const mainTable = componentConfig.targetTable!;
// fieldGroup별 sourceTable 분류
const groupsByTable = new Map<string, typeof groups>();
groups.forEach((group) => {
const table = group.sourceTable || mainTable;
if (!groupsByTable.has(table)) {
groupsByTable.set(table, []);
}
groupsByTable.get(table)!.push(group);
});
// UPSERT API 호출
const { dataApi } = await import("@/lib/api/data");
const result = await dataApi.upsertGroupedRecords(componentConfig.targetTable, parentKeys, records);
// 디테일 테이블이 있는지 확인 (mainTable과 다른 sourceTable)
const detailTables = [...groupsByTable.keys()].filter((t) => t !== mainTable);
const hasDetailTable = detailTables.length > 0;
if (result.success) {
console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", {
inserted: result.inserted,
updated: result.updated,
deleted: result.deleted,
console.log("🏗️ [SelectedItemsDetailInput] 저장 구조:", {
mainTable,
detailTables,
hasDetailTable,
groupsByTable: Object.fromEntries(groupsByTable),
});
if (hasDetailTable) {
// ============================================================
// 🆕 2단계 저장: 메인 테이블 + 디테일 테이블 분리 저장
// 예: customer_item_mapping (매핑) + customer_item_prices (가격)
// ============================================================
const mainGroups = groupsByTable.get(mainTable) || [];
let totalInserted = 0;
let totalUpdated = 0;
for (const item of items) {
// Step 1: 메인 테이블 매핑 레코드 생성/갱신
const mappingData: Record<string, any> = { ...parentKeys };
// 메인 그룹 필드 추출 (customer_item_code, customer_item_name 등)
mainGroups.forEach((group) => {
const entries = item.fieldGroups[group.id] || [];
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
if (entries.length > 0) {
groupFields.forEach((field) => {
if (entries[0][field.name] !== undefined) {
mappingData[field.name] = entries[0][field.name];
}
});
}
// autoFillFrom 필드 처리 (item_id 등)
groupFields.forEach((field) => {
if (field.autoFillFrom && item.originalData) {
const value = item.originalData[field.autoFillFrom];
if (value !== undefined && value !== null) {
mappingData[field.name] = value;
}
}
});
});
console.log("📋 [2단계 저장] Step 1 - 매핑 데이터:", mappingData);
// 기존 매핑 레코드 찾기
let mappingId: string | null = null;
const searchFilters: Record<string, any> = {};
// parentKeys + item_id로 검색
Object.entries(parentKeys).forEach(([key, value]) => {
searchFilters[key] = value;
});
if (mappingData.item_id) {
searchFilters.item_id = mappingData.item_id;
}
try {
const searchResult = await dataApi.getTableData(mainTable, {
filters: searchFilters,
size: 1,
});
if (searchResult.data && searchResult.data.length > 0) {
// 기존 매핑 업데이트
mappingId = searchResult.data[0].id;
console.log("📌 [2단계 저장] 기존 매핑 발견:", mappingId);
await dataApi.updateRecord(mainTable, mappingId, mappingData);
totalUpdated++;
} else {
// 새 매핑 생성
const createResult = await dataApi.createRecord(mainTable, mappingData);
if (createResult.success && createResult.data) {
mappingId = createResult.data.id;
console.log("✨ [2단계 저장] 새 매핑 생성:", mappingId);
totalInserted++;
}
}
} catch (err) {
console.error("❌ [2단계 저장] 매핑 저장 실패:", err);
continue;
}
if (!mappingId) {
console.error("❌ [2단계 저장] mapping_id 획득 실패 - item:", mappingData.item_id);
continue;
}
// Step 2: 디테일 테이블에 가격 레코드 저장
for (const detailTable of detailTables) {
const detailGroups = groupsByTable.get(detailTable) || [];
const priceRecords: Record<string, any>[] = [];
detailGroups.forEach((group) => {
const entries = item.fieldGroups[group.id] || [];
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
entries.forEach((entry) => {
// 실제 값이 있는 엔트리만 저장
const hasValues = groupFields.some((field) => {
const value = entry[field.name];
return value !== undefined && value !== null && value !== "";
});
if (hasValues) {
const priceRecord: Record<string, any> = {
mapping_id: mappingId,
// 비정규화: 직접 필터링을 위해 customer_id, item_id 포함
...parentKeys,
item_id: mappingData.item_id,
};
groupFields.forEach((field) => {
if (entry[field.name] !== undefined) {
priceRecord[field.name] = entry[field.name];
}
});
priceRecords.push(priceRecord);
}
});
});
if (priceRecords.length > 0) {
console.log(`📋 [2단계 저장] Step 2 - ${detailTable} 레코드:`, {
mappingId,
count: priceRecords.length,
records: priceRecords,
});
const detailResult = await dataApi.upsertGroupedRecords(
detailTable,
{ mapping_id: mappingId },
priceRecords,
);
if (detailResult.success) {
console.log(`✅ [2단계 저장] ${detailTable} 저장 성공:`, detailResult);
} else {
console.error(`❌ [2단계 저장] ${detailTable} 저장 실패:`, detailResult.error);
}
} else {
console.log(`⏭️ [2단계 저장] ${detailTable} - 가격 레코드 없음 (빈 항목)`);
}
}
}
console.log("✅ [SelectedItemsDetailInput] 2단계 저장 완료:", {
inserted: totalInserted,
updated: totalUpdated,
});
// 저장 성공 이벤트 발생
// 저장 성공 이벤트
window.dispatchEvent(
new CustomEvent("formSaveSuccess", {
detail: { message: "데이터가 저장되었습니다." },
}),
);
} else {
console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error);
window.dispatchEvent(
new CustomEvent("formSaveError", {
detail: { message: result.error || "데이터 저장 실패" },
}),
);
// ============================================================
// 단일 테이블 저장 (기존 로직 - detailTable 없는 경우)
// ============================================================
const records = generateCartesianProduct(items);
console.log("📦 [SelectedItemsDetailInput] 단일 테이블 UPSERT:", {
tableName: mainTable,
parentKeys,
recordCount: records.length,
});
const result = await dataApi.upsertGroupedRecords(mainTable, parentKeys, records);
if (result.success) {
console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", {
inserted: result.inserted,
updated: result.updated,
deleted: result.deleted,
});
window.dispatchEvent(
new CustomEvent("formSaveSuccess", {
detail: { message: "데이터가 저장되었습니다." },
}),
);
} else {
console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error);
window.dispatchEvent(
new CustomEvent("formSaveError", {
detail: { message: result.error || "데이터 저장 실패" },
}),
);
}
}
} catch (error) {
console.error("❌ [SelectedItemsDetailInput] UPSERT 오류:", error);
@@ -769,7 +948,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
return () => {
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
};
}, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct]);
}, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct, dataRegistry]);
// 스타일 계산
const componentStyle: React.CSSProperties = {
@@ -844,15 +1023,27 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const unitMatch = roundingTypeLabel.match(/(\d+)/);
const unit = unitMatch ? parseInt(unitMatch[1]) : parseFloat(roundingTypeCode) || 1;
const priceBeforeRounding = price;
// roundingUnit 라벨로 반올림 방법 결정
if (roundingUnitLabel.includes("반올림") && !roundingUnitLabel.includes("없음")) {
price = Math.round(price / unit) * unit;
if (roundingUnitLabel.includes("없음") || !roundingUnitCode) {
// 반올림없음: 할인 적용된 원래 값 그대로
// price 변경 없음
} else if (roundingUnitLabel.includes("절삭")) {
price = Math.floor(price / unit) * unit;
} else if (roundingUnitLabel.includes("올림")) {
price = Math.ceil(price / unit) * unit;
} else if (roundingUnitLabel.includes("반올림")) {
price = Math.round(price / unit) * unit;
}
// "반올림없음"이면 그대로
console.log("🔢 [calculatePrice] 반올림 처리:", {
roundingTypeLabel,
roundingUnitLabel,
unit,
priceBeforeRounding,
priceAfterRounding: price,
});
return price;
},