개발관리>PART 도면 다중 업로드 — M1·M2 [도면 다중 업로드] 버튼 + 파일명↔품번 자동 매칭
wace partMngTempList.jsp btnDrawingUpload + PartMngController.uploadDrawingFilesForPartList +
partMng.xml partMngListByPartNos 1:1 이식.
- 백엔드 (devPartService.drawingMultiUpload):
· 확장자 → doc_type 매핑 (STP/STEP=3D_CAD, DWG/DXF=2D_DRAWING_CAD, PDF=2D_PDF_CAD)
· 파일명에서 알려진 확장자 반복 제거 (.idw .dwg .dxf .stp .step .pdf .chg)
· PART_NO 매칭: 정확 일치 우선 → 안 되면 longest prefix
· partNoList 지정 → 그 목록 IN 절로 후보 제한 (M1, 현재 그리드 기반)
· partNoList 미지정 → IS_LAST='1' 전체 part_mng 매칭 (M2, 페이지 밖도 허용)
(wace partMngListByPartNos <if PART_NO_LIST != null> 분기 1:1)
· 매칭 성공 → attach_file_info INSERT (target_objid = part_mng.objid)
· 매칭 실패 → notFoundCount + 임시 파일 삭제
· 결과 details[] 반환 (파일별 상태/매칭품번/사유)
- 엔드포인트: POST /api/development/part/drawing-multi-upload
· multer 파일당 200MB · 최대 500개 · 임시 디스크 저장 후 회사/날짜 폴더 이동
- 프론트 PartDrawingMultiUploadButton (개발관리 공용):
· 버튼 클릭 → 숨김 input(multiple, accept=.stp,.step,.dwg,.dxf,.pdf)
· 확장자별 분류 + "총 N개 업로드?" confirm (wace 1:1 텍스트)
· 결과 다이얼로그 — 총합/성공/품번 미존재/실패 + 파일별 상세표
- M1(part-regist): partNoList = 현재 그리드 rows.part_no 전달
- M2(part-search): partNoList 미전달 → 전체 part_mng 매칭
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -168,6 +168,43 @@ export async function excelSave(req: AuthenticatedRequest, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 도면 다중 업로드 (wace btnDrawingUpload 1:1) ───────────
|
||||
|
||||
export async function drawingMultiUpload(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req.user as any)?.userId ?? "system";
|
||||
const companyCode = (req.user as any)?.companyCode ?? "COMPANY_16";
|
||||
const files = (req.files as Express.Multer.File[]) ?? [];
|
||||
if (files.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "업로드할 파일이 없습니다." });
|
||||
}
|
||||
// multipart body — partNoList 는 JSON 문자열로 전달됨.
|
||||
// 지정 → 그 목록만 매칭 후보 (M1 현재 그리드 한정)
|
||||
// 미지정/빈 배열 → IS_LAST='1' 전체 매칭 (M2 조회 — 페이지 밖도 허용)
|
||||
let partNoList: string[] | null = null;
|
||||
const raw = req.body?.partNoList;
|
||||
if (typeof raw === "string" && raw.trim() !== "") {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) partNoList = parsed.map(String).filter(Boolean);
|
||||
} catch {
|
||||
return res.status(400).json({ success: false, message: "partNoList JSON 파싱 실패" });
|
||||
}
|
||||
} else if (Array.isArray(raw)) {
|
||||
partNoList = raw.map(String).filter(Boolean);
|
||||
}
|
||||
const result = await svc.drawingMultiUpload(userId, companyCode, files, partNoList);
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `업로드 완료 — 성공 ${result.successCount} / 미매칭 ${result.notFoundCount} / 실패 ${result.failCount}`,
|
||||
});
|
||||
} catch (e: any) {
|
||||
logger.error("도면 다중 업로드 실패", { error: e.message });
|
||||
return res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 다중 삭제 ──────────────────────────────────────────────
|
||||
|
||||
export async function removeMany(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as ctrl from "../controllers/devPartController";
|
||||
|
||||
@@ -16,6 +18,14 @@ const excelUpload = multer({
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||
});
|
||||
|
||||
// 도면 다중 업로드 — 임시 디스크 저장. 매칭 성공 시 서비스에서 최종 위치로 이동.
|
||||
const drawingTempDir = path.join(process.cwd(), "uploads", "temp");
|
||||
if (!fs.existsSync(drawingTempDir)) fs.mkdirSync(drawingTempDir, { recursive: true });
|
||||
const drawingUpload = multer({
|
||||
dest: drawingTempDir,
|
||||
limits: { fileSize: 200 * 1024 * 1024 }, // 파일당 200MB
|
||||
});
|
||||
|
||||
// M1 — 임시(등록) 그리드
|
||||
router.get("/part-temp/list", ctrl.getTempList);
|
||||
router.post("/part-temp/deploy", ctrl.deploy);
|
||||
@@ -27,6 +37,13 @@ router.get("/part/list", ctrl.getList);
|
||||
router.post("/part/excel-parse", excelUpload.single("file"), ctrl.excelParse);
|
||||
router.post("/part/excel-save", ctrl.excelSave);
|
||||
|
||||
// 도면 다중 업로드 (M1·M2 공용) — /:objid 보다 위
|
||||
router.post(
|
||||
"/part/drawing-multi-upload",
|
||||
drawingUpload.array("files", 500),
|
||||
ctrl.drawingMultiUpload
|
||||
);
|
||||
|
||||
// PART 자동완성 옵션 (select2-part 1:1) — /:objid 보다 위
|
||||
router.get("/part/options", ctrl.partOptions);
|
||||
|
||||
|
||||
@@ -19,9 +19,12 @@
|
||||
// ============================================================
|
||||
|
||||
import { PoolClient } from "pg";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { getPool, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { createObjId } from "../utils/objidUtil";
|
||||
import { generateUUID } from "../utils/generateId";
|
||||
import { PART_BASE_SIMPLE } from "./devPartSqlFragments";
|
||||
|
||||
// ─── 필터/바디 타입 ──────────────────────────────────────────
|
||||
@@ -511,3 +514,253 @@ export async function removeMany(objids: string[]): Promise<number> {
|
||||
);
|
||||
return r.rowCount ?? 0;
|
||||
}
|
||||
|
||||
// ─── 도면 다중 업로드 ─────────────────────────────────────────
|
||||
// wace partMngTempList.jsp btnDrawingUpload + PartMngController.uploadDrawingFilesForPartList 1:1.
|
||||
//
|
||||
// 흐름:
|
||||
// 1) IS_LAST='1' part_mng 전체 조회 → PART_NO → OBJID 맵
|
||||
// 2) 각 파일별:
|
||||
// a) 확장자(STP/STEP/DWG/DXF/PDF) → doc_type 결정
|
||||
// (STP/STEP=3D_CAD, DWG/DXF=2D_DRAWING_CAD, PDF=2D_PDF_CAD)
|
||||
// b) 파일명에서 알려진 확장자 반복 제거 (.idw .dwg .dxf .stp .step .pdf .chg)
|
||||
// c) PART_NO 매칭: 정확 일치 우선 → 안 되면 startsWith (가장 긴 prefix)
|
||||
// d) 매칭 성공 → attach_file_info INSERT (target_objid = part_mng.objid)
|
||||
// e) 매칭 실패 → notFoundCount++ 파일 삭제
|
||||
|
||||
const DRAWING_KNOWN_EXTS = [".idw", ".dwg", ".dxf", ".stp", ".step", ".pdf", ".chg"];
|
||||
|
||||
function removeDrawingExtensions(fileName: string): string {
|
||||
let result = fileName;
|
||||
let removed = true;
|
||||
while (removed) {
|
||||
removed = false;
|
||||
const lastDot = result.lastIndexOf(".");
|
||||
if (lastDot > 0) {
|
||||
const ext = result.substring(lastDot).toLowerCase();
|
||||
if (DRAWING_KNOWN_EXTS.includes(ext)) {
|
||||
result = result.substring(0, lastDot);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findMatchingPartNo(
|
||||
fileNameWithoutExt: string,
|
||||
partNoSet: Set<string>
|
||||
): string | null {
|
||||
if (!fileNameWithoutExt || partNoSet.size === 0) return null;
|
||||
if (partNoSet.has(fileNameWithoutExt)) return fileNameWithoutExt;
|
||||
let best: string | null = null;
|
||||
for (const pn of partNoSet) {
|
||||
if (fileNameWithoutExt.startsWith(pn)) {
|
||||
if (!best || pn.length > best.length) best = pn;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface DrawingMultiUploadDetail {
|
||||
fileName: string;
|
||||
partNo?: string;
|
||||
docType?: string;
|
||||
status: "success" | "fail" | "notFound" | "unsupported";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface DrawingMultiUploadResult {
|
||||
successCount: number;
|
||||
failCount: number;
|
||||
notFoundCount: number;
|
||||
details: DrawingMultiUploadDetail[];
|
||||
}
|
||||
|
||||
export async function drawingMultiUpload(
|
||||
userId: string,
|
||||
companyCode: string,
|
||||
files: Express.Multer.File[],
|
||||
partNoList: string[] | null | undefined
|
||||
): Promise<DrawingMultiUploadResult> {
|
||||
const pool = getPool();
|
||||
|
||||
// 1) 매칭 후보 PART 조회
|
||||
// partNoList 지정 → 그 목록 IN 절로 제한 (M1 등록 화면, 현재 그리드 기반)
|
||||
// partNoList 없음 → IS_LAST='1' 전체 (M2 조회 화면 — 페이지 밖 파트도 매칭 허용)
|
||||
// (wace partMng.xml partMngListByPartNos: `<if PART_NO_LIST != null>` 1:1)
|
||||
const hasList = Array.isArray(partNoList) && partNoList.length > 0;
|
||||
const partRes = hasList
|
||||
? await pool.query<{ objid: string; part_no: string }>(
|
||||
`SELECT objid::text AS objid, part_no
|
||||
FROM part_mng
|
||||
WHERE is_last = '1'
|
||||
AND part_no = ANY($1::text[])`,
|
||||
[partNoList]
|
||||
)
|
||||
: await pool.query<{ objid: string; part_no: string }>(
|
||||
`SELECT objid::text AS objid, part_no
|
||||
FROM part_mng
|
||||
WHERE is_last = '1' AND part_no IS NOT NULL`
|
||||
);
|
||||
const partNoMap = new Map<string, string>();
|
||||
for (const r of partRes.rows) {
|
||||
if (r.part_no) partNoMap.set(r.part_no, r.objid);
|
||||
}
|
||||
const partNoSet = new Set(partNoMap.keys());
|
||||
logger.info("도면 다중 업로드 시작", {
|
||||
files: files.length,
|
||||
scope: hasList ? "visible" : "all",
|
||||
requestedPartNos: hasList ? partNoList!.length : null,
|
||||
partCandidates: partNoMap.size,
|
||||
});
|
||||
|
||||
// 2) 회사/날짜 폴더 준비 (fileController.ts 와 동일 경로 규약)
|
||||
const baseUploadDir = path.join(process.cwd(), "uploads");
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(today.getDate()).padStart(2, "0");
|
||||
const dateFolder = `${year}/${month}/${day}`;
|
||||
const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode;
|
||||
const finalUploadDir = path.join(baseUploadDir, actualCompanyCode, dateFolder);
|
||||
if (!fs.existsSync(finalUploadDir)) {
|
||||
fs.mkdirSync(finalUploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const result: DrawingMultiUploadResult = {
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
notFoundCount: 0,
|
||||
details: [],
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
// 파일명 UTF-8 디코딩
|
||||
let originalName: string;
|
||||
try {
|
||||
originalName = Buffer.from(file.originalname, "latin1").toString("utf8");
|
||||
} catch {
|
||||
originalName = file.originalname;
|
||||
}
|
||||
|
||||
// 확장자 → doc_type
|
||||
const ext = path
|
||||
.extname(originalName)
|
||||
.toLowerCase()
|
||||
.replace(".", "")
|
||||
.toUpperCase();
|
||||
let docType = "";
|
||||
let docTypeName = "";
|
||||
if (ext === "STP" || ext === "STEP") {
|
||||
docType = "3D_CAD";
|
||||
docTypeName = "3D CAD 첨부파일";
|
||||
} else if (ext === "DWG" || ext === "DXF") {
|
||||
docType = "2D_DRAWING_CAD";
|
||||
docTypeName = "2D(Drawing) CAD 첨부파일";
|
||||
} else if (ext === "PDF") {
|
||||
docType = "2D_PDF_CAD";
|
||||
docTypeName = "2D(PDF) CAD 첨부파일";
|
||||
} else {
|
||||
result.failCount++;
|
||||
result.details.push({
|
||||
fileName: originalName,
|
||||
status: "unsupported",
|
||||
reason: `지원하지 않는 확장자: ${ext}`,
|
||||
});
|
||||
try { fs.unlinkSync(file.path); } catch {}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 파일명 ↔ part_no 매칭
|
||||
const nameWithoutExt = removeDrawingExtensions(originalName);
|
||||
const matchedPartNo = findMatchingPartNo(nameWithoutExt, partNoSet);
|
||||
if (!matchedPartNo) {
|
||||
result.notFoundCount++;
|
||||
result.details.push({
|
||||
fileName: originalName,
|
||||
status: "notFound",
|
||||
reason: `품번 매칭 실패 (${nameWithoutExt})`,
|
||||
});
|
||||
try { fs.unlinkSync(file.path); } catch {}
|
||||
continue;
|
||||
}
|
||||
const targetObjid = partNoMap.get(matchedPartNo)!;
|
||||
|
||||
// 임시 → 최종 위치 이동
|
||||
const sanitizedName = originalName
|
||||
.replace(/[\/\\:*?"<>|]/g, "_")
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/_{2,}/g, "_");
|
||||
const savedFileName = `${Date.now()}_${sanitizedName}`;
|
||||
const finalFilePath = path.join(finalUploadDir, savedFileName);
|
||||
try {
|
||||
fs.renameSync(file.path, finalFilePath);
|
||||
} catch (e: any) {
|
||||
logger.error("도면 파일 저장 실패", { error: e.message, file: originalName });
|
||||
result.failCount++;
|
||||
result.details.push({
|
||||
fileName: originalName,
|
||||
partNo: matchedPartNo,
|
||||
docType,
|
||||
status: "fail",
|
||||
reason: "파일 저장 실패",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = `/${actualCompanyCode}/${dateFolder}/${savedFileName}`;
|
||||
const fullFilePath = `/uploads${relativePath}`;
|
||||
|
||||
// attach_file_info INSERT
|
||||
const objidValue = parseInt(
|
||||
generateUUID().replace(/-/g, "").substring(0, 15),
|
||||
16
|
||||
);
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO attach_file_info (
|
||||
objid, target_objid, saved_file_name, real_file_name, doc_type, doc_type_name,
|
||||
file_size, file_ext, file_path, company_code, writer, regdate, status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), 'ACTIVE')`,
|
||||
[
|
||||
objidValue,
|
||||
targetObjid,
|
||||
savedFileName,
|
||||
originalName,
|
||||
docType,
|
||||
docTypeName,
|
||||
file.size,
|
||||
ext.toLowerCase(),
|
||||
fullFilePath,
|
||||
companyCode,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
result.successCount++;
|
||||
result.details.push({
|
||||
fileName: originalName,
|
||||
partNo: matchedPartNo,
|
||||
docType,
|
||||
status: "success",
|
||||
});
|
||||
} catch (e: any) {
|
||||
logger.error("attach_file_info INSERT 실패", {
|
||||
error: e.message,
|
||||
file: originalName,
|
||||
});
|
||||
result.failCount++;
|
||||
result.details.push({
|
||||
fileName: originalName,
|
||||
partNo: matchedPartNo,
|
||||
docType,
|
||||
status: "fail",
|
||||
reason: e.message,
|
||||
});
|
||||
try { fs.unlinkSync(finalFilePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("도면 다중 업로드 완료", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { devPartApi, PartListFilter, PartRow } from "@/lib/api/devPart";
|
||||
import { PartFormDialog } from "@/components/development/PartFormDialog";
|
||||
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
|
||||
import { PartExcelImportDialog } from "@/components/development/PartExcelImportDialog";
|
||||
import { PartDrawingMultiUploadButton } from "@/components/development/PartDrawingMultiUploadButton";
|
||||
import { DevPartSelect } from "@/components/development/DevPartSelect";
|
||||
|
||||
// wace 23셀 + 부속 (PARENT_PART_INFO/PARTNER_TITLE/Q_QTY)
|
||||
@@ -202,6 +203,10 @@ export default function PartRegistPage() {
|
||||
<Button size="sm" variant="outline" onClick={() => setExcelOpen(true)}>
|
||||
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">Excel Upload</span>
|
||||
</Button>
|
||||
<PartDrawingMultiUploadButton
|
||||
partNoList={rows.map((r) => r.part_no).filter(Boolean) as string[]}
|
||||
onUploaded={() => fetchList()}
|
||||
/>
|
||||
<Button size="sm" onClick={handleDeploy}
|
||||
disabled={checkedIds.length === 0}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
|
||||
@@ -18,6 +18,7 @@ import { devPartApi, PartListFilter, PartRow } from "@/lib/api/devPart";
|
||||
import { PartFormDialog } from "@/components/development/PartFormDialog";
|
||||
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
|
||||
import { PartExcelImportDialog } from "@/components/development/PartExcelImportDialog";
|
||||
import { PartDrawingMultiUploadButton } from "@/components/development/PartDrawingMultiUploadButton";
|
||||
import { DevPartSelect } from "@/components/development/DevPartSelect";
|
||||
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
@@ -170,6 +171,8 @@ export default function PartSearchPage() {
|
||||
<Button size="sm" variant="outline" onClick={() => setExcelOpen(true)}>
|
||||
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">Excel Upload</span>
|
||||
</Button>
|
||||
{/* M2 조회 — partNoList 미전달: IS_LAST='1' 전체 part_mng 매칭 (페이지 밖도 허용) */}
|
||||
<PartDrawingMultiUploadButton onUploaded={() => fetchList()} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 > PART 도면 다중 업로드 버튼 — wace partMngTempList.jsp btnDrawingUpload 1:1.
|
||||
//
|
||||
// 동작:
|
||||
// 1) 버튼 클릭 → 숨김 <input type="file" multiple accept=".stp,.step,.dwg,.dxf,.pdf"> 클릭
|
||||
// 2) onChange → 확장자 분류 (3D/2D/PDF) + 0개 거부 + 분류표 confirm
|
||||
// 3) 확인 → POST /api/development/part/drawing-multi-upload
|
||||
// 4) 응답 받아 결과 다이얼로그 표시 + 그리드 새로고침
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FileImage, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
devPartApi,
|
||||
DrawingMultiUploadResult,
|
||||
DrawingMultiUploadDetail,
|
||||
} from "@/lib/api/devPart";
|
||||
|
||||
interface Props {
|
||||
/** 매칭 후보 PART_NO 범위.
|
||||
* 배열 지정 → 그 목록만 매칭 후보 (M1 등록 화면 — 현재 그리드 기반).
|
||||
* null/undefined/빈 배열 → IS_LAST='1' 전체 part_mng 매칭 (M2 조회 화면 — 페이지 밖도 허용).
|
||||
* (wace partMng.xml `<if PART_NO_LIST != null>` 분기 1:1) */
|
||||
partNoList?: string[] | null;
|
||||
onUploaded?: () => void; // 업로드 완료 후 그리드 새로고침
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ACCEPT = ".stp,.step,.dwg,.dxf,.pdf";
|
||||
|
||||
export function PartDrawingMultiUploadButton({ partNoList, onUploaded, className }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [result, setResult] = useState<DrawingMultiUploadResult | null>(null);
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
|
||||
const onClick = () => {
|
||||
if (uploading) return;
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
const onChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = e.target.files;
|
||||
if (!picked || picked.length === 0) return;
|
||||
|
||||
// wace fn_uploadDrawingFiles 1:1 — 확장자별 분류
|
||||
const filesByType: Record<"3D" | "2D" | "PDF", File[]> = {
|
||||
"3D": [],
|
||||
"2D": [],
|
||||
PDF: [],
|
||||
};
|
||||
for (let i = 0; i < picked.length; i++) {
|
||||
const f = picked[i];
|
||||
const dot = f.name.lastIndexOf(".");
|
||||
if (dot < 0) continue;
|
||||
const ext = f.name.substring(dot + 1).toLowerCase();
|
||||
if (ext === "stp" || ext === "step") filesByType["3D"].push(f);
|
||||
else if (ext === "dwg" || ext === "dxf") filesByType["2D"].push(f);
|
||||
else if (ext === "pdf") filesByType.PDF.push(f);
|
||||
}
|
||||
const valid = [...filesByType["3D"], ...filesByType["2D"], ...filesByType.PDF];
|
||||
|
||||
// input 초기화 (같은 파일 재선택 가능)
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
|
||||
if (valid.length === 0) {
|
||||
window.alert("업로드 가능한 파일 형식이 없습니다. (stp, dwg, dxf, pdf만 가능)");
|
||||
return;
|
||||
}
|
||||
|
||||
const msg =
|
||||
`총 ${valid.length}개의 파일을 업로드하시겠습니까?\n` +
|
||||
`- 3D (STP): ${filesByType["3D"].length}개\n` +
|
||||
`- 2D (DWG/DXF): ${filesByType["2D"].length}개\n` +
|
||||
`- PDF: ${filesByType.PDF.length}개`;
|
||||
if (!window.confirm(msg)) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await devPartApi.drawingMultiUpload(valid, partNoList);
|
||||
setResult(res);
|
||||
setResultOpen(true);
|
||||
if (onUploaded) onUploaded();
|
||||
} catch (err: any) {
|
||||
toast.error(
|
||||
err?.response?.data?.message ?? err?.message ?? "도면 업로드 실패"
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onClick}
|
||||
disabled={uploading}
|
||||
className={className}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileImage className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-1">도면 다중 업로드</span>
|
||||
</Button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
className="hidden"
|
||||
onChange={onChange}
|
||||
/>
|
||||
<DrawingResultDialog
|
||||
open={resultOpen}
|
||||
onOpenChange={setResultOpen}
|
||||
result={result}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 결과 다이얼로그 ────────────────────────────────────────
|
||||
|
||||
function DrawingResultDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
result,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
result: DrawingMultiUploadResult | null;
|
||||
}) {
|
||||
if (!result) return null;
|
||||
const total =
|
||||
result.successCount + result.failCount + result.notFoundCount;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[720px] w-[95vw] max-h-[80vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="bg-blue-600 px-4 py-3">
|
||||
<DialogTitle className="text-white">도면 다중 업로드 결과</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
<div className="grid grid-cols-4 gap-2 text-sm">
|
||||
<Stat label="총합" value={total} />
|
||||
<Stat label="성공" value={result.successCount} tone="success" />
|
||||
<Stat label="품번 미존재" value={result.notFoundCount} tone="warn" />
|
||||
<Stat label="실패" value={result.failCount} tone="error" />
|
||||
</div>
|
||||
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/40">
|
||||
<th className="border px-2 py-1 text-left">파일명</th>
|
||||
<th className="border px-2 py-1 text-left">매칭 품번</th>
|
||||
<th className="border px-2 py-1 text-left">문서구분</th>
|
||||
<th className="border px-2 py-1 text-left">상태</th>
|
||||
<th className="border px-2 py-1 text-left">사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.details.map((d, i) => (
|
||||
<tr key={i}>
|
||||
<td className="border px-2 py-1 break-all">{d.fileName}</td>
|
||||
<td className="border px-2 py-1">{d.partNo ?? "—"}</td>
|
||||
<td className="border px-2 py-1">{d.docType ?? "—"}</td>
|
||||
<td className="border px-2 py-1">
|
||||
<StatusBadge status={d.status} />
|
||||
</td>
|
||||
<td className="border px-2 py-1 break-all text-muted-foreground">
|
||||
{d.reason ?? ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-end">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: "success" | "warn" | "error";
|
||||
}) {
|
||||
const cls =
|
||||
tone === "success"
|
||||
? "text-emerald-700 bg-emerald-50"
|
||||
: tone === "warn"
|
||||
? "text-amber-700 bg-amber-50"
|
||||
: tone === "error"
|
||||
? "text-red-700 bg-red-50"
|
||||
: "text-foreground bg-muted/40";
|
||||
return (
|
||||
<div className={`rounded border px-2 py-2 ${cls}`}>
|
||||
<div className="text-[11px] text-muted-foreground">{label}</div>
|
||||
<div className="text-base font-semibold">{value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: DrawingMultiUploadDetail["status"] }) {
|
||||
const map: Record<
|
||||
DrawingMultiUploadDetail["status"],
|
||||
{ label: string; cls: string }
|
||||
> = {
|
||||
success: { label: "성공", cls: "bg-emerald-100 text-emerald-700" },
|
||||
notFound: { label: "품번 미존재", cls: "bg-amber-100 text-amber-700" },
|
||||
unsupported: { label: "확장자 미지원", cls: "bg-amber-100 text-amber-700" },
|
||||
fail: { label: "실패", cls: "bg-red-100 text-red-700" },
|
||||
};
|
||||
const v = map[status];
|
||||
return (
|
||||
<span className={`rounded px-1.5 py-0.5 text-[11px] ${v.cls}`}>
|
||||
{v.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -306,4 +306,43 @@ export const devPartApi = {
|
||||
const res = await apiClient.post("/development/part/excel-save", { rows });
|
||||
return res.data?.data as ExcelSaveResponse;
|
||||
},
|
||||
|
||||
// 도면 다중 업로드 (wace btnDrawingUpload 1:1)
|
||||
// 확장자 stp/step → 3D_CAD, dwg/dxf → 2D_DRAWING_CAD, pdf → 2D_PDF_CAD
|
||||
// 파일명 ↔ part_no 자동 매칭 (정확 일치 → longest prefix)
|
||||
// partNoList 지정 → 그 목록만 매칭 후보 (M1)
|
||||
// partNoList null/undefined → IS_LAST='1' 전체 매칭 (M2)
|
||||
async drawingMultiUpload(
|
||||
files: File[],
|
||||
partNoList?: string[] | null
|
||||
): Promise<DrawingMultiUploadResult> {
|
||||
const fd = new FormData();
|
||||
for (const f of files) fd.append("files", f);
|
||||
if (Array.isArray(partNoList) && partNoList.length > 0) {
|
||||
fd.append("partNoList", JSON.stringify(partNoList));
|
||||
}
|
||||
const res = await apiClient.post(
|
||||
"/development/part/drawing-multi-upload",
|
||||
fd,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
);
|
||||
return res.data?.data as DrawingMultiUploadResult;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 도면 다중 업로드 결과 타입 ──────────────────────────────
|
||||
|
||||
export interface DrawingMultiUploadDetail {
|
||||
fileName: string;
|
||||
partNo?: string;
|
||||
docType?: string;
|
||||
status: "success" | "fail" | "notFound" | "unsupported";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface DrawingMultiUploadResult {
|
||||
successCount: number;
|
||||
failCount: number;
|
||||
notFoundCount: number;
|
||||
details: DrawingMultiUploadDetail[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user