Files
wace_rps/frontend/components/development/BomReportExcelImportDialog.tsx
T
hjjeong 0c791d21d6 개발관리>E-BOM 등록 — XLSX 구현 폐기 후 CSV(11컬럼·수준 기반)로 재작성
운영판 wace 재확인 결과 BOM 등록은 XLSX가 아니라 CSV 가 진짜 입력 포맷이었음.
근거:
  · openBomReportExcelImportPopUp.jsp : 제목 "PART 및 구조등록 CSV upload",
    Drop Zone "Drag & Drop CSV 템플릿", fnc_setFileDropZone(..., "csv") 로 CSV 만 허용
  · /partMng/parsingExcelFile.do 가 .csv 분기에서 별도 함수 parsingCsvFile() 호출
  · CSV 11컬럼 : 수준 / 품번 / 품명 / 수량 / 항목수량 / 재료 / 열처리경도 / 열처리방법 /
                  표면처리 / 공급업체(MAKER) / 범주이름(PART_TYPE)
  · CSV 는 PARENT_PART_NO 컬럼이 없고 "수준" 으로 부모-자식 자동 결정
    · 숫자("1","2","3"): 깊이 D 의 부모 = D-1 의 최신 품번
    · 점 표기법("1","1.1","1.4.1"): 마지막 점 이전 수준이 부모

backend (devBomExcelImportService.ts):
  · XLSX(xlsx 라이브러리) 파싱 → CSV(iconv-lite) 파싱으로 완전 재작성
  · 인코딩 자동 감지 1:1 — CP949 → UTF-8 → EUC-KR → MS949 순서, � 카운트로 베스트 선택,
    UTF-8 BOM() 자동 제거
  · CSV 영문 범주명 자동 변환 — Assy/ASSY→조립품, Buy/BUY→구매품, Make/MAKE→부품
  · 범주별 ACCTFG/ODRFG 자동 설정 — 조립품(0001813)·부품(0001812) → ACCTFG=4·ODRFG=1,
                                       구매품(0000063) → ACCTFG=7·ODRFG=0
  · 기본값 일괄 — UNIT_DC=0001400(EA) · UNITMANG_DC=0001400 · UNITCHNG_NB=1 ·
                  LOT_FG=1 · USE_YN=1 · QC_FG=0 · SETITEM_FG=0 · REQ_FG=0
  · 검증 1:1 — rowIndex > 2 에서만 모품번 자품번 목록 존재 검사, NOTE 누적
  · 저장 로직(savePartBomMaster)은 동일 유지 — 자식 PART updatePartInfoFromCsv UPDATE /
    insertpartInfo INSERT, 부모 PART 존재 시 lookup·없으면 "" (INSERT 안 함),
    bom_part_qty INSERT 시 ACCTFG/ODRFG/UNIT_DC/UNITCHNG_NB 등 모든 컬럼 동기

frontend:
  · BomExcelRow → BomCsvRow (LEVEL 컬럼 + ACCTFG/ODRFG/UNIT_DC/UNITCHNG_NB/LOT_FG/USE_YN/
    QC_FG/SETITEM_FG/REQ_FG 자동 채움 필드 추가). BomExcelRow 는 호환 type alias 로 보존
  · BomReportExcelImportDialog : 제목 "PART 및 구조등록 CSV upload", accept=".csv", Drop Zone
    안내 텍스트 변경, 그리드 컬럼 — 결과/수준/모품번(자동)/품번/품명/수량/항목수량/재료/
    열처리경도/열처리방법/표면처리/공급업체/범주/계정구분(자동)/조달구분(자동)
  · 파싱 결과의 encoding 정보 표시 (CP949/UTF-8 등)

정적 자산:
  · 운영 BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx 제거 (운영판도 실제 사용 안 함)
  · 신규 BOM_REPORT_CSV_IMPORT_TEMPLATE.csv 추가 (11컬럼 헤더 + 4행 샘플)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:02:15 +09:00

395 lines
16 KiB
TypeScript

"use client";
// 개발관리 E-BOM 등록 CSV Import 다이얼로그
// wace partMng/openBomReportExcelImportPopUp.jsp 1:1
//
// 운영판 흐름:
// · Drop Zone: Drag & Drop CSV 템플릿 (fnc_setFileDropZone(..., "csv"))
// · 파싱: parsingExcelFile.do 의 .csv 분기 → parsingCsvFile (수준 기반 부모 자동 매핑)
// · 저장: partBomApplySave.do → savePartBomMaster
//
// CSV 컬럼 (11개, 헤더 1줄 후 데이터):
// 0:수준 1:품번 2:품명 3:수량 4:항목수량 5:재료 6:열처리경도 7:열처리방법
// 8:표면처리 9:공급업체(MAKER) 10:범주이름(PART_TYPE)
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { Download, Upload, Save, Loader2, FileX, Copy } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { devBomApi, BomCsvRow, BomCopySourceRow } from "@/lib/api/devBom";
const PRODUCT_GROUP = "0000001";
const TEMPLATE_DOWNLOAD_URL = "/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
editObjid?: string | null;
initialProductCd?: string;
onSaved: () => void;
}
interface Column {
key: keyof BomCsvRow;
label: string;
width: string;
align?: "left" | "center" | "right";
showNameFor?: keyof BomCsvRow;
}
// 그리드 컬럼: 화면 표시는 핵심 + 자동 채움 컬럼 (운영 그리드 25컬럼 중 CSV 11컬럼 + 자동 ACCTFG/ODRFG)
const COLUMNS: Column[] = [
{ key: "NOTE", label: "결과", width: "min-w-[200px]", align: "left" },
{ key: "LEVEL", label: "수준", width: "min-w-[60px]", align: "center" },
{ key: "PARENT_PART_NO", label: "모품번 (자동)", width: "min-w-[140px]", align: "center" },
{ key: "PART_NO", label: "품번", width: "min-w-[140px]", align: "center" },
{ key: "PART_NAME", label: "품명", width: "min-w-[200px]", align: "left" },
{ key: "QTY", label: "수량", width: "min-w-[70px]", align: "right" },
{ key: "ITEM_QTY", label: "항목수량", width: "min-w-[80px]", align: "right" },
{ key: "MATERIAL", label: "재료", width: "min-w-[100px]" },
{ key: "HEAT_TREATMENT_HARDNESS", label: "열처리경도", width: "min-w-[100px]" },
{ key: "HEAT_TREATMENT_METHOD", label: "열처리방법", width: "min-w-[100px]" },
{ 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: "ACCTFG", label: "계정구분(자동)", width: "min-w-[90px]", align: "center" },
{ key: "ODRFG", label: "조달구분(자동)", width: "min-w-[90px]", align: "center" },
];
const ACCTFG_LABEL: Record<string, string> = { "4": "반제품", "7": "비용" };
const ODRFG_LABEL: Record<string, string> = { "0": "구매", "1": "생산", "8": "Phantom" };
function displayValue(r: BomCsvRow, col: Column): string {
if (col.key === "ACCTFG") {
const v = String(r.ACCTFG ?? "");
return ACCTFG_LABEL[v] ?? v;
}
if (col.key === "ODRFG") {
const v = String(r.ODRFG ?? "");
return ODRFG_LABEL[v] ?? v;
}
if (col.showNameFor) {
const name = r[col.showNameFor];
if (name) return String(name);
}
return String(r[col.key] ?? "");
}
export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, initialProductCd, onSaved }: Props) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [productCd, setProductCd] = useState<string>("");
const [bomPartNo, setBomPartNo] = useState<string>("");
const [bomPartName, setBomPartName] = useState<string>("");
const [version, setVersion] = useState<string>("");
const [copyOptions, setCopyOptions] = useState<BomCopySourceRow[]>([]);
const [copySelect, setCopySelect] = useState<string>("");
const [rows, setRows] = useState<BomCsvRow[]>([]);
const [hasError, setHasError] = useState(false);
const [fileName, setFileName] = useState<string>("");
const [encoding, setEncoding] = useState<string>("");
const [parsing, setParsing] = useState(false);
const [saving, setSaving] = useState(false);
const [copying, setCopying] = useState(false);
const [dragOver, setDragOver] = useState(false);
const reset = useCallback(() => {
setProductCd(initialProductCd ?? "");
setBomPartNo("");
setBomPartName("");
setVersion("");
setRows([]);
setHasError(false);
setFileName("");
setEncoding("");
setCopySelect("");
}, [initialProductCd]);
useEffect(() => {
if (!open) return;
reset();
devBomApi.excelCopySource().then(setCopyOptions).catch(() => setCopyOptions([]));
}, [open, reset]);
const handleDialogChange = (v: boolean) => {
if (!v) reset();
onOpenChange(v);
};
const applyFirstLevelToHeader = (first: { part_no: string; part_name: string } | null) => {
if (!first) return;
if (first.part_no) setBomPartNo(first.part_no);
if (first.part_name) setBomPartName(first.part_name);
};
const parseFile = useCallback(async (file: File) => {
if (!/\.csv$/i.test(file.name)) {
toast.error("CSV(.csv) 파일만 업로드 가능합니다.");
return;
}
setParsing(true);
setFileName(file.name);
try {
const data = await devBomApi.excelParse(file);
setRows(data.rows ?? []);
setHasError(!!data.hasError);
setEncoding(data.encoding ?? "");
applyFirstLevelToHeader(data.firstLevel);
if (!data.rows || data.rows.length === 0) {
toast.warning("파싱된 데이터가 없습니다. CSV 형식을 확인해 주세요.");
} else if (data.hasError) {
toast.error("CSV 파일 로딩결과가 유효하지 않습니다. 결과 메시지를 확인해 주세요.");
} else {
toast.success(`${data.rows.length}건 파싱 완료 (인코딩: ${data.encoding})`);
}
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "CSV 파싱 실패");
setRows([]); setHasError(false); setFileName(""); setEncoding("");
} finally {
setParsing(false);
}
}, []);
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 handleCopy = async () => {
if (!copySelect) { toast.error("복사할 BOM을 선택하세요."); return; }
setCopying(true);
try {
const copied = await devBomApi.excelCopy(copySelect);
setRows(copied);
setHasError(false);
const first = copied.find((r) => !r.PARENT_PART_NO);
if (first) applyFirstLevelToHeader({ part_no: first.PART_NO, part_name: first.PART_NAME });
toast.success(`BOM 데이터 ${copied.length}건 불러왔습니다.`);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "BOM 복사 실패");
} finally {
setCopying(false);
}
};
const handleSave = async () => {
if (!productCd) { toast.error("제품구분을 선택해 주세요."); return; }
if (!bomPartNo) { toast.error("품번을 입력해 주세요."); return; }
if (!bomPartName){ toast.error("품명을 입력해 주세요."); return; }
if (hasError) {
toast.error("CSV 파일 로딩결과가 유효하지 않습니다. 결과 메시지를 확인해 주세요.");
return;
}
try {
const dup = await devBomApi.excelCheckDuplicate(bomPartNo, editObjid ?? undefined);
if (dup) {
toast.error("입력한 품번이 이미 존재합니다. 다른 품번을 입력해주세요.");
return;
}
} catch { /* 비차단 */ }
const confirmMsg = rows.length > 0 ? "저장 하시겠습니까?" : "품번, 품명으로 빈 E-BOM을 생성하시겠습니까?";
if (!confirm(confirmMsg)) return;
setSaving(true);
try {
const result = await devBomApi.excelSave({
bomReportObjid: editObjid ?? undefined,
productCd, partNo: bomPartNo, partName: bomPartName, version,
rows,
});
toast.success(`${result.mode === "create" ? "등록" : "수정"} 완료 — BOM ${result.bomRows}건 (PART 신규 ${result.insertedParts} / 수정 ${result.updatedParts})`);
onSaved();
handleDialogChange(false);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
const errorCount = useMemo(() => rows.filter((r) => r.NOTE).length, [rows]);
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogContent className="max-w-[1500px] w-[97vw] max-h-[92vh] flex flex-col">
<DialogHeader>
<DialogTitle>PART CSV upload</DialogTitle>
</DialogHeader>
{/* 헤더 */}
<div className="grid grid-cols-4 gap-3 border-b pb-3">
<div>
<Label className="mb-1 block text-xs text-muted-foreground"> *</Label>
<CommCodeSelect
groupId={PRODUCT_GROUP}
value={productCd}
onValueChange={setProductCd}
/>
</div>
<div>
<Label className="mb-1 block text-xs text-muted-foreground"> * (1 )</Label>
<Input value={bomPartNo} readOnly placeholder="CSV 1레벨에서 자동 채움" />
</div>
<div>
<Label className="mb-1 block text-xs text-muted-foreground"> * (1 )</Label>
<Input value={bomPartName} readOnly placeholder="CSV 1레벨에서 자동 채움" />
</div>
<div>
<Label className="mb-1 block text-xs text-muted-foreground">Version</Label>
<Input value={version} onChange={(e) => setVersion(e.target.value)} placeholder="REV 등" />
</div>
</div>
{/* E-BOM 복사 + 액션 버튼 */}
<div className="flex flex-wrap items-center gap-2 border-b pb-3">
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground whitespace-nowrap">E-BOM </Label>
<select
className="h-9 rounded-md border bg-background px-2 text-sm min-w-[280px]"
value={copySelect}
onChange={(e) => setCopySelect(e.target.value)}
>
<option value=""></option>
{copyOptions.map((o) => (
<option key={o.objid} value={o.objid}>
{o.part_no} / {o.part_name} {o.revision ? `(v${o.revision})` : ""} - {o.regdate ?? ""}
</option>
))}
</select>
<Button variant="outline" size="sm" onClick={handleCopy} disabled={copying || !copySelect}>
{copying ? <Loader2 className="h-4 w-4 animate-spin" /> : <Copy className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
</div>
<div className="ml-auto flex items-center gap-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">CSV </span>
</Button>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={handleFileInput}
/>
{rows.length > 0 && (
<Button variant="ghost" size="sm"
onClick={() => { setRows([]); setHasError(false); setFileName(""); setEncoding(""); }}>
<FileX className="h-4 w-4" /><span className="ml-1"></span>
</Button>
)}
</div>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-3">
{fileName && <span className="truncate max-w-[400px]">{fileName}</span>}
{encoding && <span>: <b>{encoding}</b></span>}
<span> {rows.length}</span>
{errorCount > 0 && <span className="text-destructive font-semibold"> {errorCount}</span>}
</div>
{/* Drop Zone */}
{rows.length === 0 && !parsing && (
<div
className={cn(
"border-2 border-dashed rounded p-8 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-9 w-9 mx-auto text-muted-foreground mb-2" />
<div className="text-sm text-muted-foreground">
Drag &amp; Drop CSV 릿 (.csv)
</div>
<div className="mt-1 text-xs text-muted-foreground">
컬럼: 수준 / / / / / / / / / /
</div>
</div>
)}
{parsing && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" /><span className="ml-2"> ...</span>
</div>
)}
{/* 결과 그리드 */}
{rows.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>
{rows.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 || 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>
);
}