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

853 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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,
} from "lucide-react";
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
import { DynamicFormApi } from "@/lib/api/dynamicForm";
import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
import { cn } from "@/lib/utils";
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
import { EditableSpreadsheet } from "./EditableSpreadsheet";
export interface ExcelUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tableName: string;
uploadMode?: "insert" | "update" | "upsert";
keyColumn?: string;
onSuccess?: () => void;
userId?: string;
}
interface ColumnMapping {
excelColumn: string;
systemColumn: string | null;
}
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
open,
onOpenChange,
tableName,
uploadMode = "insert",
keyColumn,
onSuccess,
userId = "guest",
}) => {
const [currentStep, setCurrentStep] = useState(1);
// 1단계: 파일 선택 & 미리보기
const [file, setFile] = useState<File | null>(null);
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [selectedSheet, setSelectedSheet] = useState<string>("");
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [detectedRange, setDetectedRange] = useState<string>("");
const [allData, setAllData] = useState<Record<string, any>[]>([]);
const [displayData, setDisplayData] = useState<Record<string, any>[]>([]);
// 2단계: 컬럼 매핑 + 매핑 템플릿 자동 적용
const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false);
const [excelColumns, setExcelColumns] = useState<string[]>([]);
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
// 3단계: 확인
const [isUploading, setIsUploading] = useState(false);
// 파일 선택 핸들러
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
await processFile(selectedFile);
};
// 파일 처리 공통 함수 (파일 선택 및 드래그 앤 드롭에서 공유)
const processFile = async (selectedFile: File) => {
const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase();
if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) {
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) {
const columns = Object.keys(data[0]);
const lastCol = String.fromCharCode(64 + columns.length);
setDetectedRange(`A1:${lastCol}${data.length + 1}`);
setExcelColumns(columns);
}
toast.success(`파일이 선택되었습니다: ${selectedFile.name}`);
} catch (error) {
console.error("파일 읽기 오류:", error);
toast.error("파일을 읽는 중 오류가 발생했습니다.");
setFile(null);
}
};
// 드래그 앤 드롭 핸들러
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
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) {
const columns = Object.keys(data[0]);
const lastCol = String.fromCharCode(64 + columns.length);
setDetectedRange(`A1:${lastCol}${data.length + 1}`);
setExcelColumns(columns);
}
} catch (error) {
console.error("시트 읽기 오류:", error);
toast.error("시트를 읽는 중 오류가 발생했습니다.");
}
};
// 테이블 스키마 가져오기 (2단계 진입 시)
useEffect(() => {
if (currentStep === 2 && tableName) {
loadTableSchema();
}
}, [currentStep, tableName]);
// 테이블 생성 시 자동 생성되는 시스템 컬럼 (매핑에서 제외)
const AUTO_GENERATED_COLUMNS = [
"id",
"created_date",
"updated_date",
"writer",
"company_code",
];
const loadTableSchema = async () => {
try {
console.log("🔍 테이블 스키마 로드 시작:", { tableName });
const response = await getTableSchema(tableName);
console.log("📊 테이블 스키마 응답:", response);
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);
}
} else {
console.error("❌ 테이블 스키마 로드 실패:", response);
}
} catch (error) {
console.error("❌ 테이블 스키마 로드 실패:", error);
toast.error("테이블 스키마를 불러올 수 없습니다.");
}
};
// 자동 매핑 - 컬럼명과 라벨 모두 비교
const handleAutoMapping = () => {
const newMappings = excelColumns.map((excelCol) => {
const normalizedExcelCol = excelCol.toLowerCase().trim();
// 1. 먼저 라벨로 매칭 시도
let matchedSystemCol = systemColumns.find(
(sysCol) =>
sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
);
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
if (!matchedSystemCol) {
matchedSystemCol = systemColumns.find(
(sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
);
}
return {
excelColumn: excelCol,
systemColumn: matchedSystemCol ? matchedSystemCol.name : null,
};
});
setColumnMappings(newMappings);
const matchedCount = newMappings.filter((m) => m.systemColumn).length;
toast.success(`${matchedCount}개 컬럼이 자동 매핑되었습니다.`);
};
// 컬럼 매핑 변경
const handleMappingChange = (excelColumn: string, systemColumn: string | null) => {
setColumnMappings((prev) =>
prev.map((mapping) =>
mapping.excelColumn === excelColumn ? { ...mapping, systemColumn } : mapping
)
);
};
// 다음 단계
const handleNext = () => {
if (currentStep === 1 && !file) {
toast.error("파일을 선택해주세요.");
return;
}
if (currentStep === 1 && displayData.length === 0) {
toast.error("데이터가 없습니다.");
return;
}
// 1단계 → 2단계 전환 시: 빈 헤더 열 제외
if (currentStep === 1) {
// 빈 헤더가 아닌 열만 필터링
const validColumnIndices: number[] = [];
const validColumns: string[] = [];
excelColumns.forEach((col, index) => {
if (col && col.trim() !== "") {
validColumnIndices.push(index);
validColumns.push(col);
}
});
// 빈 헤더 열이 있었다면 데이터에서도 해당 열 제거
if (validColumns.length < excelColumns.length) {
const removedCount = excelColumns.length - validColumns.length;
// 새로운 데이터: 유효한 열만 포함
const cleanedData = displayData.map((row) => {
const newRow: Record<string, any> = {};
validColumns.forEach((colName) => {
newRow[colName] = row[colName];
});
return newRow;
});
setExcelColumns(validColumns);
setDisplayData(cleanedData);
setAllData(cleanedData);
if (removedCount > 0) {
toast.info(`빈 헤더 ${removedCount}개 열이 제외되었습니다.`);
}
}
}
setCurrentStep((prev) => Math.min(prev + 1, 3));
};
// 이전 단계
const handlePrevious = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
// 업로드 핸들러
const handleUpload = async () => {
if (!file || !tableName) {
toast.error("필수 정보가 누락되었습니다.");
return;
}
setIsUploading(true);
try {
// allData를 사용하여 전체 데이터 업로드
const mappedData = allData.map((row) => {
const mappedRow: Record<string, any> = {};
columnMappings.forEach((mapping) => {
if (mapping.systemColumn) {
mappedRow[mapping.systemColumn] = row[mapping.excelColumn];
}
});
return mappedRow;
});
// 빈 행 필터링: 모든 값이 비어있거나 undefined/null인 행 제외
const filteredData = mappedData.filter((row) => {
const values = Object.values(row);
return values.some((value) => {
if (value === undefined || value === null) return false;
if (typeof value === "string" && value.trim() === "") return false;
return true;
});
});
console.log(
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}`
);
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++;
}
}
if (successCount > 0) {
toast.success(
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
);
// 매핑 템플릿 저장 (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 (saveResult.success) {
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
} else {
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
}
} catch (error) {
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
}
onSuccess?.();
} else {
toast.error("업로드에 실패했습니다.");
}
} catch (error) {
console.error("❌ 엑셀 업로드 실패:", error);
toast.error("엑셀 업로드 중 오류가 발생했습니다.");
} finally {
setIsUploading(false);
}
};
// 모달 닫기 시 초기화
useEffect(() => {
if (!open) {
setCurrentStep(1);
setFile(null);
setSheetNames([]);
setSelectedSheet("");
setIsAutoMappingLoaded(false);
setDetectedRange("");
setAllData([]);
setDisplayData([]);
setExcelColumns([]);
setSystemColumns([]);
setColumnMappings([]);
}
}, [open]);
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" />
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
{/* 스텝 인디케이터 (3단계) */}
<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 htmlFor="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="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((sheetName) => (
<SelectItem
key={sheetName}
value={sheetName}
className="text-xs sm:text-sm"
>
{sheetName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="text-xs text-muted-foreground">
{displayData.length} · , Tab/Enter로
</span>
</div>
{/* 엑셀처럼 편집 가능한 스프레드시트 */}
<EditableSpreadsheet
columns={excelColumns}
data={displayData}
onColumnsChange={(newColumns) => {
setExcelColumns(newColumns);
// 범위 재계산
const lastCol =
newColumns.length > 0
? String.fromCharCode(64 + newColumns.length)
: "A";
setDetectedRange(`A1:${lastCol}${displayData.length + 1}`);
}}
onDataChange={(newData) => {
setDisplayData(newData);
setAllData(newData);
// 범위 재계산
const lastCol =
excelColumns.length > 0
? String.fromCharCode(64 + excelColumns.length)
: "A";
setDetectedRange(`A1:${lastCol}${newData.length + 1}`);
}}
maxHeight="320px"
/>
</>
)}
</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={handleAutoMapping}
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-[350px] 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.systemColumn || "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.systemColumn
? (() => {
const col = systemColumns.find(
(c) => c.name === mapping.systemColumn
);
return col?.label || mapping.systemColumn;
})()
: "매핑 안함"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-xs sm:text-sm">
</SelectItem>
{systemColumns.map((col) => (
<SelectItem
key={col.name}
value={col.name}
className="text-xs sm:text-sm"
>
{col.label || col.name} ({col.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
{/* 매핑 자동 저장 안내 */}
{isAutoMappingLoaded ? (
<div className="rounded-md border border-success bg-success/10 p-3">
<div className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-success" />
<div className="text-[10px] text-success sm:text-xs">
<p className="font-medium"> </p>
<p className="mt-1">
.
.
</p>
</div>
</div>
</div>
) : (
<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"> </p>
<p className="mt-1">
.
.
</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> {tableName}
</p>
<p>
<span className="font-medium">:</span>{" "}
{uploadMode === "insert"
? "신규 등록"
: uploadMode === "update"
? "업데이트"
: "Upsert"}
</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.systemColumn)
.map((mapping, index) => {
const col = systemColumns.find(
(c) => c.name === mapping.systemColumn
);
return (
<p key={index}>
<span className="font-medium">{mapping.excelColumn}</span> {" "}
{col?.label || mapping.systemColumn}
</p>
);
})}
{columnMappings.filter((m) => m.systemColumn).length === 0 && (
<p className="text-destructive"> .</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.systemColumn).length === 0
}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isUploading ? "업로드 중..." : "업로드"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};