7218edc500
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>
244 lines
8.0 KiB
TypeScript
244 lines
8.0 KiB
TypeScript
"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>
|
|
);
|
|
}
|