ERP-node/frontend/components/common/MultiTableExcelUploadModal.tsx

787 lines
28 KiB
TypeScript
Raw Normal View History

"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";
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 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 handleNext = () => {
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;
}
}
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([]);
}
}, [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 || (currentStep === 1 && !file)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</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>
);
};