생산관리 M-BOM 본 편집(PR-B1) + 폴더 컬럼 + DataGrid 서버 페이지네이션 + bigint=varchar fix

PR-B1 본 편집/저장 (운영 saveMbom.do 1:1)
  · 매퍼 7종 1:1 (insert/updateMbomHeader, insert/updateMbomDetail,
    deleteMbomDetailByObjid, insertMbomHistory, updateProjectMbomStatus)
  · 신규 CREATE: createObjId + generateMbomNo(M-{partNo}-YYMMDD-NN) +
    child_objid 재매핑 + detail 일괄 insert + history(CREATE) + project_mgmt.mbom_status='Y'
  · 수정 UPDATE: 기존 mbom_header.objid UPSERT(insert/update/delete) + history(UPDATE)
  · POST /api/production/mbom/save (BEGIN/COMMIT/ROLLBACK 트랜잭션)
  · MbomDetailDialog: '본 편집' 토글 + 13개 셀 인라인 편집 + 저장/취소 가드

M-BOM 컬럼 폴더 아이콘
  · production/mbom/page.tsx: mbom_status 컬럼 → mbom_has(0/1) renderType=folder
  · onClick → MbomDetailDialog 오픈 (행 더블클릭도 그대로 유지)
  · 운영판 wace 견적/partMng 폴더 아이콘 패턴 1:1

DataGrid 서버 페이지네이션
  · props 신설: serverPaging/serverPage/serverPageSize/serverTotalItems
    + onPageChange/onPageSizeChange
  · 5메뉴 적용: production/mbom, development/change-list/ebom-regist/part-search/part-regist
  · pageSizeOptions=[10,15,20,50,100,200,500] 통일
  · 클라이언트 모드 하위호환 유지

bigint=varchar fix (mbom 트리 SQL 4종)
  · ATTACH_FILE_INFO 서브쿼리: P.OBJID(bigint) = F.TARGET_OBJID(varchar) → P.OBJID::varchar 캐스트
  · EBOM_WORKING_TREE_SQL INNER JOIN: P.OBJID = COALESCE(V.LAST_PART_OBJID,V.PART_NO) → ::varchar 캐스트
  · 사용자 보고: 폴더 클릭 시 'operator does not exist: bigint = character varying' 토스트

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-14 16:26:20 +09:00
parent 3a5bcb5e3a
commit 7a7f4f03b5
11 changed files with 882 additions and 88 deletions
@@ -52,3 +52,22 @@ export async function getTree(req: AuthenticatedRequest, res: Response) {
return res.status(500).json({ success: false, message: e.message });
}
}
// PR-B1 — 본 편집 저장 (운영 saveMbom.do 1:1)
export async function save(req: AuthenticatedRequest, res: Response) {
try {
const payload = req.body as svc.MbomSavePayload;
if (!payload?.project_obj_id) {
return res.status(400).json({ success: false, message: "project_obj_id 누락" });
}
if (!Array.isArray(payload.rows)) {
return res.status(400).json({ success: false, message: "rows 누락" });
}
const userId = req.user?.userId ?? "system";
const data = await svc.save(payload, userId);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("M-BOM 저장 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
@@ -13,5 +13,6 @@ router.use(authenticateToken);
router.get("/list", ctrl.getList);
router.get("/detail/:objid", ctrl.getDetail);
router.get("/tree/:objid", ctrl.getTree);
router.post("/save", ctrl.save); // PR-B1 본 편집 저장
export default router;
+457 -13
View File
@@ -21,6 +21,7 @@
// ============================================================
import { getPool } from "../database/db";
import { createObjId } from "../utils/objidUtil";
// ─── 필터/페이지 타입 ──────────────────────────────────────────
@@ -611,9 +612,9 @@ SELECT
P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT,
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title,
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty,
COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length
FROM VIEW_BOM V
@@ -686,9 +687,9 @@ SELECT
P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT,
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title,
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty,
COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length
FROM VIEW_BOM V
@@ -770,9 +771,9 @@ SELECT
P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT,
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title,
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty,
COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length
FROM VIEW_BOM V
@@ -780,6 +781,449 @@ LEFT JOIN PART_MNG P ON V.PART_OBJID = P.OBJID
ORDER BY V.PATH2
`;
// ─── 저장 (PR-B1) ───────────────────────────────────────────
//
// 운영판 ProductionPlanningController.saveMbom (1549~1645) + 서비스 saveMbom (1192~1574) 1:1.
// 매퍼: insertMbomHeader / updateMbomHeader / insertMbomDetail / updateMbomDetail
// / deleteMbomDetailByObjid / insertMbomHistory / updateProjectMbomStatus.
//
// 분기 처리:
// isUpdate=false (최초 저장) — 새 mbom_header 생성 + child_objid 재매핑 후 detail 일괄 insert
// + history(CREATE) + project_mgmt.mbom_status='Y'
// isUpdate=true (수정 저장) — 기존 mbom_header.objid 조회 → updateMbomHeader
// → mbom_data 의 objid 기준 UPSERT(insert/update) + 누락분 delete
// + history(UPDATE, 변경 행수 description)
export interface MbomSaveRow {
objid?: string | null;
parent_objid?: string | null;
child_objid?: string | null;
seq?: number | string | null;
level?: number | string | null;
part_objid?: string | number | null;
part_no?: string | null;
part_name?: string | null;
qty?: number | string | null;
item_qty?: number | string | null;
unit?: string | null;
supply_type?: string | null;
make_or_buy?: string | null;
raw_material_no?: string | null;
raw_material_spec?: string | null;
raw_material?: string | null;
size?: string | null; // tree row alias → raw_material_size
raw_material_size?: string | null;
processing_vendor?: string | null;
processing_deadline?: string | null;
grinding_deadline?: string | null;
required_qty?: number | string | null;
order_qty?: number | string | null;
production_qty?: number | string | null;
stock_qty?: number | string | null;
shortage_qty?: number | string | null;
vendor?: string | null;
unit_price?: number | string | null;
processing_unit_price?: number | string | null;
total_price?: number | string | null;
currency?: string | null;
lead_time?: number | string | null;
min_order_qty?: number | string | null;
remark?: string | null;
}
export interface MbomSavePayload {
project_obj_id: string;
is_update: boolean;
mbom_part_no?: string | null; // 최상위 제품 변경 시 (PR-B1 에서는 신규/유지만)
rows: MbomSaveRow[];
}
export interface MbomSaveResult {
mode: "CREATE" | "UPDATE";
mbom_header_objid: string;
mbom_no: string;
inserted: number;
updated: number;
deleted: number;
}
// generateMbomNo — wace generateMbomNo(EBOM/TEMPLATE) 1:1.
// 패턴: M-{cleanPartNo}-{YYMMDD}-{NN} (NN = 동일 prefix 마지막+1, 미존재 시 01)
async function generateMbomNo(
client: any,
sourceBomType: string,
basePartNo: string,
): Promise<string> {
let cleanPartNo = (basePartNo || "").trim();
if (cleanPartNo.startsWith("M-")) cleanPartNo = cleanPartNo.substring(2);
const now = new Date();
const yy = String(now.getFullYear()).slice(-2);
const mm = String(now.getMonth() + 1).padStart(2, "0");
const dd = String(now.getDate()).padStart(2, "0");
const dateStr = `${yy}${mm}${dd}`;
const prefix = `M-${cleanPartNo}-${dateStr}`;
const r = await client.query(
`SELECT MBOM_NO FROM MBOM_HEADER
WHERE MBOM_NO LIKE $1 || '-%'
ORDER BY MBOM_NO DESC LIMIT 1`,
[prefix],
);
let seq = 1;
if (r.rows[0]?.mbom_no) {
const m = String(r.rows[0].mbom_no).match(/-(\d{2})$/);
if (m) seq = Math.min(99, Number(m[1]) + 1);
}
return `${prefix}-${String(seq).padStart(2, "0")}`;
}
const DETAIL_INSERT_SQL = `
INSERT INTO MBOM_DETAIL (
OBJID, MBOM_HEADER_OBJID, PARENT_OBJID, CHILD_OBJID, SEQ, LEVEL,
PART_OBJID, PART_NO, PART_NAME, QTY, ITEM_QTY, UNIT,
SUPPLY_TYPE, MAKE_OR_BUY,
RAW_MATERIAL_PART_NO, RAW_MATERIAL_SPEC, RAW_MATERIAL, RAW_MATERIAL_SIZE,
PROCESSING_VENDOR, PROCESSING_DEADLINE, GRINDING_DEADLINE,
REQUIRED_QTY, ORDER_QTY, PRODUCTION_QTY, STOCK_QTY, SHORTAGE_QTY,
VENDOR, UNIT_PRICE, PROCESSING_UNIT_PRICE, TOTAL_PRICE, CURRENCY,
LEAD_TIME, MIN_ORDER_QTY,
STATUS, WRITER, REGDATE, EDITER, EDIT_DATE, REMARK
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,
$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,
$27,$28,$29,$30,$31,$32,$33,
'ACTIVE', $34, NOW(), $34, NOW(), $35
)`;
const DETAIL_UPDATE_SQL = `
UPDATE MBOM_DETAIL SET
PARENT_OBJID = NULLIF($1, ''),
SEQ = $2,
LEVEL = $3,
PART_OBJID = $4,
PART_NO = NULLIF($5, ''),
PART_NAME = NULLIF($6, ''),
QTY = $7,
ITEM_QTY = $8,
UNIT = NULLIF($9, ''),
SUPPLY_TYPE = NULLIF($10, ''),
MAKE_OR_BUY = NULLIF($11, ''),
RAW_MATERIAL_PART_NO = NULLIF($12, ''),
RAW_MATERIAL_SPEC = NULLIF($13, ''),
RAW_MATERIAL = NULLIF($14, ''),
RAW_MATERIAL_SIZE = NULLIF($15, ''),
PROCESSING_VENDOR = NULLIF($16, ''),
PROCESSING_DEADLINE = NULLIF($17, ''),
GRINDING_DEADLINE = NULLIF($18, ''),
REQUIRED_QTY = $19,
ORDER_QTY = $20,
PRODUCTION_QTY = $21,
STOCK_QTY = $22,
SHORTAGE_QTY = $23,
EDITER = $24,
EDIT_DATE = NOW(),
REMARK = NULLIF($25, '')
WHERE OBJID = $26`;
function n(v: any): number | null {
if (v == null || v === "") return null;
const num = Number(v);
return Number.isFinite(num) ? num : null;
}
function s(v: any): string | null {
if (v == null) return null;
const str = String(v).trim();
return str === "" ? null : str;
}
function bi(v: any): string | null {
// part_objid 는 bigint — 숫자/문자열 모두 수용, 빈값/NaN 은 null
if (v == null || v === "") return null;
const num = Number(v);
return Number.isFinite(num) ? String(Math.trunc(num)) : null;
}
function detailInsertParams(
row: MbomSaveRow,
objid: string,
mbomHeaderObjid: string,
childObjid: string,
parentObjid: string | null,
userId: string,
): any[] {
return [
objid,
mbomHeaderObjid,
parentObjid,
childObjid,
n(row.seq) ?? 999,
n(row.level) ?? 1,
bi(row.part_objid),
s(row.part_no),
s(row.part_name),
n(row.qty),
n(row.item_qty),
s(row.unit),
s(row.supply_type),
s(row.make_or_buy),
s(row.raw_material_no),
s(row.raw_material_spec),
s(row.raw_material),
s(row.raw_material_size ?? row.size),
s(row.processing_vendor),
s(row.processing_deadline),
s(row.grinding_deadline),
n(row.required_qty),
n(row.order_qty),
n(row.production_qty),
n(row.stock_qty),
n(row.shortage_qty),
s(row.vendor),
n(row.unit_price),
n(row.processing_unit_price),
n(row.total_price),
s(row.currency) ?? "KRW",
n(row.lead_time),
n(row.min_order_qty),
userId,
s(row.remark),
];
}
function detailUpdateParams(row: MbomSaveRow, objid: string, userId: string): any[] {
return [
s(row.parent_objid) ?? "",
n(row.seq) ?? 999,
n(row.level) ?? 1,
bi(row.part_objid),
s(row.part_no) ?? "",
s(row.part_name) ?? "",
n(row.qty),
n(row.item_qty),
s(row.unit) ?? "",
s(row.supply_type) ?? "",
s(row.make_or_buy) ?? "",
s(row.raw_material_no) ?? "",
s(row.raw_material_spec) ?? "",
s(row.raw_material) ?? "",
s(row.raw_material_size ?? row.size) ?? "",
s(row.processing_vendor) ?? "",
s(row.processing_deadline) ?? "",
s(row.grinding_deadline) ?? "",
n(row.required_qty),
n(row.order_qty),
n(row.production_qty),
n(row.stock_qty),
n(row.shortage_qty),
userId,
s(row.remark) ?? "",
objid,
];
}
export async function save(payload: MbomSavePayload, sessionUserId: string): Promise<MbomSaveResult> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const projectObjId = String(payload.project_obj_id ?? "").trim();
if (!projectObjId) throw new Error("project_obj_id 누락");
const userId = String(sessionUserId ?? "").trim() || "system";
// 1) 프로젝트 + 할당 정보 조회 (sourceBomType + basePartNo)
const proj = await client.query(
`SELECT PM.OBJID::VARCHAR AS objid,
PM.PART_NO AS part_no, PM.PART_NAME AS part_name,
PM.SOURCE_BOM_TYPE AS source_bom_type,
PM.SOURCE_EBOM_OBJID AS source_ebom_objid,
PM.SOURCE_MBOM_OBJID AS source_mbom_objid,
CM.PRODUCT AS product_code,
(SELECT PBR.PART_NO FROM PART_BOM_REPORT PBR
WHERE PBR.OBJID::VARCHAR = PM.SOURCE_EBOM_OBJID LIMIT 1) AS ebom_part_no,
(SELECT MH.MBOM_NO FROM MBOM_HEADER MH
WHERE MH.OBJID = PM.SOURCE_MBOM_OBJID LIMIT 1) AS source_mbom_no
FROM PROJECT_MGMT PM
INNER JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
WHERE PM.OBJID::VARCHAR = $1 LIMIT 1`,
[projectObjId],
);
if (!proj.rows[0]) throw new Error("프로젝트 정보를 찾을 수 없습니다");
const p = proj.rows[0];
let sourceBomType: string = p.source_bom_type ?? "";
let sourceEbomObjid: string | null = null;
let sourceMbomObjid: string | null = null;
let basePartNo = "";
if (sourceBomType === "EBOM") {
sourceEbomObjid = p.source_ebom_objid ?? null;
basePartNo = p.ebom_part_no ?? p.part_no ?? "";
} else if (sourceBomType === "MBOM") {
sourceMbomObjid = p.source_mbom_objid ?? null;
basePartNo = p.source_mbom_no ?? p.part_no ?? "";
} else {
// Machine 이외(product != 0000928) + part_no 가 있으면 TEMPLATE
if (p.product_code !== "0000928" && p.part_no) {
sourceBomType = "TEMPLATE";
basePartNo = p.part_no;
} else {
throw new Error("M-BOM 기준 정보가 없습니다. BOM 복사 팝업에서 먼저 기준을 설정해주세요.");
}
}
let mbomHeaderObjid: string;
let mbomNo: string;
let inserted = 0, updated = 0, deleted = 0;
let mode: "CREATE" | "UPDATE";
if (payload.is_update) {
// ── UPDATE 분기 ──
const exist = await client.query(
`SELECT OBJID AS objid, MBOM_NO AS mbom_no FROM MBOM_HEADER
WHERE PROJECT_OBJID = $1 AND STATUS = 'Y'
ORDER BY REGDATE DESC LIMIT 1`,
[projectObjId],
);
if (!exist.rows[0]) throw new Error("수정 대상 M-BOM 헤더를 찾을 수 없습니다");
mbomHeaderObjid = exist.rows[0].objid;
mbomNo = exist.rows[0].mbom_no;
mode = "UPDATE";
// 헤더 update
await client.query(
`UPDATE MBOM_HEADER SET
PART_NO = $1, PART_NAME = $2,
EDITER = $3, EDIT_DATE = NOW()
WHERE OBJID = $4`,
[p.part_no ?? "", p.part_name ?? "", userId, mbomHeaderObjid],
);
// 기존 detail objid 수집
const existRes = await client.query(
`SELECT OBJID AS objid FROM MBOM_DETAIL WHERE MBOM_HEADER_OBJID = $1`,
[mbomHeaderObjid],
);
const existIds = new Set<string>(existRes.rows.map((r: any) => r.objid));
const incomingIds = new Set<string>();
// UPSERT
for (const row of payload.rows ?? []) {
let objid = s(row.objid) ?? "";
if (!objid) objid = createObjId();
let childObjid = s(row.child_objid) ?? objid;
incomingIds.add(objid);
if (existIds.has(objid)) {
await client.query(DETAIL_UPDATE_SQL, detailUpdateParams({ ...row, child_objid: childObjid }, objid, userId));
updated++;
} else {
await client.query(
DETAIL_INSERT_SQL,
detailInsertParams(row, objid, mbomHeaderObjid, childObjid, s(row.parent_objid), userId),
);
inserted++;
}
}
// 누락 행 delete
for (const oldId of existIds) {
if (!incomingIds.has(oldId)) {
await client.query(`DELETE FROM MBOM_DETAIL WHERE OBJID = $1`, [oldId]);
deleted++;
}
}
// history UPDATE
await insertHistory(
client,
mbomHeaderObjid,
"UPDATE",
`${inserted + updated + deleted}개 항목 처리 (insert=${inserted}, update=${updated}, delete=${deleted})`,
userId,
);
} else {
// ── CREATE 분기 ──
mbomHeaderObjid = createObjId();
mbomNo = await generateMbomNo(client, sourceBomType, basePartNo);
mode = "CREATE";
await client.query(
`INSERT INTO MBOM_HEADER (
OBJID, MBOM_NO, SOURCE_BOM_TYPE, SOURCE_EBOM_OBJID, SOURCE_MBOM_OBJID,
PROJECT_OBJID, PART_NO, PART_NAME, STATUS, MBOM_STATUS,
WRITER, REGDATE
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'Y','DRAFT',$9,NOW())`,
[
mbomHeaderObjid, mbomNo, sourceBomType,
sourceEbomObjid, sourceMbomObjid,
projectObjId, p.part_no ?? "", p.part_name ?? "",
userId,
],
);
// child_objid 재매핑 (E-BOM/MBOM 의 기존 child_objid → 새 child_objid)
const childMap = new Map<string, string>();
for (const row of payload.rows ?? []) {
const oldChild = s(row.child_objid);
if (oldChild && !childMap.has(oldChild)) childMap.set(oldChild, createObjId());
}
for (const row of payload.rows ?? []) {
const oldChild = s(row.child_objid);
const oldParent = s(row.parent_objid);
const newChild = (oldChild && childMap.get(oldChild)) || createObjId();
const newParent = oldParent ? (childMap.get(oldParent) ?? oldParent) : null;
await client.query(
DETAIL_INSERT_SQL,
detailInsertParams(row, newChild, mbomHeaderObjid, newChild, newParent, userId),
);
inserted++;
}
// history CREATE
await insertHistory(
client,
mbomHeaderObjid,
"CREATE",
`M-BOM 신규 저장 (${inserted}개 항목)`,
userId,
);
// PROJECT_MGMT.MBOM_STATUS = 'Y'
await client.query(
`UPDATE PROJECT_MGMT SET MBOM_STATUS = 'Y', MBOM_WRITER = $1, MBOM_REGDATE = NOW()
WHERE OBJID::VARCHAR = $2`,
[userId, projectObjId],
);
}
await client.query("COMMIT");
return { mode, mbom_header_objid: mbomHeaderObjid, mbom_no: mbomNo, inserted, updated, deleted };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
async function insertHistory(
client: any,
mbomHeaderObjid: string,
changeType: "CREATE" | "UPDATE",
description: string,
userId: string,
) {
await client.query(
`INSERT INTO MBOM_HISTORY (
OBJID, MBOM_HEADER_OBJID, CHANGE_TYPE, CHANGE_DESCRIPTION,
BEFORE_DATA, AFTER_DATA, CHANGE_USER, CHANGE_DATE
) VALUES ($1, $2, $3, $4, NULL, NULL, $5, NOW())`,
[createObjId(), mbomHeaderObjid, changeType, description, userId],
);
}
// 매퍼 partMng.getBOMTreeList search_type='working' 1:1.
// E-BOM 호환 — part_no 컬럼명 충돌 회피 위해 운영판처럼 V.PART_NO 는 PART_OBJID 로 alias.
const EBOM_WORKING_TREE_SQL = `
@@ -844,9 +1288,9 @@ SELECT
P.REMARK AS part_remark,
P.THICKNESS, P.WIDTH, P.HEIGHT, P.OUT_DIAMETER, P.IN_DIAMETER, P.LENGTH,
P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
-- E-BOM 분기는 생산정보가 없으므로 NULL 로 채워 SAVED 와 동일한 키셋 유지
NULL::text AS supply_type, NULL::text AS make_or_buy,
NULL::text AS raw_material_no, NULL::text AS raw_material_spec,
@@ -861,6 +1305,6 @@ SELECT
NULL::int AS lead_time, NULL::numeric AS min_order_qty,
NULL::text AS writer, NULL::text AS regdate, NULL::text AS editer, NULL::text AS edit_date, NULL::text AS remark
FROM VIEW_BOM V
INNER JOIN PART_MNG P ON P.OBJID = COALESCE(V.LAST_PART_OBJID, V.PART_NO)
INNER JOIN PART_MNG P ON P.OBJID::varchar = COALESCE(V.LAST_PART_OBJID, V.PART_NO)
ORDER BY V.PATH2
`;
@@ -172,7 +172,13 @@ export default function EoHistoryPage() {
gridId="development-eo-history"
showColumnSettings
paginationStyle="range"
pageSizeOptions={[10, 15, 20, 50, 100]}
pageSizeOptions={[10, 15, 20, 50, 100, 200, 500]}
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
summaryStats={eoSummary}
systemColumnKeys={["writer_name", "his_reg_date_title"]}
onRefresh={() => fetchList()}
@@ -206,7 +206,13 @@ export default function EbomRegistPage() {
gridId="development-ebom-regist"
showColumnSettings
paginationStyle="range"
pageSizeOptions={[10, 15, 20, 50, 100]}
pageSizeOptions={[10, 15, 20, 50, 100, 200, 500]}
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
summaryStats={bomSummary}
systemColumnKeys={["dept_user_name", "reg_date", "deploy_date", "revision", "status_title"]}
onRefresh={() => fetchList()}
@@ -239,7 +239,13 @@ export default function PartRegistPage() {
gridId="development-part-regist"
showColumnSettings
paginationStyle="range"
pageSizeOptions={[10, 15, 20, 50, 100]}
pageSizeOptions={[10, 15, 20, 50, 100, 200, 500]}
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
summaryStats={partSummary}
systemColumnKeys={["revision", "status"]}
onRefresh={() => fetchList()}
@@ -201,7 +201,13 @@ export default function PartSearchPage() {
gridId="development-part-search"
showColumnSettings
paginationStyle="range"
pageSizeOptions={[10, 15, 20, 50, 100]}
pageSizeOptions={[10, 15, 20, 50, 100, 200, 500]}
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
summaryStats={partSummary}
systemColumnKeys={["revision", "eo_no"]}
onRefresh={() => fetchList()}
@@ -51,27 +51,10 @@ const EMPTY_FILTER: MbomListFilter = {
search_req_del_date_from: "",
search_req_del_date_to: "",
page: 1,
page_size: 50,
page_size: 50, // DataGrid 서버 페이지네이션 — 페이지 변경/사이즈 변경 시 부모가 직접 재요청
};
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
{ key: "category_name", label: "주문유형", width: "w-[100px]", align: "center" },
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
{ key: "receipt_date", label: "접수일", width: "w-[100px]", align: "center" },
{ key: "writer_name", label: "작성자", width: "w-[90px]", align: "center" },
{ key: "customer_name", label: "고객사", minWidth: "min-w-[160px]" },
{ key: "paid_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[150px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "serial_no", label: "S/N", width: "w-[110px]", align: "center" },
{ key: "quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "req_del_date", label: "요청납기", width: "w-[100px]", align: "center" },
{ key: "customer_request", label: "고객사요청사항", minWidth: "min-w-[200px]" },
{ key: "mbom_status", label: "M-BOM", width: "w-[80px]", align: "center" },
{ key: "mbom_regdate", label: "최종저장일", width: "w-[100px]", align: "center" },
];
// 그리드 컬럼은 useMemo 로 컴포넌트 내부에서 생성 — onClick(openDialog) 캡처 위해.
export default function MbomMgmtPage() {
const [rows, setRows] = useState<MbomRow[]>([]);
@@ -124,11 +107,43 @@ export default function MbomMgmtPage() {
}, []);
// DataGrid 키 부여 (objid + part_no 조합 — 같은 프로젝트 다중 행 unique)
// mbom_has: folder 컬럼이 숫자 > 0 일 때 파랑. mbom_header_objid 가 있으면 저장된 M-BOM.
const gridRows = useMemo(
() => rows.map((r, i) => ({ ...r, id: `${r.objid}__${r.part_no ?? ""}__${i}` })),
() => rows.map((r, i) => ({
...r,
id: `${r.objid}__${r.part_no ?? ""}__${i}`,
mbom_has: r.mbom_header_objid ? 1 : 0,
})),
[rows]
);
const openMbomDialog = useCallback((row: any) => {
if (!row?.objid) return;
setDialogObjid(String(row.objid));
setDialogOpen(true);
}, []);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
{ key: "category_name", label: "주문유형", width: "w-[100px]", align: "center" },
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
{ key: "receipt_date", label: "접수일", width: "w-[100px]", align: "center" },
{ key: "writer_name", label: "작성자", width: "w-[90px]", align: "center" },
{ key: "customer_name", label: "고객사", minWidth: "min-w-[160px]" },
{ key: "paid_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[150px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "serial_no", label: "S/N", width: "w-[110px]", align: "center" },
{ key: "quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "req_del_date", label: "요청납기", width: "w-[100px]", align: "center" },
{ key: "customer_request", label: "고객사요청사항", minWidth: "min-w-[200px]" },
// M-BOM 컬럼 — 폴더 아이콘 (저장됨=파랑, 미저장=흰색). 클릭 시 본 편집 다이얼로그.
{ key: "mbom_has", label: "M-BOM", width: "w-[80px]", align: "center",
renderType: "folder", onClick: openMbomDialog },
{ key: "mbom_regdate", label: "최종저장일", width: "w-[100px]", align: "center" },
]), [openMbomDialog]);
const handleSearch = () => {
setFilter((f) => ({ ...f, page: 1 }));
fetchList({ page: 1 });
@@ -227,6 +242,14 @@ export default function MbomMgmtPage() {
showRowNumber
emptyMessage="조건에 맞는 프로젝트가 없습니다."
gridId="production-mbom-mgmt"
pageSizeOptions={[25, 50, 100, 200, 500]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
onRowDoubleClick={(row: any) => {
if (!row?.objid) return;
setDialogObjid(String(row.objid));
@@ -239,6 +262,7 @@ export default function MbomMgmtPage() {
open={dialogOpen}
onOpenChange={setDialogOpen}
projectObjid={dialogObjid}
onSaved={fetchList}
/>
</div>
);
+48 -14
View File
@@ -94,6 +94,18 @@ export interface DataGridProps {
onDownload?: () => void;
/** 차트 분석 패널 활성화. 지정 시 toolbar에 📊 토글 + 그리드 하단에 패널 노출 */
showChart?: boolean;
/** 서버 페이지네이션 모드. true면 page/pageSize/totalItems 를 props로 받고 변경 시 콜백 호출 */
serverPaging?: boolean;
/** 서버 페이지네이션 현재 페이지 (1-based, serverPaging=true일 때) */
serverPage?: number;
/** 서버 페이지네이션 페이지 크기 (serverPaging=true일 때) */
serverPageSize?: number;
/** 서버 페이지네이션 총 건수 (serverPaging=true일 때) */
serverTotalItems?: number;
/** 페이지 변경 콜백 — 서버 페이지네이션 시 API 재호출용 */
onPageChange?: (page: number) => void;
/** 페이지 크기 변경 콜백 — 서버 페이지네이션 시 API 재호출용 */
onPageSizeChange?: (pageSize: number) => void;
}
const fmtNum = (val: any) => {
@@ -303,6 +315,12 @@ export function DataGrid({
onRefresh,
onDownload,
showChart = false,
serverPaging = false,
serverPage,
serverPageSize,
serverTotalItems,
onPageChange,
onPageSizeChange,
}: DataGridProps) {
const [columns, setColumns] = useState(initialColumns);
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
@@ -360,10 +378,22 @@ export function DataGrid({
// 헤더 필터 (컬럼별 선택된 값 Set)
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
// 페이지네이션
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
// 페이지네이션 — 서버 모드면 props 사용, 아니면 로컬 state
const [localCurrentPage, setLocalCurrentPage] = useState(1);
const [localPageSize, setLocalPageSize] = useState(defaultPageSize);
const [pageSizeInput, setPageSizeInput] = useState(String(defaultPageSize));
const currentPage = serverPaging ? (serverPage ?? 1) : localCurrentPage;
const pageSize = serverPaging ? (serverPageSize ?? defaultPageSize) : localPageSize;
const setCurrentPage = useCallback((p: number | ((prev: number) => number)) => {
const next = typeof p === "function" ? (p as (n: number) => number)(currentPage) : p;
if (serverPaging) { onPageChange?.(next); } else { setLocalCurrentPage(next); }
}, [serverPaging, currentPage, onPageChange]);
const setPageSize = useCallback((n: number) => {
if (serverPaging) { onPageSizeChange?.(n); } else { setLocalPageSize(n); }
}, [serverPaging, onPageSizeChange]);
useEffect(() => {
if (serverPaging && serverPageSize) setPageSizeInput(String(serverPageSize));
}, [serverPaging, serverPageSize]);
// 인라인 편집
const [editingCell, setEditingCell] = useState<{ rowIdx: number; colKey: string } | null>(null);
@@ -533,31 +563,34 @@ export function DataGrid({
return result;
}, [data, headerFilters, sortKey, sortDir]);
// 필터/데이터 변경 시 1페이지로 리셋
// 필터/데이터 변경 시 1페이지로 리셋 — 서버 페이지네이션에서는 부모가 직접 page 관리하므로 스킵
useEffect(() => {
setCurrentPage(1);
}, [data, headerFilters]);
if (serverPaging) return;
setLocalCurrentPage(1);
}, [data, headerFilters, serverPaging]);
// 페이지네이션 계산
const totalItems = processedData.length;
// 서버 모드: totalItems 는 prop, paginatedData 는 data 그대로(서버가 잘라준 한 페이지)
// 클라이언트 모드: totalItems = processedData.length, paginatedData = client slice
const totalItems = serverPaging ? (serverTotalItems ?? processedData.length) : processedData.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safePage = Math.min(currentPage, totalPages);
useEffect(() => {
if (currentPage > totalPages) setCurrentPage(totalPages);
}, [currentPage, totalPages]);
if (!serverPaging && currentPage > totalPages) setCurrentPage(totalPages);
}, [serverPaging, currentPage, totalPages, setCurrentPage]);
const pageOffset = (safePage - 1) * pageSize;
const paginatedData = showPagination
? processedData.slice(pageOffset, pageOffset + pageSize)
: processedData;
const paginatedData = serverPaging
? processedData
: (showPagination ? processedData.slice(pageOffset, pageOffset + pageSize) : processedData);
// 페이지 크기 입력 적용
const applyPageSize = () => {
const n = parseInt(pageSizeInput, 10);
if (!isNaN(n) && n >= 1) {
setPageSize(n);
setCurrentPage(1);
if (!serverPaging) setLocalCurrentPage(1);
setPageSizeInput(String(n));
} else {
setPageSizeInput(String(pageSize));
@@ -1031,7 +1064,8 @@ export function DataGrid({
if (!isNaN(n) && n >= 1) {
setPageSize(n);
setPageSizeInput(String(n));
setCurrentPage(1);
// 서버 모드는 부모가 page 리셋(또는 페이지 유지)을 직접 결정
if (!serverPaging) setLocalCurrentPage(1);
}
}}
>
@@ -1,34 +1,37 @@
"use client";
// 생산관리 > M-BOM 관리 — 단건 상세 + read-only 트리 다이얼로그.
// 생산관리 > M-BOM 관리 — 단건 상세 + 트리 (read-only / 편집).
//
// 운영판 통합:
// wace mBomHeaderPopup.jsp (헤더 메타)
// + wace mBomPopupLeft.jsp (read-only 트리 — 4분기 자동)
// + wace mBomPopupLeft.jsp (좌측 트리 — read-only/편집)
//
// 4분기 (운영판 mBomPopupLeft.do):
// SAVED 저장된 mbom_header.status='Y' 의 트리 (생산정보 포함)
// ASSIGNED_EBOM source_bom_type='EBOM' + source_ebom_objid → bom_part_qty 트리
// SAVED 저장된 mbom_header.status='Y' 의 트리 (생산정보 포함, 편집 시 UPDATE)
// ASSIGNED_EBOM source_bom_type='EBOM' + source_ebom_objid → bom_part_qty 트리 (편집 시 신규 CREATE)
// ASSIGNED_MBOM source_bom_type='MBOM' + source_mbom_objid → mbom_detail 구조만
// TEMPLATE Machine 이외 + 동일 part_no 의 mbom_header 템플릿
// NONE 빈 트리
//
// 본 편집 / BOM 복사 / 구매리스트 생성 / 변경이력 — PR-B 분리.
// PR-B1 — 셀 인라인 편집 + 저장 (운영 saveMbom.do 1:1).
// 행 추가/삭제(mBomCenterBtnPopup) / BOM 복사 / 구매리스트 / 변경이력 — PR-B2~ 분리.
import React, { useEffect, useMemo, useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, Folder } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Loader2, Folder, Pencil, Save, X } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow } from "@/lib/api/mbom";
import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow, MbomSaveRow } from "@/lib/api/mbom";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
projectObjid: string | null;
onSaved?: () => void;
}
const BOM_DATA_TYPE_LABEL: Record<MbomBomDataType, { text: string; color: string }> = {
@@ -39,38 +42,55 @@ const BOM_DATA_TYPE_LABEL: Record<MbomBomDataType, { text: string; color: string
NONE: { text: "BOM 없음", color: "bg-slate-500" },
};
export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) {
// 편집 모드에서 사용자가 직접 수정 가능한 필드 (운영판 wace fieldMapping 기반)
type EditableField =
| "qty" | "supply_type" | "make_or_buy"
| "raw_material_no" | "raw_material" | "raw_material_size"
| "required_qty" | "order_qty" | "production_qty"
| "processing_vendor" | "processing_deadline" | "grinding_deadline"
| "remark";
export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: Props) {
const [detail, setDetail] = useState<MbomDetail | null>(null);
const [tree, setTree] = useState<MbomTreeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [editableRows, setEditableRows] = useState<MbomTreeRow[]>([]);
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (!open || !projectObjid) {
setDetail(null); setTree(null);
return;
}
let alive = true;
const loadTree = (objid: string) => {
setLoading(true);
Promise.all([
mbomApi.getDetail(projectObjid),
mbomApi.getTree(projectObjid),
return Promise.all([
mbomApi.getDetail(objid),
mbomApi.getTree(objid),
])
.then(([d, t]) => {
if (!alive) return;
setDetail(d);
setTree(t);
setEditableRows((t?.rows ?? []).map(r => ({ ...r })));
setDirty(false);
})
.catch((e: any) => {
toast.error(e?.response?.data?.message ?? e?.message ?? "M-BOM 조회 실패");
})
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
.finally(() => setLoading(false));
};
useEffect(() => {
if (!open || !projectObjid) {
setDetail(null); setTree(null); setEditableRows([]);
setEditMode(false); setDirty(false);
return;
}
void loadTree(projectObjid);
}, [open, projectObjid]);
const maxLevel = Math.max(1, tree?.max_level ?? 1);
const rows: MbomTreeRow[] = tree?.rows ?? [];
const rows = editMode ? editableRows : (tree?.rows ?? []);
const bomDataType: MbomBomDataType = tree?.bom_data_type ?? "NONE";
const meta = BOM_DATA_TYPE_LABEL[bomDataType];
const canEdit = bomDataType !== "NONE";
const levelHeaders = useMemo(() => {
const h: number[] = [];
@@ -78,13 +98,91 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) {
return h;
}, [maxLevel]);
const updateRow = (idx: number, field: EditableField, value: any) => {
setEditableRows(prev => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: value };
return next;
});
setDirty(true);
};
const handleEditToggle = () => {
if (editMode && dirty) {
if (!window.confirm("편집 중인 변경사항이 사라집니다. 취소하시겠습니까?")) return;
setEditableRows((tree?.rows ?? []).map(r => ({ ...r })));
setDirty(false);
}
setEditMode(!editMode);
};
const handleSave = async () => {
if (!projectObjid) return;
if (rows.length === 0) {
toast.error("저장할 트리가 비어있습니다");
return;
}
setSaving(true);
try {
const isUpdate = bomDataType === "SAVED";
const payload = {
project_obj_id: projectObjid,
is_update: isUpdate,
rows: editableRows.map<MbomSaveRow>(r => ({
objid: r.objid,
parent_objid: r.parent_objid,
child_objid: r.child_objid,
seq: r.seq,
level: r.level,
part_objid: r.part_objid,
part_no: r.part_no,
part_name: r.part_name,
qty: r.qty,
item_qty: r.item_qty,
unit: r.unit,
supply_type: r.supply_type,
make_or_buy: r.make_or_buy,
raw_material_no: r.raw_material_no,
raw_material_spec: r.raw_material_spec,
raw_material: r.raw_material,
raw_material_size: (r as any).raw_material_size ?? r.size,
processing_vendor: r.processing_vendor,
processing_deadline: r.processing_deadline,
grinding_deadline: r.grinding_deadline,
required_qty: r.required_qty,
order_qty: r.order_qty,
production_qty: r.production_qty,
remark: r.remark,
})),
};
const result = await mbomApi.save(payload);
toast.success(`M-BOM ${result.mode === "CREATE" ? "생성" : "수정"} 완료 (${result.mbom_no})`);
setEditMode(false);
setDirty(false);
await loadTree(projectObjid);
onSaved?.();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={(v) => {
if (!v && editMode && dirty) {
if (!window.confirm("저장하지 않은 변경사항이 있습니다. 닫으시겠습니까?")) return;
}
onOpenChange(v);
}}>
<DialogContent className="max-w-[1600px] w-[97vw] max-h-[92vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="bg-blue-600 px-4 py-3">
<DialogTitle className="text-white flex items-center gap-3">
<span>M-BOM </span>
<span>M-BOM {editMode ? "본 편집" : "단건 상세"}</span>
<span className={cn("rounded px-2 py-0.5 text-xs font-semibold", meta.color)}>{meta.text}</span>
{editMode && dirty && (
<span className="rounded bg-orange-500 px-2 py-0.5 text-xs font-semibold"></span>
)}
</DialogTitle>
</DialogHeader>
@@ -115,6 +213,24 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) {
<span className="ml-2 text-muted-foreground/70">BOM_OBJID = {tree.bom_report_objid}</span>
)}
</div>
<div className="flex items-center gap-2">
{canEdit && !editMode && (
<Button size="sm" variant="outline" onClick={handleEditToggle} disabled={loading}>
<Pencil className="w-3 h-3 mr-1" />
</Button>
)}
{editMode && (
<>
<Button size="sm" variant="outline" onClick={handleEditToggle} disabled={saving}>
<X className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
{saving ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Save className="w-3 h-3 mr-1" />}
({bomDataType === "SAVED" ? "수정" : "신규"})
</Button>
</>
)}
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto">
@@ -170,24 +286,36 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) {
))}
<td className="border px-2 py-0.5 whitespace-nowrap">{r.part_no}</td>
<td className="border px-2 py-0.5">{r.part_name}</td>
<td className="border px-2 py-0.5 text-right">{fmtNum(r.qty)}</td>
<EditableNumCell editable={editMode} value={r.qty} onChange={(v) => updateRow(idx, "qty", v)} />
<td className="border px-2 py-0.5 text-right">{fmtNum(r.item_qty)}</td>
<td className="border px-2 py-0.5 text-center">{r.unit_title ?? r.unit ?? ""}</td>
<td className="border px-2 py-0.5 text-center">{r.supply_type ?? ""}</td>
<td className="border px-2 py-0.5 text-center">{r.make_or_buy ?? ""}</td>
<td className="border px-2 py-0.5">{r.raw_material_no ?? ""}</td>
<td className="border px-2 py-0.5">{r.raw_material ?? ""}</td>
<td className="border px-2 py-0.5">{r.size ?? ""}</td>
<td className="border px-2 py-0.5 text-right">{fmtNum(r.required_qty)}</td>
<td className="border px-2 py-0.5 text-right">{fmtNum(r.order_qty)}</td>
<td className="border px-2 py-0.5 text-right">{fmtNum(r.production_qty)}</td>
<td className="border px-2 py-0.5">{r.processing_vendor_name ?? r.processing_vendor ?? ""}</td>
<td className="border px-2 py-0.5 text-center">{r.processing_deadline ?? ""}</td>
<td className="border px-2 py-0.5 text-center">{r.grinding_deadline ?? ""}</td>
<EditableSelectCell editable={editMode} value={r.supply_type} options={SUPPLY_TYPE_OPTIONS}
onChange={(v) => updateRow(idx, "supply_type", v)} />
<EditableSelectCell editable={editMode} value={r.make_or_buy} options={MAKE_OR_BUY_OPTIONS}
onChange={(v) => updateRow(idx, "make_or_buy", v)} />
<EditableTextCell editable={editMode} value={r.raw_material_no}
onChange={(v) => updateRow(idx, "raw_material_no", v)} />
<EditableTextCell editable={editMode} value={r.raw_material}
onChange={(v) => updateRow(idx, "raw_material", v)} />
<EditableTextCell editable={editMode} value={(r as any).raw_material_size ?? r.size}
onChange={(v) => updateRow(idx, "raw_material_size", v)} />
<EditableNumCell editable={editMode} value={r.required_qty}
onChange={(v) => updateRow(idx, "required_qty", v)} />
<EditableNumCell editable={editMode} value={r.order_qty}
onChange={(v) => updateRow(idx, "order_qty", v)} />
<EditableNumCell editable={editMode} value={r.production_qty}
onChange={(v) => updateRow(idx, "production_qty", v)} />
<EditableTextCell editable={editMode} value={r.processing_vendor_name ?? r.processing_vendor}
onChange={(v) => updateRow(idx, "processing_vendor", v)} />
<EditableDateCell editable={editMode} value={r.processing_deadline}
onChange={(v) => updateRow(idx, "processing_deadline", v)} />
<EditableDateCell editable={editMode} value={r.grinding_deadline}
onChange={(v) => updateRow(idx, "grinding_deadline", v)} />
<td className="border px-2 py-0.5 text-center"><FolderCell n={r.cu01_cnt} /></td>
<td className="border px-2 py-0.5 text-center"><FolderCell n={r.cu02_cnt} /></td>
<td className="border px-2 py-0.5 text-center"><FolderCell n={r.cu03_cnt} /></td>
<td className="border px-2 py-0.5">{r.remark ?? ""}</td>
<EditableTextCell editable={editMode} value={r.remark}
onChange={(v) => updateRow(idx, "remark", v)} />
</tr>
);
})}
@@ -204,6 +332,74 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) {
);
}
const SUPPLY_TYPE_OPTIONS = [
{ value: "", label: "—" },
{ value: "자급", label: "자급" },
{ value: "사급", label: "사급" },
];
const MAKE_OR_BUY_OPTIONS = [
{ value: "", label: "—" },
{ value: "Make", label: "Make" },
{ value: "Buy", label: "Buy" },
];
function EditableNumCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) {
if (!editable) return <td className="border px-2 py-0.5 text-right">{fmtNum(value)}</td>;
return (
<td className="border px-1 py-0.5 text-right">
<Input
type="number"
step="any"
className="h-6 px-1 py-0 text-xs text-right border-blue-300 focus-visible:ring-1"
value={value ?? ""}
onChange={(e) => onChange(e.target.value === "" ? null : e.target.value)}
/>
</td>
);
}
function EditableTextCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) {
if (!editable) return <td className="border px-2 py-0.5">{value ?? ""}</td>;
return (
<td className="border px-1 py-0.5">
<Input
type="text"
className="h-6 px-1 py-0 text-xs border-blue-300 focus-visible:ring-1"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
/>
</td>
);
}
function EditableDateCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) {
if (!editable) return <td className="border px-2 py-0.5 text-center">{value ?? ""}</td>;
return (
<td className="border px-1 py-0.5">
<Input
type="date"
className="h-6 px-1 py-0 text-xs border-blue-300 focus-visible:ring-1"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
/>
</td>
);
}
function EditableSelectCell({
editable, value, options, onChange,
}: { editable: boolean; value: any; options: { value: string; label: string }[]; onChange: (v: any) => void }) {
if (!editable) return <td className="border px-2 py-0.5 text-center">{value ?? ""}</td>;
return (
<td className="border px-1 py-0.5">
<select
className="h-6 px-1 py-0 text-xs w-full border border-blue-300 rounded"
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
>
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</td>
);
}
function FolderCell({ n }: { n: any }) {
const has = Number(n ?? 0) > 0;
return (
@@ -229,7 +425,6 @@ function fmtNum(v: any): string {
if (v == null || v === "") return "";
const n = Number(v);
if (!isFinite(n)) return String(v);
// 정수면 천 단위, 소수가 있으면 그대로 (4자리 까지 표시)
return Number.isInteger(n)
? n.toLocaleString()
: n.toLocaleString(undefined, { maximumFractionDigits: 4 });
+53
View File
@@ -166,6 +166,55 @@ export interface MbomTreeResponse {
rows: MbomTreeRow[];
}
// ─── 저장 (PR-B1) ───────────────────────────────────────────
// 운영판 saveMbom.do 1:1 — 신규/수정 통합 엔드포인트.
// is_update=false → 새 mbom_header + child_objid 재매핑 후 detail 일괄 insert
// is_update=true → 기존 mbom_header.objid 조회 → UPSERT + 누락 행 delete
export interface MbomSaveRow {
objid?: string | null;
parent_objid?: string | null;
child_objid?: string | null;
seq?: number | string | null;
level?: number | string | null;
part_objid?: string | number | null;
part_no?: string | null;
part_name?: string | null;
qty?: number | string | null;
item_qty?: number | string | null;
unit?: string | null;
supply_type?: string | null;
make_or_buy?: string | null;
raw_material_no?: string | null;
raw_material_spec?: string | null;
raw_material?: string | null;
size?: string | null;
raw_material_size?: string | null;
processing_vendor?: string | null;
processing_deadline?: string | null;
grinding_deadline?: string | null;
required_qty?: number | string | null;
order_qty?: number | string | null;
production_qty?: number | string | null;
remark?: string | null;
}
export interface MbomSavePayload {
project_obj_id: string;
is_update: boolean;
mbom_part_no?: string | null;
rows: MbomSaveRow[];
}
export interface MbomSaveResult {
mode: "CREATE" | "UPDATE";
mbom_header_objid: string;
mbom_no: string;
inserted: number;
updated: number;
deleted: number;
}
export const mbomApi = {
async list(filter: MbomListFilter = {}): Promise<MbomListResponse> {
const res = await apiClient.get("/production/mbom/list", { params: filter });
@@ -179,4 +228,8 @@ export const mbomApi = {
const res = await apiClient.get(`/production/mbom/tree/${encodeURIComponent(objid)}`);
return res.data?.data as MbomTreeResponse;
},
async save(payload: MbomSavePayload): Promise<MbomSaveResult> {
const res = await apiClient.post("/production/mbom/save", payload);
return res.data?.data as MbomSaveResult;
},
};