7779f37c17
PART Excel Import (M1·M2 공용):
- /partMng/openPartExcelImportPopUp.do + partParsingExcelFile.do + partUploadSave.do 1:1
- 22컬럼 파싱 + NOTE 누적 검증 (품번 필수/중복, PART_TYPE/ACCTFG/UNIT_DC commCode 매핑,
ODRFG/LOT_FG/USE_YN/QC_FG/SETITEM_FG/REQ_FG 한글 → 코드값)
- 저장은 신규 PART_NO 만 mergePartMng INSERT (기존 IS_LAST='1' 행은 skip)
- part-regist + part-search 페이지에 Excel Upload 버튼 + 다이얼로그 연결
BOM Report Excel Import (M3 = openBomReportExcelImportPopUp = "PART 및 구조등록 Excel upload"):
- /partMng/parsingExcelFile.do + checkDuplicatePartNo.do + getBomDataForCopy.do
+ partBomApplySave.do (savePartBomMaster) 1:1
- 10컬럼 파싱 + 자품번/모품번/품명/수량 필수 검증, 모품번이 자품번 목록(Set)에 존재 검증,
수량 숫자+>0 검증, PART_TYPE='0001788'(구매품표준) part_mng 존재 검증
- 1레벨(모품번 없는 첫 행) → 헤더 PART_NO/PART_NAME 자동 채움
- 저장 트랜잭션 (wace 1:1):
헤더 part_bom_report INSERT(신규) / DELETE 자식트리+UPDATE(수정)
자식 PART: part_mng IS_LAST='1' 존재 시 updatePartInfoFromCsv UPDATE, 없으면 insertpartInfo INSERT
부모 PART: 존재 시 lookup, 없으면 "" (절대 INSERT 안 함 — wace 5359-5361)
bom_part_qty INSERT (relatePartInfo) — 부모행 CHILD_OBJID 를 PARENT_OBJID 로 체인
- 헤더 PART_NO 중복 검사 (편집 중인 자신 제외)
- E-BOM 복사 기능 (기존 BOM → 그리드 행) + Template Download
- ebom-regist 페이지에 "E-BOM 등록(Excel)" 버튼 + 다이얼로그 연결
운영 템플릿 정적 자산:
- frontend/public/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx
- frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx (.gitignore 우회 git add -f)
wace structureExcelImportPopup.jsp 는 옛 차종/제품군/사양 도메인 화면으로 운영 메뉴 트리에
서 더이상 호출되지 않아 이식 대상 제외.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
"use client";
|
|
|
|
// 개발관리 PART Excel Import 다이얼로그
|
|
// wace partMng/openPartExcelImportPopUp.jsp 1:1
|
|
// - Template Download / Drag & Drop / 파일선택 → 백엔드 파싱 + 검증
|
|
// - 검증 그리드 22컬럼 + NOTE (에러는 빨강) — wace expenseDetailGrid 1:1
|
|
// - NOTE 있는 행이 하나라도 있으면 저장 차단 (wace fn_save 1:1)
|
|
|
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Download, Upload, Save, Loader2, FileX } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { devPartApi, PartExcelRow } from "@/lib/api/devPart";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const TEMPLATE_DOWNLOAD_URL = "/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx";
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSaved: () => void;
|
|
}
|
|
|
|
interface Column {
|
|
key: keyof PartExcelRow;
|
|
label: string;
|
|
width: string;
|
|
align?: "left" | "center" | "right";
|
|
showNameFor?: keyof PartExcelRow;
|
|
}
|
|
|
|
const COLUMNS: Column[] = [
|
|
{ key: "NOTE", label: "결과", width: "min-w-[200px]", align: "left" },
|
|
{ key: "PART_NO", label: "품번", width: "min-w-[140px]", align: "center" },
|
|
{ key: "PART_NAME", label: "품명", width: "min-w-[200px]", align: "left" },
|
|
{ key: "MATERIAL", label: "재료", width: "min-w-[100px]" },
|
|
{ key: "HEAT_TREATMENT_HARDNESS", label: "열처리경도", width: "min-w-[110px]" },
|
|
{ key: "HEAT_TREATMENT_METHOD", label: "열처리방법", width: "min-w-[110px]" },
|
|
{ key: "SURFACE_TREATMENT", label: "표면처리", width: "min-w-[100px]" },
|
|
{ key: "MAKER", label: "메이커", width: "min-w-[110px]" },
|
|
{ key: "PART_TYPE", label: "범주", width: "min-w-[90px]", align: "center", showNameFor: "PART_TYPE_NAME" },
|
|
{ key: "SPEC", label: "규격", width: "min-w-[100px]" },
|
|
{ key: "ACCTFG", label: "계정구분", width: "min-w-[90px]", align: "center", showNameFor: "ACCTFG_NAME" },
|
|
{ key: "ODRFG", label: "조달구분", width: "min-w-[90px]", align: "center", showNameFor: "ODRFG_NAME" },
|
|
{ key: "UNIT_DC", label: "재고단위", width: "min-w-[80px]", align: "center", showNameFor: "UNIT_DC_NAME" },
|
|
{ key: "UNITMANG_DC", label: "관리단위", width: "min-w-[80px]", align: "center", showNameFor: "UNITMANG_DC_NAME" },
|
|
{ key: "UNITCHNG_NB", label: "환산수량", width: "min-w-[80px]", align: "right" },
|
|
{ key: "LOT_FG", label: "LOT구분", width: "min-w-[80px]", align: "center" },
|
|
{ key: "USE_YN", label: "사용여부", width: "min-w-[80px]", align: "center" },
|
|
{ key: "QC_FG", label: "검사여부", width: "min-w-[80px]", align: "center" },
|
|
{ key: "SETITEM_FG", label: "SET품여부", width: "min-w-[90px]", align: "center" },
|
|
{ key: "REQ_FG", label: "의뢰여부", width: "min-w-[80px]", align: "center" },
|
|
{ key: "UNIT_LENGTH", label: "개당길이", width: "min-w-[80px]", align: "right" },
|
|
{ key: "UNIT_QTY", label: "개당소요량", width: "min-w-[90px]", align: "right" },
|
|
{ key: "REMARK", label: "비고", width: "min-w-[130px]", align: "left" },
|
|
];
|
|
|
|
const LABEL_LOT = { "0": "미사용", "1": "사용" } as Record<string, string>;
|
|
const LABEL_USE = { "0": "미사용", "1": "사용" } as Record<string, string>;
|
|
const LABEL_QC = { "0": "무검사", "1": "검사" } as Record<string, string>;
|
|
const LABEL_YN = { "0": "부", "1": "여" } as Record<string, string>;
|
|
|
|
function displayValue(r: PartExcelRow, col: Column): string {
|
|
if (col.showNameFor) {
|
|
const name = r[col.showNameFor];
|
|
if (name) return String(name);
|
|
return String(r[col.key] ?? "");
|
|
}
|
|
const v = String(r[col.key] ?? "");
|
|
if (col.key === "LOT_FG") return LABEL_LOT[v] ?? v;
|
|
if (col.key === "USE_YN") return LABEL_USE[v] ?? v;
|
|
if (col.key === "QC_FG") return LABEL_QC[v] ?? v;
|
|
if (col.key === "SETITEM_FG" || col.key === "REQ_FG") return LABEL_YN[v] ?? v;
|
|
return v;
|
|
}
|
|
|
|
export function PartExcelImportDialog({ open, onOpenChange, onSaved }: Props) {
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [parsedRows, setParsedRows] = useState<PartExcelRow[]>([]);
|
|
const [hasError, setHasError] = useState(false);
|
|
const [fileName, setFileName] = useState<string>("");
|
|
const [parsing, setParsing] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
|
|
const reset = useCallback(() => {
|
|
setParsedRows([]);
|
|
setHasError(false);
|
|
setFileName("");
|
|
}, []);
|
|
|
|
const handleDialogChange = (v: boolean) => {
|
|
if (!v) reset();
|
|
onOpenChange(v);
|
|
};
|
|
|
|
const parseFile = useCallback(async (file: File) => {
|
|
if (!/\.xlsx?$/i.test(file.name)) {
|
|
toast.error("xlsx 또는 xls 파일만 업로드 가능합니다.");
|
|
return;
|
|
}
|
|
setParsing(true);
|
|
setFileName(file.name);
|
|
try {
|
|
const data = await devPartApi.excelParse(file);
|
|
setParsedRows(data.rows ?? []);
|
|
setHasError(!!data.hasError);
|
|
if (!data.rows || data.rows.length === 0) {
|
|
toast.warning("파싱된 데이터가 없습니다. 템플릿 형식을 확인해 주세요.");
|
|
} else if (data.hasError) {
|
|
toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요.");
|
|
} else {
|
|
toast.success(`${data.rows.length}건 파싱 완료`);
|
|
}
|
|
} catch (e: any) {
|
|
toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 파싱 실패");
|
|
reset();
|
|
} finally {
|
|
setParsing(false);
|
|
}
|
|
}, [reset]);
|
|
|
|
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) parseFile(f);
|
|
e.target.value = "";
|
|
};
|
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
setDragOver(false);
|
|
const f = e.dataTransfer.files?.[0];
|
|
if (f) parseFile(f);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (parsedRows.length === 0) { toast.error("저장할 데이터가 없습니다."); return; }
|
|
if (hasError) {
|
|
toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요.");
|
|
return;
|
|
}
|
|
if (!confirm("저장 하시겠습니까?")) return;
|
|
setSaving(true);
|
|
try {
|
|
const res = await devPartApi.excelSave(parsedRows);
|
|
toast.success(`${res.inserted}건이 저장되었습니다.${res.skipped > 0 ? ` (중복 ${res.skipped}건 건너뜀)` : ""}`);
|
|
onSaved();
|
|
handleDialogChange(false);
|
|
} catch (e: any) {
|
|
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const errorCount = useMemo(() => parsedRows.filter((r) => r.NOTE).length, [parsedRows]);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleDialogChange}>
|
|
<DialogContent className="max-w-[1500px] w-[97vw] max-h-[92vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>PART 등록 Excel upload</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-wrap items-center gap-2 border-b pb-2">
|
|
<Button variant="outline" size="sm" asChild>
|
|
<a href={TEMPLATE_DOWNLOAD_URL} download>
|
|
<Download className="h-4 w-4" /><span className="ml-1">Template Download</span>
|
|
</a>
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={parsing}>
|
|
<Upload className="h-4 w-4" /><span className="ml-1">파일 선택</span>
|
|
</Button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".xlsx,.xls"
|
|
className="hidden"
|
|
onChange={handleFileInput}
|
|
/>
|
|
{fileName && (
|
|
<span className="text-sm text-muted-foreground ml-2 truncate max-w-[300px]">
|
|
{fileName}
|
|
</span>
|
|
)}
|
|
{parsedRows.length > 0 && (
|
|
<Button variant="ghost" size="sm" onClick={reset}>
|
|
<FileX className="h-4 w-4" /><span className="ml-1">초기화</span>
|
|
</Button>
|
|
)}
|
|
<div className="ml-auto text-xs text-muted-foreground">
|
|
총 {parsedRows.length}건
|
|
{errorCount > 0 && <span className="ml-2 text-destructive">에러 {errorCount}건</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Drop Zone — 파싱 전에만 노출 */}
|
|
{parsedRows.length === 0 && !parsing && (
|
|
<div
|
|
className={cn(
|
|
"border-2 border-dashed rounded p-10 text-center transition-colors cursor-pointer",
|
|
dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/30"
|
|
)}
|
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={handleDrop}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<Upload className="h-10 w-10 mx-auto text-muted-foreground mb-2" />
|
|
<div className="text-sm text-muted-foreground">
|
|
Drag & Drop 또는 클릭하여 엑셀 템플릿 업로드 (.xlsx, .xls)
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{parsing && (
|
|
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin" /><span className="ml-2">파싱 중...</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 결과 그리드 */}
|
|
{parsedRows.length > 0 && !parsing && (
|
|
<div className="flex-1 min-h-0 overflow-auto border rounded">
|
|
<table className="text-xs border-collapse w-max min-w-full">
|
|
<thead className="bg-muted sticky top-0">
|
|
<tr>
|
|
<th className="border px-2 py-1 w-[40px] text-center">#</th>
|
|
{COLUMNS.map((c) => (
|
|
<th key={c.key as string} className={cn("border px-2 py-1", c.width)}>
|
|
{c.label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{parsedRows.map((r, i) => (
|
|
<tr key={i} className={r.NOTE ? "bg-destructive/5" : ""}>
|
|
<td className="border px-2 py-1 text-center">{i + 1}</td>
|
|
{COLUMNS.map((c) => {
|
|
const value = displayValue(r, c);
|
|
const isNote = c.key === "NOTE";
|
|
return (
|
|
<td
|
|
key={c.key as string}
|
|
className={cn(
|
|
"border px-2 py-1 whitespace-nowrap",
|
|
c.align === "right" && "text-right",
|
|
c.align === "center" && "text-center",
|
|
isNote && r.NOTE && "text-destructive font-semibold"
|
|
)}
|
|
title={value}
|
|
>
|
|
{value}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter className="gap-2">
|
|
<Button variant="outline" onClick={() => handleDialogChange(false)}>닫기</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saving || parsedRows.length === 0 || hasError}
|
|
>
|
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
|
<span className="ml-1">저장</span>
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|