엑셀 업로드 분할테이블 지원
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user