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

350 lines
12 KiB
TypeScript

"use client";
import React, { useState, useRef } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { Upload, FileSpreadsheet, AlertCircle, CheckCircle2 } from "lucide-react";
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
import { DynamicFormApi } from "@/lib/api/dynamicForm";
export interface ExcelUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tableName: string;
uploadMode?: "insert" | "update" | "upsert";
keyColumn?: string;
onSuccess?: () => void;
}
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
open,
onOpenChange,
tableName,
uploadMode = "insert",
keyColumn,
onSuccess,
}) => {
const [file, setFile] = useState<File | null>(null);
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [selectedSheet, setSelectedSheet] = useState<string>("");
const [isUploading, setIsUploading] = useState(false);
const [previewData, setPreviewData] = useState<Record<string, any>[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
// 파일 선택 핸들러
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
// 파일 확장자 검증
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] || "");
// 미리보기 데이터 로드 (첫 5행)
const data = await importFromExcel(selectedFile, sheets[0]);
setPreviewData(data.slice(0, 5));
toast.success(`파일이 선택되었습니다: ${selectedFile.name}`);
} catch (error) {
console.error("파일 읽기 오류:", error);
toast.error("파일을 읽는 중 오류가 발생했습니다.");
setFile(null);
}
};
// 시트 변경 핸들러
const handleSheetChange = async (sheetName: string) => {
setSelectedSheet(sheetName);
if (!file) return;
try {
const data = await importFromExcel(file, sheetName);
setPreviewData(data.slice(0, 5));
} catch (error) {
console.error("시트 읽기 오류:", error);
toast.error("시트를 읽는 중 오류가 발생했습니다.");
}
};
// 업로드 핸들러
const handleUpload = async () => {
if (!file) {
toast.error("파일을 선택해주세요.");
return;
}
if (!tableName) {
toast.error("테이블명이 지정되지 않았습니다.");
return;
}
setIsUploading(true);
try {
// 엑셀 데이터 읽기
const data = await importFromExcel(file, selectedSheet);
console.log("📤 엑셀 업로드 시작:", {
tableName,
uploadMode,
rowCount: data.length,
});
// 업로드 모드에 따라 처리
let successCount = 0;
let failCount = 0;
for (const row of data) {
try {
if (uploadMode === "insert") {
// 삽입 모드
const formData = { screenId: 0, tableName, data: row };
const result = await DynamicFormApi.saveFormData(formData);
if (result.success) {
successCount++;
} else {
console.error("저장 실패:", result.message, row);
failCount++;
}
} else if (uploadMode === "update" && keyColumn) {
// 업데이트 모드
const keyValue = row[keyColumn];
if (keyValue) {
await DynamicFormApi.updateFormDataPartial(tableName, keyValue, row);
successCount++;
} else {
failCount++;
}
} else if (uploadMode === "upsert" && keyColumn) {
// Upsert 모드 (있으면 업데이트, 없으면 삽입)
const keyValue = row[keyColumn];
if (keyValue) {
try {
const updateResult = await DynamicFormApi.updateFormDataPartial(tableName, keyValue, row);
if (!updateResult.success) {
// 업데이트 실패 시 삽입 시도
const formData = { screenId: 0, tableName, data: row };
const insertResult = await DynamicFormApi.saveFormData(formData);
if (insertResult.success) {
successCount++;
} else {
console.error("Upsert 실패:", insertResult.message, row);
failCount++;
}
} else {
successCount++;
}
} catch {
const formData = { screenId: 0, tableName, data: row };
const insertResult = await DynamicFormApi.saveFormData(formData);
if (insertResult.success) {
successCount++;
} else {
console.error("Upsert 실패:", insertResult.message, row);
failCount++;
}
}
} else {
const formData = { screenId: 0, tableName, data: row };
const result = await DynamicFormApi.saveFormData(formData);
if (result.success) {
successCount++;
} else {
console.error("저장 실패:", result.message, row);
failCount++;
}
}
}
} catch (error) {
console.error("행 처리 오류:", row, error);
failCount++;
}
}
console.log("✅ 엑셀 업로드 완료:", {
successCount,
failCount,
totalCount: data.length,
});
if (successCount > 0) {
toast.success(`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`);
// onSuccess 내부에서 closeModal이 호출되므로 여기서는 호출하지 않음
onSuccess?.();
// onOpenChange(false); // 제거: onSuccess에서 이미 모달을 닫음
} else {
toast.error("업로드에 실패했습니다.");
}
} catch (error) {
console.error("❌ 엑셀 업로드 실패:", error);
toast.error("엑셀 업로드 중 오류가 발생했습니다.");
} finally {
setIsUploading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-[900px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 파일 선택 */}
<div>
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
*
</Label>
<div className="mt-2 flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
>
<Upload className="mr-2 h-4 w-4" />
{file ? file.name : "파일 선택"}
</Button>
<input
ref={fileInputRef}
id="file-upload"
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileChange}
className="hidden"
/>
</div>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
형식: .xlsx, .xls, .csv
</p>
</div>
{/* 시트 선택 */}
{sheetNames.length > 0 && (
<div>
<Label htmlFor="sheet-select" className="text-xs sm:text-sm">
</Label>
<Select value={selectedSheet} onValueChange={handleSheetChange}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="시트를 선택하세요" />
</SelectTrigger>
<SelectContent>
{sheetNames.map((sheetName) => (
<SelectItem key={sheetName} value={sheetName} className="text-xs sm:text-sm">
<FileSpreadsheet className="mr-2 inline h-4 w-4" />
{sheetName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 업로드 모드 정보 */}
<div className="rounded-md border border-border bg-muted/50 p-3">
<div className="flex items-start gap-2">
<AlertCircle 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"> : {uploadMode === "insert" ? "삽입" : uploadMode === "update" ? "업데이트" : "Upsert"}</p>
<p className="mt-1">
{uploadMode === "insert" && "새로운 데이터로 삽입됩니다."}
{uploadMode === "update" && `기존 데이터를 업데이트합니다. (키: ${keyColumn})`}
{uploadMode === "upsert" && `있으면 업데이트, 없으면 삽입합니다. (키: ${keyColumn})`}
</p>
</div>
</div>
</div>
{/* 미리보기 */}
{previewData.length > 0 && (
<div>
<Label className="text-xs sm:text-sm"> ( 5)</Label>
<div className="mt-2 max-h-[300px] overflow-auto rounded-md border border-border">
<table className="min-w-full text-[10px] sm:text-xs">
<thead className="sticky top-0 bg-muted">
<tr>
{Object.keys(previewData[0]).map((key) => (
<th key={key} className="whitespace-nowrap border-b border-border px-2 py-1 text-left font-medium">
{key}
</th>
))}
</tr>
</thead>
<tbody>
{previewData.map((row, index) => (
<tr key={index} className="border-b border-border last:border-0">
{Object.values(row).map((value, i) => (
<td key={i} className="max-w-[150px] truncate whitespace-nowrap px-2 py-1" title={String(value)}>
{String(value)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground sm:text-xs">
<CheckCircle2 className="h-3 w-3 text-success" />
<span> {previewData.length} ()</span>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isUploading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleUpload}
disabled={!file || isUploading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isUploading ? "업로드 중..." : "업로드"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};