Files
vexplor_dev/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx
kjs c618283306 feat: Refactor work process and item inspection logic
- 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.
2026-04-23 17:36:04 +09:00

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>
);
}