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:
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user