610 lines
23 KiB
TypeScript
610 lines
23 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useState, useRef, useCallback } from "react";
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
} from "@/components/ui/dialog";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Label } from "@/components/ui/label";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
import {
|
||
|
|
Upload,
|
||
|
|
FileSpreadsheet,
|
||
|
|
AlertCircle,
|
||
|
|
CheckCircle2,
|
||
|
|
Download,
|
||
|
|
Loader2,
|
||
|
|
X,
|
||
|
|
} from "lucide-react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { importFromExcel } from "@/lib/utils/excelExport";
|
||
|
|
import { apiClient } from "@/lib/api/client";
|
||
|
|
|
||
|
|
interface BomExcelUploadModalProps {
|
||
|
|
open: boolean;
|
||
|
|
onOpenChange: (open: boolean) => void;
|
||
|
|
onSuccess?: () => void;
|
||
|
|
/** bomId가 있으면 "새 버전 등록" 모드, 없으면 "새 BOM 생성" 모드 */
|
||
|
|
bomId?: string;
|
||
|
|
bomName?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ParsedRow {
|
||
|
|
rowIndex: number;
|
||
|
|
level: number;
|
||
|
|
item_number: string;
|
||
|
|
item_name: string;
|
||
|
|
quantity: number;
|
||
|
|
unit: string;
|
||
|
|
process_type: string;
|
||
|
|
remark: string;
|
||
|
|
valid: boolean;
|
||
|
|
error?: string;
|
||
|
|
isHeader?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
type UploadStep = "upload" | "preview" | "result";
|
||
|
|
|
||
|
|
const EXPECTED_HEADERS = ["레벨", "품번", "품명", "소요량", "단위", "공정구분", "비고"];
|
||
|
|
|
||
|
|
const HEADER_MAP: Record<string, string> = {
|
||
|
|
"레벨": "level",
|
||
|
|
"level": "level",
|
||
|
|
"품번": "item_number",
|
||
|
|
"품목코드": "item_number",
|
||
|
|
"item_number": "item_number",
|
||
|
|
"item_code": "item_number",
|
||
|
|
"품명": "item_name",
|
||
|
|
"품목명": "item_name",
|
||
|
|
"item_name": "item_name",
|
||
|
|
"소요량": "quantity",
|
||
|
|
"수량": "quantity",
|
||
|
|
"quantity": "quantity",
|
||
|
|
"qty": "quantity",
|
||
|
|
"단위": "unit",
|
||
|
|
"unit": "unit",
|
||
|
|
"공정구분": "process_type",
|
||
|
|
"공정": "process_type",
|
||
|
|
"process_type": "process_type",
|
||
|
|
"비고": "remark",
|
||
|
|
"remark": "remark",
|
||
|
|
};
|
||
|
|
|
||
|
|
export function BomExcelUploadModal({
|
||
|
|
open,
|
||
|
|
onOpenChange,
|
||
|
|
onSuccess,
|
||
|
|
bomId,
|
||
|
|
bomName,
|
||
|
|
}: BomExcelUploadModalProps) {
|
||
|
|
const isVersionMode = !!bomId;
|
||
|
|
|
||
|
|
const [step, setStep] = useState<UploadStep>("upload");
|
||
|
|
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
|
||
|
|
const [fileName, setFileName] = useState<string>("");
|
||
|
|
const [uploading, setUploading] = useState(false);
|
||
|
|
const [uploadResult, setUploadResult] = useState<any>(null);
|
||
|
|
const [downloading, setDownloading] = useState(false);
|
||
|
|
const [versionName, setVersionName] = useState<string>("");
|
||
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
|
|
||
|
|
const reset = useCallback(() => {
|
||
|
|
setStep("upload");
|
||
|
|
setParsedRows([]);
|
||
|
|
setFileName("");
|
||
|
|
setUploadResult(null);
|
||
|
|
setUploading(false);
|
||
|
|
setVersionName("");
|
||
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const handleClose = useCallback(() => {
|
||
|
|
reset();
|
||
|
|
onOpenChange(false);
|
||
|
|
}, [reset, onOpenChange]);
|
||
|
|
|
||
|
|
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
|
|
const file = e.target.files?.[0];
|
||
|
|
if (!file) return;
|
||
|
|
|
||
|
|
setFileName(file.name);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const rawData = await importFromExcel(file);
|
||
|
|
if (!rawData || rawData.length === 0) {
|
||
|
|
toast.error("엑셀 파일에 데이터가 없습니다");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const firstRow = rawData[0];
|
||
|
|
const excelHeaders = Object.keys(firstRow);
|
||
|
|
const fieldMap: Record<string, string> = {};
|
||
|
|
|
||
|
|
for (const header of excelHeaders) {
|
||
|
|
const normalized = header.trim().toLowerCase();
|
||
|
|
const mapped = HEADER_MAP[normalized] || HEADER_MAP[header.trim()];
|
||
|
|
if (mapped) {
|
||
|
|
fieldMap[header] = mapped;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const hasItemNumber = excelHeaders.some(h => {
|
||
|
|
const n = h.trim().toLowerCase();
|
||
|
|
return HEADER_MAP[n] === "item_number" || HEADER_MAP[h.trim()] === "item_number";
|
||
|
|
});
|
||
|
|
if (!hasItemNumber) {
|
||
|
|
toast.error("품번 컬럼을 찾을 수 없습니다. 컬럼명을 확인해주세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const parsed: ParsedRow[] = [];
|
||
|
|
for (let index = 0; index < rawData.length; index++) {
|
||
|
|
const row = rawData[index];
|
||
|
|
const getField = (fieldName: string): any => {
|
||
|
|
for (const [excelKey, mappedField] of Object.entries(fieldMap)) {
|
||
|
|
if (mappedField === fieldName) return row[excelKey];
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
};
|
||
|
|
|
||
|
|
const levelRaw = getField("level");
|
||
|
|
const level = typeof levelRaw === "number" ? levelRaw : parseInt(String(levelRaw || "0"), 10);
|
||
|
|
const itemNumber = String(getField("item_number") || "").trim();
|
||
|
|
const itemName = String(getField("item_name") || "").trim();
|
||
|
|
const quantityRaw = getField("quantity");
|
||
|
|
const quantity = typeof quantityRaw === "number" ? quantityRaw : parseFloat(String(quantityRaw || "1"));
|
||
|
|
const unit = String(getField("unit") || "").trim();
|
||
|
|
const processType = String(getField("process_type") || "").trim();
|
||
|
|
const remark = String(getField("remark") || "").trim();
|
||
|
|
|
||
|
|
let valid = true;
|
||
|
|
let error = "";
|
||
|
|
const isHeader = level === 0;
|
||
|
|
|
||
|
|
if (!itemNumber) {
|
||
|
|
valid = false;
|
||
|
|
error = "품번 필수";
|
||
|
|
} else if (isNaN(level) || level < 0) {
|
||
|
|
valid = false;
|
||
|
|
error = "레벨 오류";
|
||
|
|
} else if (index > 0) {
|
||
|
|
const prevLevel = parsed[index - 1]?.level ?? 0;
|
||
|
|
if (level > prevLevel + 1) {
|
||
|
|
valid = false;
|
||
|
|
error = `레벨 점프 (이전: ${prevLevel})`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
parsed.push({
|
||
|
|
rowIndex: index + 1,
|
||
|
|
isHeader,
|
||
|
|
level,
|
||
|
|
item_number: itemNumber,
|
||
|
|
item_name: itemName,
|
||
|
|
quantity: isNaN(quantity) ? 1 : quantity,
|
||
|
|
unit,
|
||
|
|
process_type: processType,
|
||
|
|
remark,
|
||
|
|
valid,
|
||
|
|
error,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const filtered = parsed.filter(r => r.item_number !== "");
|
||
|
|
|
||
|
|
// 새 BOM 생성 모드: 레벨 0 필수
|
||
|
|
if (!isVersionMode) {
|
||
|
|
const hasHeader = filtered.some(r => r.level === 0);
|
||
|
|
if (!hasHeader) {
|
||
|
|
toast.error("레벨 0(BOM 마스터) 행이 필요합니다. 첫 행에 최상위 품목을 입력해주세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
setParsedRows(filtered);
|
||
|
|
setStep("preview");
|
||
|
|
} catch (err: any) {
|
||
|
|
toast.error(`파일 파싱 실패: ${err.message}`);
|
||
|
|
}
|
||
|
|
}, [isVersionMode]);
|
||
|
|
|
||
|
|
const handleUpload = useCallback(async () => {
|
||
|
|
const invalidRows = parsedRows.filter(r => !r.valid);
|
||
|
|
if (invalidRows.length > 0) {
|
||
|
|
toast.error(`유효하지 않은 행이 ${invalidRows.length}건 있습니다.`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setUploading(true);
|
||
|
|
try {
|
||
|
|
const rowPayload = parsedRows.map(r => ({
|
||
|
|
level: r.level,
|
||
|
|
item_number: r.item_number,
|
||
|
|
item_name: r.item_name,
|
||
|
|
quantity: r.quantity,
|
||
|
|
unit: r.unit,
|
||
|
|
process_type: r.process_type,
|
||
|
|
remark: r.remark,
|
||
|
|
}));
|
||
|
|
|
||
|
|
let res;
|
||
|
|
if (isVersionMode) {
|
||
|
|
res = await apiClient.post(`/bom/${bomId}/excel-upload-version`, {
|
||
|
|
rows: rowPayload,
|
||
|
|
versionName: versionName.trim() || undefined,
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
res = await apiClient.post("/bom/excel-upload", { rows: rowPayload });
|
||
|
|
}
|
||
|
|
|
||
|
|
if (res.data?.success) {
|
||
|
|
setUploadResult(res.data.data);
|
||
|
|
setStep("result");
|
||
|
|
const msg = isVersionMode
|
||
|
|
? `새 버전 생성 완료: 하위품목 ${res.data.data.insertedCount}건`
|
||
|
|
: `BOM 생성 완료: 하위품목 ${res.data.data.insertedCount}건`;
|
||
|
|
toast.success(msg);
|
||
|
|
onSuccess?.();
|
||
|
|
} else {
|
||
|
|
const errData = res.data?.data;
|
||
|
|
if (errData?.unmatchedItems?.length > 0) {
|
||
|
|
toast.error(`매칭 안 되는 품번: ${errData.unmatchedItems.join(", ")}`);
|
||
|
|
setParsedRows(prev => prev.map(r => {
|
||
|
|
if (errData.unmatchedItems.includes(r.item_number)) {
|
||
|
|
return { ...r, valid: false, error: "품번 미등록" };
|
||
|
|
}
|
||
|
|
return r;
|
||
|
|
}));
|
||
|
|
} else {
|
||
|
|
toast.error(res.data?.message || "업로드 실패");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (err: any) {
|
||
|
|
toast.error(`업로드 오류: ${err.response?.data?.message || err.message}`);
|
||
|
|
} finally {
|
||
|
|
setUploading(false);
|
||
|
|
}
|
||
|
|
}, [parsedRows, isVersionMode, bomId, versionName, onSuccess]);
|
||
|
|
|
||
|
|
const handleDownloadTemplate = useCallback(async () => {
|
||
|
|
setDownloading(true);
|
||
|
|
try {
|
||
|
|
const XLSX = await import("xlsx");
|
||
|
|
let data: Record<string, any>[] = [];
|
||
|
|
|
||
|
|
if (isVersionMode && bomId) {
|
||
|
|
// 기존 BOM 데이터를 템플릿으로 다운로드
|
||
|
|
try {
|
||
|
|
const res = await apiClient.get(`/bom/${bomId}/excel-download`);
|
||
|
|
if (res.data?.success && res.data.data?.length > 0) {
|
||
|
|
data = res.data.data.map((row: any) => ({
|
||
|
|
"레벨": row.level,
|
||
|
|
"품번": row.item_number,
|
||
|
|
"품명": row.item_name,
|
||
|
|
"소요량": row.quantity,
|
||
|
|
"단위": row.unit,
|
||
|
|
"공정구분": row.process_type,
|
||
|
|
"비고": row.remark,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
} catch { /* 데이터 없으면 빈 템플릿 */ }
|
||
|
|
}
|
||
|
|
|
||
|
|
if (data.length === 0) {
|
||
|
|
if (isVersionMode) {
|
||
|
|
data = [
|
||
|
|
{ "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" },
|
||
|
|
{ "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" },
|
||
|
|
];
|
||
|
|
} else {
|
||
|
|
data = [
|
||
|
|
{ "레벨": 0, "품번": "(최상위 품번)", "품명": "(최상위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "BOM 마스터" },
|
||
|
|
{ "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" },
|
||
|
|
{ "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" },
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const ws = XLSX.utils.json_to_sheet(data);
|
||
|
|
const wb = XLSX.utils.book_new();
|
||
|
|
XLSX.utils.book_append_sheet(wb, ws, "BOM");
|
||
|
|
ws["!cols"] = [
|
||
|
|
{ wch: 6 }, { wch: 18 }, { wch: 20 }, { wch: 10 },
|
||
|
|
{ wch: 8 }, { wch: 12 }, { wch: 20 },
|
||
|
|
];
|
||
|
|
|
||
|
|
const filename = bomName ? `BOM_${bomName}.xlsx` : "BOM_template.xlsx";
|
||
|
|
XLSX.writeFile(wb, filename);
|
||
|
|
toast.success("템플릿 다운로드 완료");
|
||
|
|
} catch (err: any) {
|
||
|
|
toast.error(`다운로드 실패: ${err.message}`);
|
||
|
|
} finally {
|
||
|
|
setDownloading(false);
|
||
|
|
}
|
||
|
|
}, [isVersionMode, bomId, bomName]);
|
||
|
|
|
||
|
|
const headerRow = parsedRows.find(r => r.isHeader);
|
||
|
|
const detailRows = parsedRows.filter(r => !r.isHeader);
|
||
|
|
const validCount = parsedRows.filter(r => r.valid).length;
|
||
|
|
const invalidCount = parsedRows.filter(r => !r.valid).length;
|
||
|
|
|
||
|
|
const title = isVersionMode ? "BOM 새 버전 엑셀 업로드" : "BOM 엑셀 업로드";
|
||
|
|
const description = isVersionMode
|
||
|
|
? `${bomName || "선택된 BOM"}의 새 버전을 엑셀 파일로 생성합니다. 레벨 0 행은 건너뜁니다.`
|
||
|
|
: "엑셀 파일로 새 BOM을 생성합니다. 레벨 0 = BOM 마스터, 레벨 1 이상 = 하위품목.";
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
|
||
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden flex flex-col">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle className="text-base sm:text-lg">{title}</DialogTitle>
|
||
|
|
<DialogDescription className="text-xs sm:text-sm">{description}</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
{/* Step 1: 파일 업로드 */}
|
||
|
|
{step === "upload" && (
|
||
|
|
<div className="space-y-4">
|
||
|
|
{/* 새 버전 모드: 버전명 입력 */}
|
||
|
|
{isVersionMode && (
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs sm:text-sm">버전명 (미입력 시 자동 채번)</Label>
|
||
|
|
<Input
|
||
|
|
value={versionName}
|
||
|
|
onChange={(e) => setVersionName(e.target.value)}
|
||
|
|
placeholder="예: 2.0"
|
||
|
|
className="h-8 text-xs sm:h-10 sm:text-sm mt-1"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer",
|
||
|
|
"hover:border-primary/50 hover:bg-muted/50 transition-colors",
|
||
|
|
"border-muted-foreground/25",
|
||
|
|
)}
|
||
|
|
onClick={() => fileInputRef.current?.click()}
|
||
|
|
>
|
||
|
|
<FileSpreadsheet className="mx-auto h-10 w-10 text-muted-foreground mb-3" />
|
||
|
|
<p className="text-sm font-medium">엑셀 파일을 선택하세요</p>
|
||
|
|
<p className="text-xs text-muted-foreground mt-1">.xlsx, .xls, .csv 형식 지원</p>
|
||
|
|
<input
|
||
|
|
ref={fileInputRef}
|
||
|
|
type="file"
|
||
|
|
accept=".xlsx,.xls,.csv"
|
||
|
|
className="hidden"
|
||
|
|
onChange={handleFileSelect}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="rounded-md bg-muted/50 p-3">
|
||
|
|
<p className="text-xs font-medium mb-2">엑셀 컬럼 형식</p>
|
||
|
|
<div className="flex flex-wrap gap-1">
|
||
|
|
{EXPECTED_HEADERS.map((h, i) => (
|
||
|
|
<span
|
||
|
|
key={h}
|
||
|
|
className={cn(
|
||
|
|
"text-[10px] px-2 py-0.5 rounded-full",
|
||
|
|
i < 2 ? "bg-primary/10 text-primary font-medium" : "bg-muted text-muted-foreground",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{h}{i < 2 ? " *" : ""}
|
||
|
|
</span>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
<p className="text-[10px] text-muted-foreground mt-1.5">
|
||
|
|
{isVersionMode
|
||
|
|
? "* 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목. 레벨 0 행이 있으면 건너뜁니다."
|
||
|
|
: "* 레벨 0 = BOM 마스터(최상위 품목, 1행), 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목."
|
||
|
|
}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={handleDownloadTemplate}
|
||
|
|
disabled={downloading}
|
||
|
|
className="w-full"
|
||
|
|
>
|
||
|
|
{downloading ? (
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Download className="mr-2 h-4 w-4" />
|
||
|
|
)}
|
||
|
|
{isVersionMode && bomName ? `현재 BOM 데이터로 템플릿 다운로드` : "빈 템플릿 다운로드"}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Step 2: 미리보기 */}
|
||
|
|
{step === "preview" && (
|
||
|
|
<div className="flex flex-col flex-1 min-h-0 space-y-3">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<span className="text-xs text-muted-foreground">{fileName}</span>
|
||
|
|
{!isVersionMode && headerRow && (
|
||
|
|
<span className="text-xs font-medium">마스터: {headerRow.item_number}</span>
|
||
|
|
)}
|
||
|
|
<span className="text-xs">
|
||
|
|
하위품목 <span className="font-medium">{detailRows.length}</span>건
|
||
|
|
</span>
|
||
|
|
{invalidCount > 0 && (
|
||
|
|
<span className="text-xs text-destructive flex items-center gap-1">
|
||
|
|
<AlertCircle className="h-3 w-3" /> {invalidCount}건 오류
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<Button variant="ghost" size="sm" onClick={reset} className="h-7 text-xs">
|
||
|
|
<X className="h-3 w-3 mr-1" />
|
||
|
|
다시 선택
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex-1 min-h-0 overflow-auto border rounded-md">
|
||
|
|
<table className="w-full text-xs">
|
||
|
|
<thead className="bg-muted/50 sticky top-0">
|
||
|
|
<tr>
|
||
|
|
<th className="px-2 py-1.5 text-left font-medium w-8">#</th>
|
||
|
|
<th className="px-2 py-1.5 text-left font-medium w-12">구분</th>
|
||
|
|
<th className="px-2 py-1.5 text-center font-medium w-12">레벨</th>
|
||
|
|
<th className="px-2 py-1.5 text-left font-medium">품번</th>
|
||
|
|
<th className="px-2 py-1.5 text-left font-medium">품명</th>
|
||
|
|
<th className="px-2 py-1.5 text-right font-medium w-16">소요량</th>
|
||
|
|
<th className="px-2 py-1.5 text-left font-medium w-14">단위</th>
|
||
|
|
<th className="px-2 py-1.5 text-left font-medium w-20">공정</th>
|
||
|
|
<th className="px-2 py-1.5 text-left font-medium">비고</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{parsedRows.map((row) => (
|
||
|
|
<tr
|
||
|
|
key={row.rowIndex}
|
||
|
|
className={cn(
|
||
|
|
"border-t hover:bg-muted/30",
|
||
|
|
row.isHeader && "bg-blue-50/50",
|
||
|
|
!row.valid && "bg-destructive/5",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<td className="px-2 py-1 text-muted-foreground">{row.rowIndex}</td>
|
||
|
|
<td className="px-2 py-1">
|
||
|
|
{row.isHeader ? (
|
||
|
|
<span className="text-[10px] text-blue-600 font-medium bg-blue-50 px-1.5 py-0.5 rounded">
|
||
|
|
{isVersionMode ? "건너뜀" : "마스터"}
|
||
|
|
</span>
|
||
|
|
) : row.valid ? (
|
||
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||
|
|
) : (
|
||
|
|
<span className="flex items-center gap-1" title={row.error}>
|
||
|
|
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</td>
|
||
|
|
<td className="px-2 py-1 text-center">
|
||
|
|
<span
|
||
|
|
className={cn(
|
||
|
|
"inline-block rounded px-1.5 py-0.5 text-[10px] font-mono",
|
||
|
|
row.isHeader ? "bg-blue-100 text-blue-700 font-medium" : "bg-muted",
|
||
|
|
)}
|
||
|
|
style={{ marginLeft: `${row.level * 8}px` }}
|
||
|
|
>
|
||
|
|
{row.level}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td className={cn("px-2 py-1 font-mono", row.isHeader && "font-semibold")}>{row.item_number}</td>
|
||
|
|
<td className={cn("px-2 py-1", row.isHeader && "font-semibold")}>{row.item_name}</td>
|
||
|
|
<td className="px-2 py-1 text-right font-mono">{row.quantity}</td>
|
||
|
|
<td className="px-2 py-1">{row.unit}</td>
|
||
|
|
<td className="px-2 py-1">{row.process_type}</td>
|
||
|
|
<td className="px-2 py-1 text-muted-foreground truncate max-w-[100px]">{row.remark}</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{invalidCount > 0 && (
|
||
|
|
<div className="rounded-md bg-destructive/10 p-2.5 text-xs text-destructive">
|
||
|
|
<div className="font-medium mb-1">유효하지 않은 행 ({invalidCount}건)</div>
|
||
|
|
<ul className="space-y-0.5 ml-3 list-disc">
|
||
|
|
{parsedRows.filter(r => !r.valid).slice(0, 5).map(r => (
|
||
|
|
<li key={r.rowIndex}>{r.rowIndex}행: {r.error}</li>
|
||
|
|
))}
|
||
|
|
{invalidCount > 5 && <li>...외 {invalidCount - 5}건</li>}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="text-xs text-muted-foreground">
|
||
|
|
{isVersionMode
|
||
|
|
? "레벨 1 이상의 하위품목으로 새 버전을 생성합니다."
|
||
|
|
: "레벨 0 품목으로 새 BOM 마스터를 생성하고, 레벨 1 이상은 하위품목으로 등록합니다."
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Step 3: 결과 */}
|
||
|
|
{step === "result" && uploadResult && (
|
||
|
|
<div className="space-y-4 py-4">
|
||
|
|
<div className="flex flex-col items-center text-center">
|
||
|
|
<div className="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center mb-3">
|
||
|
|
<CheckCircle2 className="h-7 w-7 text-green-600" />
|
||
|
|
</div>
|
||
|
|
<h3 className="text-lg font-semibold">
|
||
|
|
{isVersionMode ? "새 버전 생성 완료" : "BOM 생성 완료"}
|
||
|
|
</h3>
|
||
|
|
<p className="text-sm text-muted-foreground mt-1">
|
||
|
|
하위품목 {uploadResult.insertedCount}건이 등록되었습니다.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className={cn("grid gap-3 max-w-xs mx-auto", isVersionMode ? "grid-cols-1" : "grid-cols-2")}>
|
||
|
|
{!isVersionMode && (
|
||
|
|
<div className="rounded-lg bg-muted/50 p-3 text-center">
|
||
|
|
<div className="text-2xl font-bold text-blue-600">1</div>
|
||
|
|
<div className="text-xs text-muted-foreground">BOM 마스터</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div className="rounded-lg bg-muted/50 p-3 text-center">
|
||
|
|
<div className="text-2xl font-bold text-green-600">{uploadResult.insertedCount}</div>
|
||
|
|
<div className="text-xs text-muted-foreground">하위품목</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
||
|
|
{step === "upload" && (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
onClick={handleClose}
|
||
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
닫기
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
{step === "preview" && (
|
||
|
|
<>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
onClick={reset}
|
||
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
onClick={handleUpload}
|
||
|
|
disabled={uploading || invalidCount > 0}
|
||
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
{uploading ? (
|
||
|
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> 업로드 중...</>
|
||
|
|
) : (
|
||
|
|
<><Upload className="mr-2 h-4 w-4" />
|
||
|
|
{isVersionMode ? `새 버전 생성 (${detailRows.length}건)` : `BOM 생성 (${detailRows.length}건)`}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
{step === "result" && (
|
||
|
|
<Button
|
||
|
|
onClick={handleClose}
|
||
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
확인
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|