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

885 lines
32 KiB
TypeScript

"use client";
import React, { useState, useRef, useEffect } 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 { Checkbox } from "@/components/ui/checkbox";
import { toast } from "sonner";
import {
Upload,
FileSpreadsheet,
AlertCircle,
CheckCircle2,
Plus,
Minus,
ArrowRight,
Save,
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";
export interface ExcelUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tableName: string;
uploadMode?: "insert" | "update" | "upsert";
keyColumn?: string;
onSuccess?: () => void;
}
interface ColumnMapping {
excelColumn: string;
systemColumn: string | null;
}
interface UploadConfig {
name: string;
type: string;
mappings: ColumnMapping[];
}
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
open,
onOpenChange,
tableName,
uploadMode = "insert",
keyColumn,
onSuccess,
}) => {
const [currentStep, setCurrentStep] = useState(1);
// 1단계: 파일 선택
const [file, setFile] = useState<File | null>(null);
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [selectedSheet, setSelectedSheet] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null);
// 2단계: 범위 지정
const [autoCreateColumn, setAutoCreateColumn] = useState(false);
const [selectedCompany, setSelectedCompany] = useState<string>("");
const [selectedDataType, setSelectedDataType] = useState<string>("");
const [detectedRange, setDetectedRange] = useState<string>("");
const [previewData, setPreviewData] = useState<Record<string, any>[]>([]);
const [allData, setAllData] = useState<Record<string, any>[]>([]);
const [displayData, setDisplayData] = useState<Record<string, any>[]>([]);
// 3단계: 컬럼 매핑
const [excelColumns, setExcelColumns] = useState<string[]>([]);
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
const [configName, setConfigName] = useState<string>("");
const [configType, setConfigType] = useState<string>("");
// 4단계: 확인
const [isUploading, setIsUploading] = useState(false);
// 파일 선택 핸들러
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] || "");
const data = await importFromExcel(selectedFile, sheets[0]);
setAllData(data);
setDisplayData(data.slice(0, 10));
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 handleSheetChange = async (sheetName: string) => {
setSelectedSheet(sheetName);
if (!file) return;
try {
const data = await importFromExcel(file, sheetName);
setAllData(data);
setDisplayData(data.slice(0, 10));
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("시트를 읽는 중 오류가 발생했습니다.");
}
};
// 행 추가
const handleAddRow = () => {
const newRow: Record<string, any> = {};
excelColumns.forEach((col) => {
newRow[col] = "";
});
setDisplayData([...displayData, newRow]);
toast.success("행이 추가되었습니다.");
};
// 행 삭제
const handleRemoveRow = () => {
if (displayData.length > 1) {
setDisplayData(displayData.slice(0, -1));
toast.success("마지막 행이 삭제되었습니다.");
} else {
toast.error("최소 1개의 행이 필요합니다.");
}
};
// 열 추가
const handleAddColumn = () => {
const newColName = `Column${excelColumns.length + 1}`;
setExcelColumns([...excelColumns, newColName]);
setDisplayData(
displayData.map((row) => ({
...row,
[newColName]: "",
}))
);
toast.success("열이 추가되었습니다.");
};
// 열 삭제
const handleRemoveColumn = () => {
if (excelColumns.length > 1) {
const lastCol = excelColumns[excelColumns.length - 1];
setExcelColumns(excelColumns.slice(0, -1));
setDisplayData(
displayData.map((row) => {
const { [lastCol]: removed, ...rest } = row;
return rest;
})
);
toast.success("마지막 열이 삭제되었습니다.");
} else {
toast.error("최소 1개의 열이 필요합니다.");
}
};
// 테이블 스키마 가져오기
useEffect(() => {
if (currentStep === 3 && tableName) {
loadTableSchema();
}
}, [currentStep, tableName]);
const loadTableSchema = async () => {
try {
console.log("🔍 테이블 스키마 로드 시작:", { tableName });
const response = await getTableSchema(tableName);
console.log("📊 테이블 스키마 응답:", response);
if (response.success && response.data) {
console.log("✅ 시스템 컬럼 로드 완료:", response.data.columns);
setSystemColumns(response.data.columns);
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: null,
}));
setColumnMappings(initialMappings);
} else {
console.error("❌ 테이블 스키마 로드 실패:", response);
}
} catch (error) {
console.error("❌ 테이블 스키마 로드 실패:", error);
toast.error("테이블 스키마를 불러올 수 없습니다.");
}
};
// 자동 매핑
const handleAutoMapping = () => {
const newMappings = excelColumns.map((excelCol) => {
const matchedSystemCol = systemColumns.find(
(sysCol) => sysCol.name.toLowerCase() === excelCol.toLowerCase()
);
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 handleSaveConfig = () => {
if (!configName.trim()) {
toast.error("거래처명을 입력해주세요.");
return;
}
const config: UploadConfig = {
name: configName,
type: configType,
mappings: columnMappings,
};
const savedConfigs = JSON.parse(
localStorage.getItem("excelUploadConfigs") || "[]"
);
savedConfigs.push(config);
localStorage.setItem("excelUploadConfigs", JSON.stringify(savedConfigs));
toast.success("설정이 저장되었습니다.");
};
// 다음 단계
const handleNext = () => {
if (currentStep === 1 && !file) {
toast.error("파일을 선택해주세요.");
return;
}
if (currentStep === 2 && displayData.length === 0) {
toast.error("데이터가 없습니다.");
return;
}
setCurrentStep((prev) => Math.min(prev + 1, 4));
};
// 이전 단계
const handlePrevious = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
// 업로드 핸들러
const handleUpload = async () => {
if (!file || !tableName) {
toast.error("필수 정보가 누락되었습니다.");
return;
}
setIsUploading(true);
try {
const mappedData = displayData.map((row) => {
const mappedRow: Record<string, any> = {};
columnMappings.forEach((mapping) => {
if (mapping.systemColumn) {
mappedRow[mapping.systemColumn] = row[mapping.excelColumn];
}
});
return mappedRow;
});
let successCount = 0;
let failCount = 0;
for (const row of mappedData) {
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}개)` : ""}`
);
onSuccess?.();
} else {
toast.error("업로드에 실패했습니다.");
}
} catch (error) {
console.error("❌ 엑셀 업로드 실패:", error);
toast.error("엑셀 업로드 중 오류가 발생했습니다.");
} finally {
setIsUploading(false);
}
};
// 모달 닫기 시 초기화
useEffect(() => {
if (!open) {
setCurrentStep(1);
setFile(null);
setSheetNames([]);
setSelectedSheet("");
setAutoCreateColumn(false);
setSelectedCompany("");
setSelectedDataType("");
setDetectedRange("");
setPreviewData([]);
setAllData([]);
setDisplayData([]);
setExcelColumns([]);
setSystemColumns([]);
setColumnMappings([]);
setConfigName("");
setConfigType("");
}
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]">
<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>
{/* 스텝 인디케이터 */}
<div className="flex items-center justify-between">
{[
{ num: 1, label: "파일 선택" },
{ num: 2, label: "범위 지정" },
{ num: 3, label: "컬럼 매핑" },
{ num: 4, 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 < 3 && (
<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 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>
)}
{/* 2단계: 범위 지정 */}
{currentStep === 2 && (
<div className="space-y-3">
{/* 상단: 3개 드롭다운 가로 배치 */}
<div className="grid grid-cols-3 gap-3">
<Select value={selectedSheet} onValueChange={handleSheetChange}>
<SelectTrigger className="h-8 text-xs sm:h-10 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>
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="company1" className="text-xs sm:text-sm">
ABC
</SelectItem>
<SelectItem value="company2" className="text-xs sm:text-sm">
XYZ
</SelectItem>
<SelectItem value="company3" className="text-xs sm:text-sm">
</SelectItem>
</SelectContent>
</Select>
<Select value={selectedDataType} onValueChange={setSelectedDataType}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="유형 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="type1" className="text-xs sm:text-sm">
1
</SelectItem>
<SelectItem value="type2" className="text-xs sm:text-sm">
2
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 중간: 체크박스 + 버튼들 한 줄 배치 */}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="auto-create"
checked={autoCreateColumn}
onCheckedChange={(checked) => setAutoCreateColumn(checked as boolean)}
/>
<label
htmlFor="auto-create"
className="text-xs font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm"
>
</label>
</div>
<div className="ml-auto flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddRow}
className="h-7 text-xs sm:h-8 sm:text-sm"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddColumn}
className="h-7 text-xs sm:h-8 sm:text-sm"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRemoveRow}
className="h-7 text-xs sm:h-8 sm:text-sm"
>
<Minus className="mr-1 h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRemoveColumn}
className="h-7 text-xs sm:h-8 sm:text-sm"
>
<Minus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 하단: 감지된 범위 + 테이블 */}
<div className="text-xs text-muted-foreground sm:text-sm">
: <span className="font-medium">{detectedRange}</span>
<span className="ml-2 text-[10px] sm:text-xs">
,
</span>
</div>
{displayData.length > 0 && (
<div className="max-h-[250px] 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>
<th className="whitespace-nowrap border-b border-r border-border bg-primary/10 px-2 py-1 text-center font-medium">
</th>
{excelColumns.map((col, index) => (
<th
key={col}
className="whitespace-nowrap border-b border-r border-border bg-primary/10 px-2 py-1 text-center font-medium"
>
{String.fromCharCode(65 + index)}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="bg-primary/5">
<td className="whitespace-nowrap border-b border-r border-border bg-primary/10 px-2 py-1 text-center font-medium">
1
</td>
{excelColumns.map((col) => (
<td
key={col}
className="whitespace-nowrap border-b border-r border-border px-2 py-1 font-medium text-primary"
>
{col}
</td>
))}
</tr>
{displayData.map((row, rowIndex) => (
<tr key={rowIndex} className="border-b border-border last:border-0">
<td className="whitespace-nowrap border-r border-border bg-muted/50 px-2 py-1 text-center font-medium text-muted-foreground">
{rowIndex + 2}
</td>
{excelColumns.map((col) => (
<td
key={col}
className="max-w-[150px] truncate whitespace-nowrap border-r border-border px-2 py-1"
title={String(row[col])}
>
{String(row[col] || "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* 3단계: 컬럼 매핑 - 3단 레이아웃 */}
{currentStep === 3 && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_3fr_2fr]">
{/* 왼쪽: 컬럼 매핑 설정 제목 + 자동 매핑 버튼 */}
<div className="space-y-4">
<div>
<h3 className="mb-3 text-sm font-semibold sm:text-base"> </h3>
<Button
type="button"
variant="default"
size="sm"
onClick={handleAutoMapping}
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
>
<Zap className="mr-2 h-4 w-4" />
</Button>
</div>
</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-[400px] 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="매핑 안함" />
</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.name} ({col.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
{/* 오른쪽: 현재 설정 저장 */}
<div className="rounded-md border border-border bg-muted/30 p-4">
<div className="mb-4 flex items-center gap-2">
<Save className="h-4 w-4" />
<h3 className="text-sm font-semibold sm:text-base"> </h3>
</div>
<div className="space-y-3">
<div>
<Label htmlFor="config-name" className="text-[10px] sm:text-xs">
*
</Label>
<Input
id="config-name"
value={configName}
onChange={(e) => setConfigName(e.target.value)}
placeholder="거래처 선택"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="config-type" className="text-[10px] sm:text-xs">
</Label>
<Input
id="config-type"
value={configType}
onChange={(e) => setConfigType(e.target.value)}
placeholder="유형을 입력하세요 (예: 원자재)"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<Button
type="button"
variant="default"
size="sm"
onClick={handleSaveConfig}
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
>
<Save className="mr-2 h-3 w-3" />
</Button>
</div>
</div>
</div>
)}
{/* 4단계: 확인 */}
{currentStep === 4 && (
<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> {displayData.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) => (
<p key={index}>
<span className="font-medium">{mapping.excelColumn}</span> {" "}
{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 < 4 ? (
<Button
onClick={handleNext}
disabled={isUploading}
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>
);
};