- Implemented new API endpoints for multi-table Excel upload and auto-detection of table chains. - The GET endpoint `/api/data/multi-table/auto-detect` allows automatic detection of foreign key relationships based on the provided root table. - The POST endpoint `/api/data/multi-table/upload` handles the upload of multi-table data, including validation and logging of the upload process. - Updated the frontend to include options for multi-table Excel upload in the button configuration panel and integrated the corresponding action handler. This feature enhances the data management capabilities by allowing users to upload and manage data across multiple related tables efficiently.
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>
|
|
);
|
|
};
|