엑셀 업로드 분할테이블 지원

This commit is contained in:
kjs
2026-01-09 11:21:16 +09:00
parent 2eccd1982c
commit cea3aa53ae
5 changed files with 1142 additions and 98 deletions

View File

@@ -42,6 +42,17 @@ export interface ExcelUploadModalProps {
keyColumn?: string;
onSuccess?: () => void;
userId?: string;
// 마스터-디테일 지원
screenId?: number;
isMasterDetail?: boolean;
masterDetailRelation?: {
masterTable: string;
detailTable: string;
masterKeyColumn: string;
detailFkColumn: string;
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
};
}
interface ColumnMapping {
@@ -57,6 +68,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
keyColumn,
onSuccess,
userId = "guest",
screenId,
isMasterDetail = false,
masterDetailRelation,
}) => {
const [currentStep, setCurrentStep] = useState(1);
@@ -184,50 +198,99 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const loadTableSchema = async () => {
try {
console.log("🔍 테이블 스키마 로드 시작:", { tableName });
console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail });
const response = await getTableSchema(tableName);
let allColumns: TableColumn[] = [];
console.log("📊 테이블 스키마 응답:", response);
// 🆕 마스터-디테일 모드: 두 테이블의 컬럼 합치기
if (isMasterDetail && masterDetailRelation) {
const { masterTable, detailTable, detailFkColumn } = masterDetailRelation;
console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable });
if (response.success && response.data) {
// 자동 생성 컬럼 제외
const filteredColumns = response.data.columns.filter(
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
);
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns);
setSystemColumns(filteredColumns);
// 기존 매핑 템플릿 조회
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
if (mappingResponse.success && mappingResponse.data) {
// 저장된 매핑 템플릿이 있으면 자동 적용
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
const savedMappings = mappingResponse.data.columnMappings;
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: savedMappings[col] || null,
}));
setColumnMappings(appliedMappings);
setIsAutoMappingLoaded(true);
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
} else {
// 매핑 템플릿이 없으면 초기 상태로 설정
console.log(" 매핑 템플릿 없음 - 새 엑셀 구조");
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: null,
}));
setColumnMappings(initialMappings);
setIsAutoMappingLoaded(false);
// 마스터 테이블 스키마
const masterResponse = await getTableSchema(masterTable);
if (masterResponse.success && masterResponse.data) {
const masterCols = masterResponse.data.columns
.filter((col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()))
.map((col) => ({
...col,
// 유니크 키를 위해 테이블명 접두사 추가
name: `${masterTable}.${col.name}`,
label: `[마스터] ${col.label || col.name}`,
originalName: col.name,
sourceTable: masterTable,
}));
allColumns = [...allColumns, ...masterCols];
}
// 디테일 테이블 스키마 (FK 컬럼 제외)
const detailResponse = await getTableSchema(detailTable);
if (detailResponse.success && detailResponse.data) {
const detailCols = detailResponse.data.columns
.filter((col) =>
!AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) &&
col.name !== detailFkColumn // FK 컬럼 제외
)
.map((col) => ({
...col,
// 유니크 키를 위해 테이블명 접두사 추가
name: `${detailTable}.${col.name}`,
label: `[디테일] ${col.label || col.name}`,
originalName: col.name,
sourceTable: detailTable,
}));
allColumns = [...allColumns, ...detailCols];
}
console.log("✅ 마스터-디테일 컬럼 로드 완료:", allColumns.length);
} else {
console.error("❌ 테이블 스키마 로드 실패:", response);
// 기존 단일 테이블 모드
const response = await getTableSchema(tableName);
console.log("📊 테이블 스키마 응답:", response);
if (response.success && response.data) {
// 자동 생성 컬럼 제외
allColumns = response.data.columns.filter(
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
);
} else {
console.error("❌ 테이블 스키마 로드 실패:", response);
return;
}
}
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns);
setSystemColumns(allColumns);
// 기존 매핑 템플릿 조회
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
if (mappingResponse.success && mappingResponse.data) {
// 저장된 매핑 템플릿이 있으면 자동 적용
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
const savedMappings = mappingResponse.data.columnMappings;
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: savedMappings[col] || null,
}));
setColumnMappings(appliedMappings);
setIsAutoMappingLoaded(true);
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
} else {
// 매핑 템플릿이 없으면 초기 상태로 설정
console.log(" 매핑 템플릿 없음 - 새 엑셀 구조");
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: null,
}));
setColumnMappings(initialMappings);
setIsAutoMappingLoaded(false);
}
} catch (error) {
console.error("❌ 테이블 스키마 로드 실패:", error);
@@ -239,18 +302,35 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const handleAutoMapping = () => {
const newMappings = excelColumns.map((excelCol) => {
const normalizedExcelCol = excelCol.toLowerCase().trim();
// [마스터], [디테일] 접두사 제거 후 비교
const cleanExcelCol = normalizedExcelCol.replace(/^\[(마스터|디테일)\]\s*/i, "");
// 1. 먼저 라벨로 매칭 시도
let matchedSystemCol = systemColumns.find(
(sysCol) =>
sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
);
// 1. 먼저 라벨로 매칭 시도 (접두사 제거 후)
let matchedSystemCol = systemColumns.find((sysCol) => {
if (!sysCol.label) return false;
// [마스터], [디테일] 접두사 제거 후 비교
const cleanLabel = sysCol.label.toLowerCase().trim().replace(/^\[(마스터|디테일)\]\s*/i, "");
return cleanLabel === normalizedExcelCol || cleanLabel === cleanExcelCol;
});
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
if (!matchedSystemCol) {
matchedSystemCol = systemColumns.find(
(sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
);
matchedSystemCol = systemColumns.find((sysCol) => {
// 마스터-디테일 모드: originalName이 있으면 사용
const originalName = (sysCol as any).originalName;
const colName = originalName || sysCol.name;
return colName.toLowerCase().trim() === normalizedExcelCol || colName.toLowerCase().trim() === cleanExcelCol;
});
}
// 3. 여전히 매칭 안되면 전체 이름(테이블.컬럼)에서 컬럼 부분만 추출해서 비교
if (!matchedSystemCol) {
matchedSystemCol = systemColumns.find((sysCol) => {
// 테이블.컬럼 형식에서 컬럼만 추출
const nameParts = sysCol.name.split(".");
const colNameOnly = nameParts.length > 1 ? nameParts[1] : nameParts[0];
return colNameOnly.toLowerCase().trim() === normalizedExcelCol || colNameOnly.toLowerCase().trim() === cleanExcelCol;
});
}
return {
@@ -344,7 +424,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const mappedRow: Record<string, any> = {};
columnMappings.forEach((mapping) => {
if (mapping.systemColumn) {
mappedRow[mapping.systemColumn] = row[mapping.excelColumn];
// 마스터-디테일 모드: 테이블.컬럼 형식에서 컬럼명만 추출
let colName = mapping.systemColumn;
if (isMasterDetail && colName.includes(".")) {
colName = colName.split(".")[1];
}
mappedRow[colName] = row[mapping.excelColumn];
}
});
return mappedRow;
@@ -364,60 +449,63 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}`
);
let successCount = 0;
let failCount = 0;
// 🆕 마스터-디테일 모드 처리
if (isMasterDetail && screenId && masterDetailRelation) {
console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
for (const row of filteredData) {
try {
if (uploadMode === "insert") {
const formData = { screenId: 0, tableName, data: row };
const result = await DynamicFormApi.saveFormData(formData);
if (result.success) {
successCount++;
} else {
failCount++;
}
}
} catch (error) {
failCount++;
}
}
if (successCount > 0) {
toast.success(
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
const uploadResult = await DynamicFormApi.uploadMasterDetailData(
screenId,
filteredData
);
// 매핑 템플릿 저장 (UPSERT - 자동 저장)
try {
const mappingsToSave: Record<string, string | null> = {};
columnMappings.forEach((mapping) => {
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
});
console.log("💾 매핑 템플릿 저장 중...", {
tableName,
excelColumns,
mappingsToSave,
});
const saveResult = await saveMappingTemplate(
tableName,
excelColumns,
mappingsToSave
if (uploadResult.success && uploadResult.data) {
const { masterInserted, masterUpdated, detailInserted, errors } = uploadResult.data;
toast.success(
`마스터 ${masterInserted + masterUpdated}건, 디테일 ${detailInserted}건 처리되었습니다.` +
(errors.length > 0 ? ` (오류: ${errors.length}건)` : "")
);
if (saveResult.success) {
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
} else {
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
// 매핑 템플릿 저장
await saveMappingTemplateInternal();
onSuccess?.();
} else {
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
}
} else {
// 기존 단일 테이블 업로드 로직
let successCount = 0;
let failCount = 0;
for (const row of filteredData) {
try {
if (uploadMode === "insert") {
const formData = { screenId: 0, tableName, data: row };
const result = await DynamicFormApi.saveFormData(formData);
if (result.success) {
successCount++;
} else {
failCount++;
}
}
} catch (error) {
failCount++;
}
} catch (error) {
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
}
onSuccess?.();
} else {
toast.error("업로드에 실패했습니다.");
if (successCount > 0) {
toast.success(
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
);
// 매핑 템플릿 저장
await saveMappingTemplateInternal();
onSuccess?.();
} else {
toast.error("업로드에 실패했습니다.");
}
}
} catch (error) {
console.error("❌ 엑셀 업로드 실패:", error);
@@ -427,6 +515,35 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
};
// 매핑 템플릿 저장 헬퍼 함수
const saveMappingTemplateInternal = async () => {
try {
const mappingsToSave: Record<string, string | null> = {};
columnMappings.forEach((mapping) => {
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
});
console.log("💾 매핑 템플릿 저장 중...", {
tableName,
excelColumns,
mappingsToSave,
});
const saveResult = await saveMappingTemplate(
tableName,
excelColumns,
mappingsToSave
);
if (saveResult.success) {
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
} else {
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
}
} catch (error) {
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
}
};
// 모달 닫기 시 초기화
useEffect(() => {
if (!open) {
@@ -461,9 +578,21 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<FileSpreadsheet className="h-5 w-5" />
{isMasterDetail && (
<span className="ml-2 rounded bg-blue-100 px-2 py-0.5 text-xs font-normal text-blue-700">
-
</span>
)}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
{isMasterDetail && masterDetailRelation ? (
<>
({masterDetailRelation.masterTable}) + ({masterDetailRelation.detailTable}) .
.
</>
) : (
"엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요."
)}
</DialogDescription>
</DialogHeader>