엑셀 업로드 템플릿 기능 구현
This commit is contained in:
@@ -19,7 +19,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Upload,
|
||||
@@ -35,6 +34,7 @@ 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";
|
||||
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
|
||||
|
||||
export interface ExcelUploadModalProps {
|
||||
open: boolean;
|
||||
@@ -66,12 +66,14 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
||||
const [selectedSheet, setSelectedSheet] = useState<string>("");
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 2단계: 범위 지정
|
||||
const [autoCreateColumn, setAutoCreateColumn] = useState(false);
|
||||
const [selectedCompany, setSelectedCompany] = useState<string>("");
|
||||
const [selectedDataType, setSelectedDataType] = useState<string>("");
|
||||
// (더 이상 사용하지 않는 상태들 - 3단계로 이동)
|
||||
|
||||
// 3단계: 컬럼 매핑 + 매핑 템플릿 자동 적용
|
||||
const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false);
|
||||
const [detectedRange, setDetectedRange] = useState<string>("");
|
||||
const [previewData, setPreviewData] = useState<Record<string, any>[]>([]);
|
||||
const [allData, setAllData] = useState<Record<string, any>[]>([]);
|
||||
@@ -89,7 +91,11 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (!selectedFile) return;
|
||||
await processFile(selectedFile);
|
||||
};
|
||||
|
||||
// 파일 처리 공통 함수 (파일 선택 및 드래그 앤 드롭에서 공유)
|
||||
const processFile = async (selectedFile: File) => {
|
||||
const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase();
|
||||
if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) {
|
||||
toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)");
|
||||
@@ -105,7 +111,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
|
||||
const data = await importFromExcel(selectedFile, sheets[0]);
|
||||
setAllData(data);
|
||||
setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
|
||||
setDisplayData(data);
|
||||
|
||||
if (data.length > 0) {
|
||||
const columns = Object.keys(data[0]);
|
||||
@@ -122,6 +128,30 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 핸들러
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile) {
|
||||
await processFile(droppedFile);
|
||||
}
|
||||
};
|
||||
|
||||
// 시트 변경 핸들러
|
||||
const handleSheetChange = async (sheetName: string) => {
|
||||
setSelectedSheet(sheetName);
|
||||
@@ -201,6 +231,15 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
}
|
||||
}, [currentStep, tableName]);
|
||||
|
||||
// 테이블 생성 시 자동 생성되는 시스템 컬럼 (매핑에서 제외)
|
||||
const AUTO_GENERATED_COLUMNS = [
|
||||
"id", // ID
|
||||
"created_date", // 생성일시
|
||||
"updated_date", // 수정일시
|
||||
"writer", // 작성자
|
||||
"company_code", // 회사코드
|
||||
];
|
||||
|
||||
const loadTableSchema = async () => {
|
||||
try {
|
||||
console.log("🔍 테이블 스키마 로드 시작:", { tableName });
|
||||
@@ -210,14 +249,41 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
console.log("📊 테이블 스키마 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
console.log("✅ 시스템 컬럼 로드 완료:", response.data.columns);
|
||||
setSystemColumns(response.data.columns);
|
||||
// 자동 생성 컬럼 제외
|
||||
const filteredColumns = response.data.columns.filter(
|
||||
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
|
||||
);
|
||||
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns);
|
||||
setSystemColumns(filteredColumns);
|
||||
|
||||
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
||||
excelColumn: col,
|
||||
systemColumn: null,
|
||||
}));
|
||||
setColumnMappings(initialMappings);
|
||||
// 기존 매핑 템플릿 조회
|
||||
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
|
||||
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
|
||||
|
||||
if (mappingResponse.success && mappingResponse.data) {
|
||||
// 저장된 매핑 템플릿이 있으면 자동 적용
|
||||
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
|
||||
const savedMappings = mappingResponse.data.columnMappings;
|
||||
|
||||
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
||||
excelColumn: col,
|
||||
systemColumn: savedMappings[col] || null,
|
||||
}));
|
||||
setColumnMappings(appliedMappings);
|
||||
setIsAutoMappingLoaded(true);
|
||||
|
||||
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
|
||||
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
|
||||
} else {
|
||||
// 매핑 템플릿이 없으면 초기 상태로 설정
|
||||
console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조");
|
||||
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
||||
excelColumn: col,
|
||||
systemColumn: null,
|
||||
}));
|
||||
setColumnMappings(initialMappings);
|
||||
setIsAutoMappingLoaded(false);
|
||||
}
|
||||
} else {
|
||||
console.error("❌ 테이블 스키마 로드 실패:", response);
|
||||
}
|
||||
@@ -343,6 +409,27 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
toast.success(
|
||||
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
||||
);
|
||||
|
||||
// 매핑 템플릿 저장 (UPSERT - 자동 저장)
|
||||
try {
|
||||
const mappingsToSave: Record<string, string | null> = {};
|
||||
columnMappings.forEach((mapping) => {
|
||||
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
|
||||
});
|
||||
|
||||
console.log("💾 매핑 템플릿 저장 중...", { tableName, excelColumns, mappingsToSave });
|
||||
const saveResult = await saveMappingTemplate(tableName, excelColumns, mappingsToSave);
|
||||
|
||||
if (saveResult.success) {
|
||||
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
|
||||
} else {
|
||||
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
|
||||
// 매핑 템플릿 저장 실패해도 업로드는 성공이므로 에러 표시 안함
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
} else {
|
||||
toast.error("업로드에 실패했습니다.");
|
||||
@@ -362,9 +449,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
setFile(null);
|
||||
setSheetNames([]);
|
||||
setSelectedSheet("");
|
||||
setAutoCreateColumn(false);
|
||||
setSelectedCompany("");
|
||||
setSelectedDataType("");
|
||||
setIsAutoMappingLoaded(false);
|
||||
setDetectedRange("");
|
||||
setPreviewData([]);
|
||||
setAllData([]);
|
||||
@@ -456,16 +541,46 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
<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>
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
<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-6 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 ? (
|
||||
<>
|
||||
<FileSpreadsheet className="mb-2 h-10 w-10 text-green-600" />
|
||||
<p className="text-sm font-medium text-green-700">{file.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
클릭하여 다른 파일 선택
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className={cn(
|
||||
"mb-2 h-10 w-10",
|
||||
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="file-upload"
|
||||
@@ -475,9 +590,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
지원 형식: .xlsx, .xls, .csv
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sheetNames.length > 0 && (
|
||||
@@ -510,67 +622,22 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
{/* 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 className="flex flex-wrap 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-10 sm:w-[180px] 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>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
@@ -751,6 +818,35 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매핑 자동 저장 안내 */}
|
||||
{isAutoMappingLoaded ? (
|
||||
<div className="mt-4 rounded-md border border-success bg-success/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 text-success" />
|
||||
<div className="text-[10px] text-success sm:text-xs">
|
||||
<p className="font-medium">이전 매핑이 자동 적용됨</p>
|
||||
<p className="mt-1">
|
||||
동일한 엑셀 구조가 감지되어 이전에 저장된 매핑이 적용되었습니다.
|
||||
필요시 수정하면 업로드 시 자동으로 저장됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 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">새로운 엑셀 구조</p>
|
||||
<p className="mt-1">
|
||||
이 엑셀 구조는 처음입니다. 컬럼 매핑을 설정하면 업로드 시 자동으로 저장되어
|
||||
다음에 같은 구조의 엑셀을 업로드할 때 자동 적용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user