350 lines
12 KiB
TypeScript
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>
|
|
);
|
|
};
|
|
|