- Updated SQL queries in `popProductionController` to separate work order process and result handling, ensuring batch_id is now managed in the work_order_process_result table. - Enhanced `workInstructionController` to include id generation for work items and details, preventing NULL values during insertion. - Implemented case-insensitive search functionality across various services, improving data retrieval accuracy. - Added sorting functionality in the item inspection page, allowing users to sort by different columns with visual indicators for sort direction. This refactor aims to improve data integrity and user experience across the production and inspection workflows.
649 lines
25 KiB
TypeScript
649 lines
25 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 {
|
|
// BOM 컬럼 정의: required 플래그로 필수 여부 표시 (헤더 빨강 폰트)
|
|
const BOM_COLUMNS: { header: string; width: number; required: boolean }[] = [
|
|
{ header: "레벨", width: 6, required: true },
|
|
{ header: "품번", width: 18, required: true },
|
|
{ header: "품명", width: 20, required: true },
|
|
{ header: "소요량", width: 10, required: true },
|
|
{ header: "단위", width: 8, required: true },
|
|
{ header: "공정구분", width: 12, required: false },
|
|
{ header: "비고", width: 20, required: false },
|
|
];
|
|
|
|
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 ExcelJS = (await import("exceljs")).default;
|
|
const workbook = new ExcelJS.Workbook();
|
|
const ws = workbook.addWorksheet("BOM");
|
|
|
|
BOM_COLUMNS.forEach((col, i) => {
|
|
ws.getColumn(i + 1).width = col.width;
|
|
});
|
|
|
|
// 헤더: 필수 컬럼은 빨강 폰트
|
|
const headerRow = ws.addRow(BOM_COLUMNS.map((c) => c.header));
|
|
headerRow.height = 24;
|
|
headerRow.eachCell((cell, colNumber) => {
|
|
const col = BOM_COLUMNS[colNumber - 1];
|
|
cell.font = {
|
|
bold: true,
|
|
size: 10,
|
|
color: { argb: col.required ? "FFDC2626" : "FF1E293B" },
|
|
};
|
|
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF1F5F9" } };
|
|
cell.border = { bottom: { style: "medium", color: { argb: "FF94A3B8" } } };
|
|
cell.alignment = { vertical: "middle", horizontal: "center" };
|
|
});
|
|
|
|
// 데이터 행 추가
|
|
for (const row of data) {
|
|
ws.addRow(BOM_COLUMNS.map((c) => row[c.header] ?? ""));
|
|
}
|
|
|
|
const buffer = await workbook.xlsx.writeBuffer();
|
|
const blob = new Blob([buffer], {
|
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = bomName ? `BOM_${bomName}.xlsx` : "BOM_template.xlsx";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
|
|
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-primary/10/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-primary font-medium bg-primary/10 px-1.5 py-0.5 rounded">
|
|
{isVersionMode ? "건너뜀" : "마스터"}
|
|
</span>
|
|
) : row.valid ? (
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-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-primary/10 text-primary 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-emerald-100 flex items-center justify-center mb-3">
|
|
<CheckCircle2 className="h-7 w-7 text-emerald-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-primary">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-emerald-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>
|
|
);
|
|
}
|