- Added category validation functionality to both ExcelUploadModal and MultiTableExcelUploadModal components, allowing for the detection of invalid category values in uploaded Excel data. - Implemented state management for category validation, including tracking mismatches and user interactions for replacements. - Updated the handleNext function to incorporate category validation checks before proceeding to the next step in the upload process. - Enhanced user feedback with toast notifications for category replacements and validation errors. These changes significantly improve the robustness of the Excel upload process by ensuring data integrity and providing users with clear guidance on category-related issues.
1080 lines
39 KiB
TypeScript
1080 lines
39 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useRef, useEffect } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Upload,
|
|
FileSpreadsheet,
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
ArrowRight,
|
|
Zap,
|
|
Download,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { importFromExcel, getExcelSheetNames, exportToExcel } from "@/lib/utils/excelExport";
|
|
import { cn } from "@/lib/utils";
|
|
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
|
import {
|
|
TableChainConfig,
|
|
uploadMultiTableExcel,
|
|
} from "@/lib/api/multiTableExcel";
|
|
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
|
import { getTableColumns } from "@/lib/api/tableManagement";
|
|
|
|
export interface MultiTableExcelUploadModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
config: TableChainConfig;
|
|
onSuccess?: () => void;
|
|
}
|
|
|
|
interface ColumnMapping {
|
|
excelColumn: string;
|
|
targetColumn: string | null;
|
|
}
|
|
|
|
export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProps> = ({
|
|
open,
|
|
onOpenChange,
|
|
config,
|
|
onSuccess,
|
|
}) => {
|
|
// 스텝: 1=모드선택+파일, 2=컬럼매핑, 3=확인
|
|
const [currentStep, setCurrentStep] = useState(1);
|
|
|
|
// 모드 선택
|
|
const [selectedModeId, setSelectedModeId] = useState<string>(
|
|
config.uploadModes[0]?.id || ""
|
|
);
|
|
|
|
// 파일
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
|
const [selectedSheet, setSelectedSheet] = useState("");
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [allData, setAllData] = useState<Record<string, any>[]>([]);
|
|
const [displayData, setDisplayData] = useState<Record<string, any>[]>([]);
|
|
const [excelColumns, setExcelColumns] = useState<string[]>([]);
|
|
|
|
// 매핑
|
|
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
|
|
|
|
// 업로드
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
// 카테고리 검증 관련
|
|
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
|
|
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
|
const [categoryMismatches, setCategoryMismatches] = useState<
|
|
Record<string, Array<{
|
|
invalidValue: string;
|
|
replacement: string | null;
|
|
validOptions: Array<{ code: string; label: string }>;
|
|
rowIndices: number[];
|
|
}>>
|
|
>({});
|
|
|
|
const selectedMode = config.uploadModes.find((m) => m.id === selectedModeId);
|
|
|
|
// 선택된 모드에서 활성화되는 컬럼 목록
|
|
const activeColumns = React.useMemo(() => {
|
|
if (!selectedMode) return [];
|
|
const cols: Array<{ dbColumn: string; excelHeader: string; required: boolean; levelLabel: string }> = [];
|
|
for (const levelIdx of selectedMode.activeLevels) {
|
|
const level = config.levels[levelIdx];
|
|
if (!level) continue;
|
|
for (const col of level.columns) {
|
|
cols.push({
|
|
...col,
|
|
levelLabel: level.label,
|
|
});
|
|
}
|
|
}
|
|
return cols;
|
|
}, [selectedMode, config.levels]);
|
|
|
|
// 템플릿 다운로드
|
|
const handleDownloadTemplate = () => {
|
|
if (!selectedMode) return;
|
|
|
|
const headers: string[] = [];
|
|
const sampleRow: Record<string, string> = {};
|
|
const sampleRow2: Record<string, string> = {};
|
|
|
|
for (const levelIdx of selectedMode.activeLevels) {
|
|
const level = config.levels[levelIdx];
|
|
if (!level) continue;
|
|
for (const col of level.columns) {
|
|
headers.push(col.excelHeader);
|
|
sampleRow[col.excelHeader] = col.required ? "(필수)" : "";
|
|
sampleRow2[col.excelHeader] = "";
|
|
}
|
|
}
|
|
|
|
// 예시 데이터 생성 (config에 맞춰)
|
|
exportToExcel(
|
|
[sampleRow, sampleRow2],
|
|
`${config.name}_${selectedMode.label}_템플릿.xlsx`,
|
|
"Sheet1"
|
|
);
|
|
|
|
toast.success("템플릿 파일이 다운로드되었습니다.");
|
|
};
|
|
|
|
// 파일 처리
|
|
const processFile = async (selectedFile: File) => {
|
|
const ext = selectedFile.name.split(".").pop()?.toLowerCase();
|
|
if (!["xlsx", "xls", "csv"].includes(ext || "")) {
|
|
toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)");
|
|
return;
|
|
}
|
|
|
|
setFile(selectedFile);
|
|
|
|
try {
|
|
const sheets = await getExcelSheetNames(selectedFile);
|
|
setSheetNames(sheets);
|
|
setSelectedSheet(sheets[0] || "");
|
|
|
|
const data = await importFromExcel(selectedFile, sheets[0]);
|
|
setAllData(data);
|
|
setDisplayData(data);
|
|
|
|
if (data.length > 0) {
|
|
setExcelColumns(Object.keys(data[0]));
|
|
}
|
|
|
|
toast.success(`파일 선택 완료: ${selectedFile.name}`);
|
|
} catch (error) {
|
|
console.error("파일 읽기 오류:", error);
|
|
toast.error("파일을 읽는 중 오류가 발생했습니다.");
|
|
setFile(null);
|
|
}
|
|
};
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const selectedFile = e.target.files?.[0];
|
|
if (selectedFile) await processFile(selectedFile);
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragOver(true);
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragOver(false);
|
|
};
|
|
|
|
const handleDrop = async (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragOver(false);
|
|
const droppedFile = e.dataTransfer.files?.[0];
|
|
if (droppedFile) await processFile(droppedFile);
|
|
};
|
|
|
|
const handleSheetChange = async (sheetName: string) => {
|
|
setSelectedSheet(sheetName);
|
|
if (!file) return;
|
|
|
|
try {
|
|
const data = await importFromExcel(file, sheetName);
|
|
setAllData(data);
|
|
setDisplayData(data);
|
|
if (data.length > 0) {
|
|
setExcelColumns(Object.keys(data[0]));
|
|
}
|
|
} catch (error) {
|
|
console.error("시트 읽기 오류:", error);
|
|
toast.error("시트를 읽는 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
// 2단계 진입 시 자동 매핑 시도
|
|
useEffect(() => {
|
|
if (currentStep === 2 && excelColumns.length > 0) {
|
|
performAutoMapping();
|
|
}
|
|
}, [currentStep]);
|
|
|
|
const performAutoMapping = () => {
|
|
const newMappings: ColumnMapping[] = excelColumns.map((excelCol) => {
|
|
const normalizedExcel = excelCol.toLowerCase().trim();
|
|
const matched = activeColumns.find((ac) => {
|
|
return (
|
|
ac.excelHeader.toLowerCase().trim() === normalizedExcel ||
|
|
ac.dbColumn.toLowerCase().trim() === normalizedExcel
|
|
);
|
|
});
|
|
return {
|
|
excelColumn: excelCol,
|
|
targetColumn: matched ? matched.excelHeader : null,
|
|
};
|
|
});
|
|
setColumnMappings(newMappings);
|
|
|
|
const matchedCount = newMappings.filter((m) => m.targetColumn).length;
|
|
if (matchedCount > 0) {
|
|
toast.success(`${matchedCount}개 컬럼이 자동 매핑되었습니다.`);
|
|
}
|
|
};
|
|
|
|
const handleMappingChange = (excelColumn: string, targetColumn: string | null) => {
|
|
setColumnMappings((prev) =>
|
|
prev.map((m) =>
|
|
m.excelColumn === excelColumn ? { ...m, targetColumn } : m
|
|
)
|
|
);
|
|
};
|
|
|
|
// 업로드 실행
|
|
const handleUpload = async () => {
|
|
if (!file || !selectedMode) return;
|
|
|
|
setIsUploading(true);
|
|
|
|
try {
|
|
// 엑셀 데이터를 excelHeader 기준으로 변환
|
|
const mappedRows = allData.map((row) => {
|
|
const mappedRow: Record<string, any> = {};
|
|
columnMappings.forEach((mapping) => {
|
|
if (mapping.targetColumn) {
|
|
mappedRow[mapping.targetColumn] = row[mapping.excelColumn];
|
|
}
|
|
});
|
|
return mappedRow;
|
|
});
|
|
|
|
// 빈 행 필터링
|
|
const filteredRows = mappedRows.filter((row) =>
|
|
Object.values(row).some(
|
|
(v) => v !== undefined && v !== null && (typeof v !== "string" || v.trim() !== "")
|
|
)
|
|
);
|
|
|
|
console.log(`다중 테이블 업로드: ${filteredRows.length}행`);
|
|
|
|
const result = await uploadMultiTableExcel({
|
|
config,
|
|
modeId: selectedModeId,
|
|
rows: filteredRows,
|
|
});
|
|
|
|
if (result.success && result.data) {
|
|
const { results, errors } = result.data;
|
|
const summaryParts = results
|
|
.filter((r) => r.inserted + r.updated > 0)
|
|
.map((r) => {
|
|
const parts: string[] = [];
|
|
if (r.inserted > 0) parts.push(`신규 ${r.inserted}건`);
|
|
if (r.updated > 0) parts.push(`수정 ${r.updated}건`);
|
|
return `${r.tableName}: ${parts.join(", ")}`;
|
|
});
|
|
|
|
const msg = summaryParts.join(" / ");
|
|
const errorMsg = errors.length > 0 ? ` (오류: ${errors.length}건)` : "";
|
|
|
|
toast.success(`업로드 완료: ${msg}${errorMsg}`);
|
|
|
|
if (errors.length > 0) {
|
|
console.warn("업로드 오류 목록:", errors);
|
|
}
|
|
|
|
onSuccess?.();
|
|
onOpenChange(false);
|
|
} else {
|
|
toast.error(result.message || "업로드에 실패했습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error("다중 테이블 업로드 실패:", error);
|
|
toast.error("업로드 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
};
|
|
|
|
// 카테고리 검증: 매핑된 컬럼 중 카테고리 타입인 것의 유효하지 않은 값 감지
|
|
const validateCategoryColumns = async () => {
|
|
try {
|
|
setIsCategoryValidating(true);
|
|
|
|
if (!selectedMode) return null;
|
|
|
|
const mismatches: typeof categoryMismatches = {};
|
|
|
|
// 활성 레벨별로 카테고리 컬럼 검증
|
|
for (const levelIdx of selectedMode.activeLevels) {
|
|
const level = config.levels[levelIdx];
|
|
if (!level) continue;
|
|
|
|
// 해당 테이블의 카테고리 타입 컬럼 조회
|
|
const colResponse = await getTableColumns(level.tableName);
|
|
if (!colResponse.success || !colResponse.data?.columns) continue;
|
|
|
|
const categoryColumns = colResponse.data.columns.filter(
|
|
(col: any) => col.inputType === "category"
|
|
);
|
|
if (categoryColumns.length === 0) continue;
|
|
|
|
// 매핑된 컬럼 중 카테고리 타입인 것 찾기
|
|
for (const catCol of categoryColumns) {
|
|
const catColName = catCol.columnName || catCol.column_name;
|
|
const catDisplayName = catCol.displayName || catCol.display_name || catColName;
|
|
|
|
// level.columns에서 해당 dbColumn 찾기
|
|
const levelCol = level.columns.find((lc) => lc.dbColumn === catColName);
|
|
if (!levelCol) continue;
|
|
|
|
// 매핑에서 해당 excelHeader에 연결된 엑셀 컬럼 찾기
|
|
const mapping = columnMappings.find((m) => m.targetColumn === levelCol.excelHeader);
|
|
if (!mapping) continue;
|
|
|
|
// 유효한 카테고리 값 조회
|
|
const valuesResponse = await getCategoryValues(level.tableName, catColName);
|
|
if (!valuesResponse.success || !valuesResponse.data) continue;
|
|
|
|
const validValues = valuesResponse.data as Array<{
|
|
valueCode: string;
|
|
valueLabel: string;
|
|
}>;
|
|
|
|
const validCodes = new Set(validValues.map((v) => v.valueCode));
|
|
const validLabels = new Set(validValues.map((v) => v.valueLabel));
|
|
const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase()));
|
|
|
|
// 엑셀 데이터에서 유효하지 않은 값 수집
|
|
const invalidMap = new Map<string, number[]>();
|
|
|
|
allData.forEach((row, rowIdx) => {
|
|
const val = row[mapping.excelColumn];
|
|
if (val === undefined || val === null || String(val).trim() === "") return;
|
|
const strVal = String(val).trim();
|
|
|
|
if (validCodes.has(strVal)) return;
|
|
if (validLabels.has(strVal)) return;
|
|
if (validLabelsLower.has(strVal.toLowerCase())) return;
|
|
|
|
if (!invalidMap.has(strVal)) {
|
|
invalidMap.set(strVal, []);
|
|
}
|
|
invalidMap.get(strVal)!.push(rowIdx);
|
|
});
|
|
|
|
if (invalidMap.size > 0) {
|
|
const options = validValues.map((v) => ({
|
|
code: v.valueCode,
|
|
label: v.valueLabel,
|
|
}));
|
|
|
|
const key = `${catColName}|||[${level.label}] ${catDisplayName}`;
|
|
mismatches[key] = Array.from(invalidMap.entries()).map(
|
|
([invalidValue, rowIndices]) => ({
|
|
invalidValue,
|
|
replacement: null,
|
|
validOptions: options,
|
|
rowIndices,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.keys(mismatches).length > 0) {
|
|
return mismatches;
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
console.error("카테고리 검증 실패:", error);
|
|
return null;
|
|
} finally {
|
|
setIsCategoryValidating(false);
|
|
}
|
|
};
|
|
|
|
// 카테고리 대체값 적용
|
|
const applyCategoryReplacements = () => {
|
|
for (const [, items] of Object.entries(categoryMismatches)) {
|
|
for (const item of items) {
|
|
if (item.replacement === null) {
|
|
toast.error("모든 항목의 대체 값을 선택해주세요.");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 시스템 컬럼명 → 엑셀 컬럼명 역매핑 구축
|
|
const dbColToExcelCol = new Map<string, string>();
|
|
if (selectedMode) {
|
|
for (const levelIdx of selectedMode.activeLevels) {
|
|
const level = config.levels[levelIdx];
|
|
if (!level) continue;
|
|
for (const lc of level.columns) {
|
|
const mapping = columnMappings.find((m) => m.targetColumn === lc.excelHeader);
|
|
if (mapping) {
|
|
dbColToExcelCol.set(lc.dbColumn, mapping.excelColumn);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const newData = allData.map((row) => ({ ...row }));
|
|
|
|
for (const [key, items] of Object.entries(categoryMismatches)) {
|
|
const systemCol = key.split("|||")[0];
|
|
const excelCol = dbColToExcelCol.get(systemCol);
|
|
if (!excelCol) continue;
|
|
|
|
for (const item of items) {
|
|
if (!item.replacement) continue;
|
|
const selectedOption = item.validOptions.find((opt) => opt.code === item.replacement);
|
|
const replacementLabel = selectedOption?.label || item.replacement;
|
|
|
|
for (const rowIdx of item.rowIndices) {
|
|
if (newData[rowIdx]) {
|
|
newData[rowIdx][excelCol] = replacementLabel;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
setAllData(newData);
|
|
setDisplayData(newData);
|
|
setShowCategoryValidation(false);
|
|
setCategoryMismatches({});
|
|
toast.success("카테고리 값이 대체되었습니다.");
|
|
setCurrentStep(3);
|
|
return true;
|
|
};
|
|
|
|
// 다음/이전 단계
|
|
const handleNext = async () => {
|
|
if (currentStep === 1) {
|
|
if (!file) {
|
|
toast.error("파일을 선택해주세요.");
|
|
return;
|
|
}
|
|
if (displayData.length === 0) {
|
|
toast.error("데이터가 없습니다.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (currentStep === 2) {
|
|
// 필수 컬럼 매핑 확인
|
|
const mappedTargets = new Set(
|
|
columnMappings.filter((m) => m.targetColumn).map((m) => m.targetColumn)
|
|
);
|
|
const unmappedRequired = activeColumns
|
|
.filter((ac) => ac.required && !mappedTargets.has(ac.excelHeader))
|
|
.map((ac) => `${ac.excelHeader}`);
|
|
|
|
if (unmappedRequired.length > 0) {
|
|
toast.error(`필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`);
|
|
return;
|
|
}
|
|
|
|
// 카테고리 컬럼 검증
|
|
const mismatches = await validateCategoryColumns();
|
|
if (mismatches) {
|
|
setCategoryMismatches(mismatches);
|
|
setShowCategoryValidation(true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
|
};
|
|
|
|
const handlePrevious = () => {
|
|
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
|
};
|
|
|
|
// 모달 닫기 시 초기화
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setCurrentStep(1);
|
|
setSelectedModeId(config.uploadModes[0]?.id || "");
|
|
setFile(null);
|
|
setSheetNames([]);
|
|
setSelectedSheet("");
|
|
setAllData([]);
|
|
setDisplayData([]);
|
|
setExcelColumns([]);
|
|
setColumnMappings([]);
|
|
setShowCategoryValidation(false);
|
|
setCategoryMismatches({});
|
|
setIsCategoryValidating(false);
|
|
}
|
|
}, [open, config.uploadModes]);
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
|
style={{ width: "1000px", height: "700px", minWidth: "700px", minHeight: "500px", maxWidth: "1400px", maxHeight: "900px" }}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
|
<FileSpreadsheet className="h-5 w-5" />
|
|
{config.name} - 엑셀 업로드
|
|
<span className="ml-2 rounded bg-indigo-100 px-2 py-0.5 text-xs font-normal text-indigo-700">
|
|
다중 테이블
|
|
</span>
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
{config.description}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* 스텝 인디케이터 */}
|
|
<div className="flex items-center justify-between">
|
|
{[
|
|
{ num: 1, label: "모드 선택 / 파일" },
|
|
{ num: 2, label: "컬럼 매핑" },
|
|
{ num: 3, label: "확인" },
|
|
].map((step, index) => (
|
|
<React.Fragment key={step.num}>
|
|
<div className="flex flex-col items-center gap-1">
|
|
<div
|
|
className={cn(
|
|
"flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors sm:h-10 sm:w-10",
|
|
currentStep === step.num
|
|
? "bg-primary text-primary-foreground"
|
|
: currentStep > step.num
|
|
? "bg-success text-white"
|
|
: "bg-muted text-muted-foreground"
|
|
)}
|
|
>
|
|
{currentStep > step.num ? (
|
|
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
|
|
) : (
|
|
step.num
|
|
)}
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
"text-[10px] font-medium sm:text-xs",
|
|
currentStep === step.num ? "text-primary" : "text-muted-foreground"
|
|
)}
|
|
>
|
|
{step.label}
|
|
</span>
|
|
</div>
|
|
{index < 2 && (
|
|
<div
|
|
className={cn(
|
|
"h-0.5 flex-1 transition-colors",
|
|
currentStep > step.num ? "bg-success" : "bg-muted"
|
|
)}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
|
|
{/* 스텝별 컨텐츠 */}
|
|
<div className="max-h-[calc(95vh-200px)] space-y-4 overflow-y-auto">
|
|
{/* 1단계: 모드 선택 + 파일 선택 */}
|
|
{currentStep === 1 && (
|
|
<div className="space-y-4">
|
|
{/* 업로드 모드 선택 */}
|
|
<div>
|
|
<Label className="text-xs font-medium sm:text-sm">업로드 모드 *</Label>
|
|
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
|
{config.uploadModes.map((mode) => (
|
|
<button
|
|
key={mode.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedModeId(mode.id);
|
|
setFile(null);
|
|
setAllData([]);
|
|
setDisplayData([]);
|
|
setExcelColumns([]);
|
|
}}
|
|
className={cn(
|
|
"rounded-lg border p-3 text-left transition-all",
|
|
selectedModeId === mode.id
|
|
? "border-primary bg-primary/5 ring-2 ring-primary/20"
|
|
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
|
)}
|
|
>
|
|
<p className="text-xs font-semibold sm:text-sm">{mode.label}</p>
|
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
|
{mode.description}
|
|
</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 템플릿 다운로드 */}
|
|
<div className="flex items-center justify-between rounded-md border border-muted bg-muted/30 p-3">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:text-sm">
|
|
<Download className="h-4 w-4" />
|
|
<span>선택한 모드에 맞는 엑셀 양식을 다운로드하세요</span>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleDownloadTemplate}
|
|
className="h-8 text-xs sm:text-sm"
|
|
>
|
|
<Download className="mr-1 h-3 w-3" />
|
|
템플릿 다운로드
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 파일 선택 */}
|
|
<div>
|
|
<Label htmlFor="multi-file-upload" className="text-xs sm:text-sm">
|
|
파일 선택 *
|
|
</Label>
|
|
<div
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className={cn(
|
|
"mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
|
|
isDragOver
|
|
? "border-primary bg-primary/5"
|
|
: file
|
|
? "border-green-500 bg-green-50"
|
|
: "border-muted-foreground/25 hover:border-primary hover:bg-muted/50"
|
|
)}
|
|
>
|
|
{file ? (
|
|
<div className="flex items-center gap-3">
|
|
<FileSpreadsheet className="h-8 w-8 text-green-600" />
|
|
<div>
|
|
<p className="text-sm font-medium text-green-700">{file.name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
클릭하여 다른 파일 선택
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Upload
|
|
className={cn(
|
|
"mb-2 h-8 w-8",
|
|
isDragOver ? "text-primary" : "text-muted-foreground"
|
|
)}
|
|
/>
|
|
<p
|
|
className={cn(
|
|
"text-sm font-medium",
|
|
isDragOver ? "text-primary" : "text-muted-foreground"
|
|
)}
|
|
>
|
|
{isDragOver ? "파일을 놓으세요" : "파일을 드래그하거나 클릭하여 선택"}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
지원 형식: .xlsx, .xls, .csv
|
|
</p>
|
|
</>
|
|
)}
|
|
<input
|
|
ref={fileInputRef}
|
|
id="multi-file-upload"
|
|
type="file"
|
|
accept=".xlsx,.xls,.csv"
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 미리보기 */}
|
|
{file && displayData.length > 0 && (
|
|
<>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-xs text-muted-foreground sm:text-sm">시트:</Label>
|
|
<Select value={selectedSheet} onValueChange={handleSheetChange}>
|
|
<SelectTrigger className="h-8 w-[140px] text-xs sm:h-9 sm:w-[180px] sm:text-sm">
|
|
<SelectValue placeholder="Sheet1" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sheetNames.map((name) => (
|
|
<SelectItem key={name} value={name} className="text-xs sm:text-sm">
|
|
{name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
{displayData.length}개 행
|
|
</span>
|
|
</div>
|
|
|
|
<EditableSpreadsheet
|
|
columns={excelColumns}
|
|
data={displayData}
|
|
onColumnsChange={setExcelColumns}
|
|
onDataChange={(newData) => {
|
|
setDisplayData(newData);
|
|
setAllData(newData);
|
|
}}
|
|
maxHeight="250px"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 2단계: 컬럼 매핑 */}
|
|
{currentStep === 2 && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold sm:text-base">컬럼 매핑 설정</h3>
|
|
<Button
|
|
type="button"
|
|
variant="default"
|
|
size="sm"
|
|
onClick={performAutoMapping}
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
|
>
|
|
<Zap className="mr-2 h-4 w-4" />
|
|
자동 매핑
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
|
|
<div>엑셀 컬럼</div>
|
|
<div></div>
|
|
<div>시스템 컬럼</div>
|
|
</div>
|
|
|
|
<div className="max-h-[300px] space-y-2 overflow-y-auto">
|
|
{columnMappings.map((mapping, index) => (
|
|
<div
|
|
key={index}
|
|
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2"
|
|
>
|
|
<div className="rounded-md border border-border bg-muted px-3 py-2 text-xs font-medium sm:text-sm">
|
|
{mapping.excelColumn}
|
|
</div>
|
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
|
<Select
|
|
value={mapping.targetColumn || "none"}
|
|
onValueChange={(value) =>
|
|
handleMappingChange(
|
|
mapping.excelColumn,
|
|
value === "none" ? null : value
|
|
)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue placeholder="매핑 안함">
|
|
{mapping.targetColumn || "매핑 안함"}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none" className="text-xs sm:text-sm">
|
|
매핑 안함
|
|
</SelectItem>
|
|
{activeColumns.map((ac) => (
|
|
<SelectItem
|
|
key={`${ac.levelLabel}-${ac.dbColumn}`}
|
|
value={ac.excelHeader}
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
{ac.required && (
|
|
<span className="mr-1 text-destructive">*</span>
|
|
)}
|
|
[{ac.levelLabel}] {ac.excelHeader} ({ac.dbColumn})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 미매핑 필수 컬럼 경고 */}
|
|
{(() => {
|
|
const mappedTargets = new Set(
|
|
columnMappings.filter((m) => m.targetColumn).map((m) => m.targetColumn)
|
|
);
|
|
const missing = activeColumns.filter(
|
|
(ac) => ac.required && !mappedTargets.has(ac.excelHeader)
|
|
);
|
|
if (missing.length === 0) return null;
|
|
return (
|
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
|
<div className="flex items-start gap-2">
|
|
<AlertCircle className="mt-0.5 h-4 w-4 text-destructive" />
|
|
<div className="text-[10px] text-destructive sm:text-xs">
|
|
<p className="font-medium">필수 컬럼이 매핑되지 않았습니다:</p>
|
|
<p className="mt-1">
|
|
{missing.map((m) => `[${m.levelLabel}] ${m.excelHeader}`).join(", ")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* 모드 정보 */}
|
|
{selectedMode && (
|
|
<div className="rounded-md border border-muted bg-muted/30 p-3">
|
|
<div className="flex items-start gap-2">
|
|
<Zap className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
|
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
|
<p className="font-medium">모드: {selectedMode.label}</p>
|
|
<p className="mt-1">
|
|
대상 테이블:{" "}
|
|
{selectedMode.activeLevels
|
|
.map((i) => config.levels[i]?.label)
|
|
.filter(Boolean)
|
|
.join(" → ")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 3단계: 확인 */}
|
|
{currentStep === 3 && (
|
|
<div className="space-y-4">
|
|
<div className="rounded-md border border-border bg-muted/50 p-4">
|
|
<h3 className="text-sm font-medium sm:text-base">업로드 요약</h3>
|
|
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
|
|
<p><span className="font-medium">파일:</span> {file?.name}</p>
|
|
<p><span className="font-medium">시트:</span> {selectedSheet}</p>
|
|
<p><span className="font-medium">데이터 행:</span> {allData.length}개</p>
|
|
<p><span className="font-medium">모드:</span> {selectedMode?.label}</p>
|
|
<p>
|
|
<span className="font-medium">대상 테이블:</span>{" "}
|
|
{selectedMode?.activeLevels
|
|
.map((i) => {
|
|
const level = config.levels[i];
|
|
return level
|
|
? `${level.label}(${level.tableName})`
|
|
: "";
|
|
})
|
|
.filter(Boolean)
|
|
.join(" → ")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-md border border-border bg-muted/50 p-4">
|
|
<h3 className="text-sm font-medium sm:text-base">컬럼 매핑</h3>
|
|
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
|
|
{columnMappings
|
|
.filter((m) => m.targetColumn)
|
|
.map((mapping, idx) => {
|
|
const ac = activeColumns.find(
|
|
(c) => c.excelHeader === mapping.targetColumn
|
|
);
|
|
return (
|
|
<p key={idx}>
|
|
<span className="font-medium">{mapping.excelColumn}</span>{" "}
|
|
→ [{ac?.levelLabel}] {mapping.targetColumn}
|
|
</p>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-md border border-warning bg-warning/10 p-3">
|
|
<div className="flex items-start gap-2">
|
|
<AlertCircle className="mt-0.5 h-4 w-4 text-warning" />
|
|
<div className="text-[10px] text-warning sm:text-xs">
|
|
<p className="font-medium">주의사항</p>
|
|
<p className="mt-1">
|
|
업로드를 진행하면 데이터가 데이터베이스에 저장됩니다.
|
|
같은 키 값의 기존 데이터는 업데이트됩니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={currentStep === 1 ? () => onOpenChange(false) : handlePrevious}
|
|
disabled={isUploading}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{currentStep === 1 ? "취소" : "이전"}
|
|
</Button>
|
|
{currentStep < 3 ? (
|
|
<Button
|
|
onClick={handleNext}
|
|
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{isCategoryValidating ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
검증 중...
|
|
</>
|
|
) : (
|
|
"다음"
|
|
)}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={handleUpload}
|
|
disabled={isUploading || columnMappings.filter((m) => m.targetColumn).length === 0}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{isUploading ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
업로드 중...
|
|
</>
|
|
) : (
|
|
"업로드"
|
|
)}
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 카테고리 대체값 선택 다이얼로그 */}
|
|
<Dialog open={showCategoryValidation} onOpenChange={(open) => {
|
|
if (!open) {
|
|
setShowCategoryValidation(false);
|
|
setCategoryMismatches({});
|
|
}
|
|
}}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
|
<AlertCircle className="h-5 w-5 text-warning" />
|
|
존재하지 않는 카테고리 값 감지
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
엑셀 데이터에 등록되지 않은 카테고리 값이 있습니다. 각 항목에 대해 대체할 값을 선택해주세요.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="max-h-[400px] space-y-4 overflow-y-auto pr-1">
|
|
{Object.entries(categoryMismatches).map(([key, items]) => {
|
|
const [, displayName] = key.split("|||");
|
|
return (
|
|
<div key={key} className="space-y-2">
|
|
<h4 className="text-sm font-semibold text-foreground">
|
|
{displayName}
|
|
</h4>
|
|
{items.map((item, idx) => (
|
|
<div
|
|
key={`${key}-${idx}`}
|
|
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-md border border-border bg-muted/30 p-2"
|
|
>
|
|
<div className="flex flex-col">
|
|
<span className="text-xs font-medium text-destructive line-through">
|
|
{item.invalidValue}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{item.rowIndices.length}건
|
|
</span>
|
|
</div>
|
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
|
<Select
|
|
value={item.replacement || ""}
|
|
onValueChange={(val) => {
|
|
setCategoryMismatches((prev) => {
|
|
const updated = { ...prev };
|
|
updated[key] = updated[key].map((it, i) =>
|
|
i === idx ? { ...it, replacement: val } : it
|
|
);
|
|
return updated;
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
|
|
<SelectValue placeholder="대체 값 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{item.validOptions.map((opt) => (
|
|
<SelectItem
|
|
key={opt.code}
|
|
value={opt.code}
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowCategoryValidation(false);
|
|
setCategoryMismatches({});
|
|
}}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowCategoryValidation(false);
|
|
setCategoryMismatches({});
|
|
setCurrentStep(3);
|
|
}}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
무시하고 진행
|
|
</Button>
|
|
<Button
|
|
onClick={applyCategoryReplacements}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
적용
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
};
|