2025-11-04 09:41:58 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
import React, { useState, useRef, useEffect } from "react";
|
2025-11-04 09:41:58 +09:00
|
|
|
import {
|
2025-11-05 16:36:32 +09:00
|
|
|
ResizableDialog,
|
|
|
|
|
ResizableDialogContent,
|
|
|
|
|
ResizableDialogHeader,
|
|
|
|
|
ResizableDialogTitle,
|
|
|
|
|
ResizableDialogDescription,
|
|
|
|
|
ResizableDialogFooter,
|
|
|
|
|
} from "@/components/ui/resizable-dialog";
|
2025-11-04 09:41:58 +09:00
|
|
|
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";
|
2025-11-04 18:31:26 +09:00
|
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
2025-11-04 09:41:58 +09:00
|
|
|
import { toast } from "sonner";
|
2025-11-04 18:31:26 +09:00
|
|
|
import {
|
|
|
|
|
Upload,
|
|
|
|
|
FileSpreadsheet,
|
|
|
|
|
AlertCircle,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
Plus,
|
|
|
|
|
Minus,
|
|
|
|
|
ArrowRight,
|
|
|
|
|
Save,
|
|
|
|
|
Zap,
|
|
|
|
|
} from "lucide-react";
|
2025-11-04 09:41:58 +09:00
|
|
|
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
|
|
|
|
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
2025-11-04 18:31:26 +09:00
|
|
|
import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2025-11-04 09:41:58 +09:00
|
|
|
|
|
|
|
|
export interface ExcelUploadModalProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
tableName: string;
|
|
|
|
|
uploadMode?: "insert" | "update" | "upsert";
|
|
|
|
|
keyColumn?: string;
|
|
|
|
|
onSuccess?: () => void;
|
2025-11-05 16:36:32 +09:00
|
|
|
userId?: string;
|
2025-11-04 09:41:58 +09:00
|
|
|
}
|
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
interface ColumnMapping {
|
|
|
|
|
excelColumn: string;
|
|
|
|
|
systemColumn: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface UploadConfig {
|
|
|
|
|
name: string;
|
|
|
|
|
type: string;
|
|
|
|
|
mappings: ColumnMapping[];
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 09:41:58 +09:00
|
|
|
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
tableName,
|
|
|
|
|
uploadMode = "insert",
|
|
|
|
|
keyColumn,
|
|
|
|
|
onSuccess,
|
2025-11-05 16:36:32 +09:00
|
|
|
userId = "guest",
|
2025-11-04 09:41:58 +09:00
|
|
|
}) => {
|
2025-11-04 18:31:26 +09:00
|
|
|
const [currentStep, setCurrentStep] = useState(1);
|
|
|
|
|
|
|
|
|
|
// 1단계: 파일 선택
|
2025-11-04 09:41:58 +09:00
|
|
|
const [file, setFile] = useState<File | null>(null);
|
|
|
|
|
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
|
|
|
|
const [selectedSheet, setSelectedSheet] = useState<string>("");
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
// 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);
|
|
|
|
|
|
2025-11-04 09:41:58 +09:00
|
|
|
// 파일 선택 핸들러
|
|
|
|
|
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]);
|
2025-11-04 18:31:26 +09:00
|
|
|
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);
|
|
|
|
|
}
|
2025-11-04 09:41:58 +09:00
|
|
|
|
|
|
|
|
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);
|
2025-11-04 18:31:26 +09:00
|
|
|
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);
|
|
|
|
|
}
|
2025-11-04 09:41:58 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error("시트 읽기 오류:", error);
|
|
|
|
|
toast.error("시트를 읽는 중 오류가 발생했습니다.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
// 행 추가
|
|
|
|
|
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) {
|
2025-11-04 09:41:58 +09:00
|
|
|
toast.error("파일을 선택해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
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("필수 정보가 누락되었습니다.");
|
2025-11-04 09:41:58 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsUploading(true);
|
|
|
|
|
|
|
|
|
|
try {
|
2025-11-04 18:31:26 +09:00
|
|
|
const mappedData = displayData.map((row) => {
|
|
|
|
|
const mappedRow: Record<string, any> = {};
|
|
|
|
|
columnMappings.forEach((mapping) => {
|
|
|
|
|
if (mapping.systemColumn) {
|
|
|
|
|
mappedRow[mapping.systemColumn] = row[mapping.excelColumn];
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return mappedRow;
|
2025-11-04 09:41:58 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let successCount = 0;
|
|
|
|
|
let failCount = 0;
|
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
for (const row of mappedData) {
|
2025-11-04 09:41:58 +09:00
|
|
|
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) {
|
2025-11-04 18:31:26 +09:00
|
|
|
toast.success(
|
|
|
|
|
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
|
|
|
|
);
|
2025-11-04 09:41:58 +09:00
|
|
|
onSuccess?.();
|
|
|
|
|
} else {
|
|
|
|
|
toast.error("업로드에 실패했습니다.");
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("❌ 엑셀 업로드 실패:", error);
|
|
|
|
|
toast.error("엑셀 업로드 중 오류가 발생했습니다.");
|
|
|
|
|
} finally {
|
|
|
|
|
setIsUploading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
// 모달 닫기 시 초기화
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) {
|
|
|
|
|
setCurrentStep(1);
|
|
|
|
|
setFile(null);
|
|
|
|
|
setSheetNames([]);
|
|
|
|
|
setSelectedSheet("");
|
|
|
|
|
setAutoCreateColumn(false);
|
|
|
|
|
setSelectedCompany("");
|
|
|
|
|
setSelectedDataType("");
|
|
|
|
|
setDetectedRange("");
|
|
|
|
|
setPreviewData([]);
|
|
|
|
|
setAllData([]);
|
|
|
|
|
setDisplayData([]);
|
|
|
|
|
setExcelColumns([]);
|
|
|
|
|
setSystemColumns([]);
|
|
|
|
|
setColumnMappings([]);
|
|
|
|
|
setConfigName("");
|
|
|
|
|
setConfigType("");
|
|
|
|
|
}
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
2025-11-04 09:41:58 +09:00
|
|
|
return (
|
2025-11-05 16:36:32 +09:00
|
|
|
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<ResizableDialogContent
|
|
|
|
|
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
|
|
|
|
defaultWidth={1000}
|
|
|
|
|
defaultHeight={700}
|
|
|
|
|
minWidth={700}
|
|
|
|
|
minHeight={500}
|
|
|
|
|
maxWidth={1400}
|
|
|
|
|
maxHeight={900}
|
|
|
|
|
modalId={`excel-upload-${tableName}`}
|
|
|
|
|
userId={userId}
|
|
|
|
|
>
|
|
|
|
|
<ResizableDialogHeader>
|
|
|
|
|
<ResizableDialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
2025-11-04 18:31:26 +09:00
|
|
|
<FileSpreadsheet className="h-5 w-5" />
|
|
|
|
|
엑셀 데이터 업로드
|
2025-11-05 16:36:32 +09:00
|
|
|
</ResizableDialogTitle>
|
|
|
|
|
<ResizableDialogDescription className="text-xs sm:text-sm">
|
|
|
|
|
엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. 모달 테두리를 드래그하여 크기를 조절할 수 있습니다.
|
|
|
|
|
</ResizableDialogDescription>
|
|
|
|
|
</ResizableDialogHeader>
|
2025-11-04 09:41:58 +09:00
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
{/* 스텝 인디케이터 */}
|
|
|
|
|
<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>
|
2025-11-04 09:41:58 +09:00
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
{/* 스텝별 컨텐츠 */}
|
|
|
|
|
<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
|
2025-11-04 09:41:58 +09:00
|
|
|
</p>
|
|
|
|
|
</div>
|
2025-11-04 18:31:26 +09:00
|
|
|
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
2025-11-04 09:41:58 +09:00
|
|
|
</div>
|
2025-11-04 18:31:26 +09:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 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">
|
|
|
|
|
|
2025-11-04 09:41:58 +09:00
|
|
|
</th>
|
2025-11-04 18:31:26 +09:00
|
|
|
{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}
|
2025-11-04 09:41:58 +09:00
|
|
|
</td>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
2025-11-04 18:31:26 +09:00
|
|
|
{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>
|
2025-11-04 09:41:58 +09:00
|
|
|
))}
|
2025-11-04 18:31:26 +09:00
|
|
|
{columnMappings.filter((m) => m.systemColumn).length === 0 && (
|
|
|
|
|
<p className="text-destructive">매핑된 컬럼이 없습니다.</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-11-04 09:41:58 +09:00
|
|
|
</div>
|
2025-11-04 18:31:26 +09:00
|
|
|
|
|
|
|
|
<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>
|
2025-11-04 09:41:58 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-05 16:36:32 +09:00
|
|
|
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
2025-11-04 09:41:58 +09:00
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
2025-11-04 18:31:26 +09:00
|
|
|
onClick={currentStep === 1 ? () => onOpenChange(false) : handlePrevious}
|
2025-11-04 09:41:58 +09:00
|
|
|
disabled={isUploading}
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
2025-11-04 18:31:26 +09:00
|
|
|
{currentStep === 1 ? "취소" : "이전"}
|
2025-11-04 09:41:58 +09:00
|
|
|
</Button>
|
2025-11-04 18:31:26 +09:00
|
|
|
{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>
|
|
|
|
|
)}
|
2025-11-05 16:36:32 +09:00
|
|
|
</ResizableDialogFooter>
|
|
|
|
|
</ResizableDialogContent>
|
|
|
|
|
</ResizableDialog>
|
2025-11-04 09:41:58 +09:00
|
|
|
);
|
|
|
|
|
};
|