787 lines
28 KiB
TypeScript
787 lines
28 KiB
TypeScript
"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>
|
|
);
|
|
};
|